From 9025496ec55d89618f26145d628cb2c4d5efb0ff Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 16:20:25 +0200 Subject: [PATCH 1/3] feat(studio+components): typed enum fields, palette prefill, generic list rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - closed-set stringly-typed fields become real Rust enums (stepper orientation, chart funnel direction, cursor style and path easing — serde values identical; unknown strings now fail the typed parse as blocking validate errors instead of silent fallbacks; path_easing loses its silent ease_in_out catch-all, the one behavior break, no repo example affected; cursor_style flagged as unconsumed by the painter). The schema-driven inspector renders exact-variant selects automatically - chart::DEFAULT_PALETTE goes pub; an empty chart colors list shows an 'engine palette' hint and + Add color prefills the 8 colors the canvas actually renders (pure palette_prefill map, extensible) - StringList kind for non-color string arrays (axes, categories) with per-entry inputs; third list control triggers the rule of three: generic ListRows shell (render-prop items) now backs ColorRows, NumberList and StringList; radar_data joins the structured-data exclusions --- .../rustmotion-components/src/chart/funnel.rs | 6 +- crates/rustmotion-components/src/chart/mod.rs | 15 +- crates/rustmotion-components/src/cursor.rs | 37 ++- crates/rustmotion-components/src/stepper.rs | 18 +- .../rustmotion-studio/src/editor/inspector.rs | 309 ++++++++++++------ .../src/editor/properties.rs | 96 +++++- crates/rustmotion/src/tests.rs | 21 +- 7 files changed, 371 insertions(+), 131 deletions(-) diff --git a/crates/rustmotion-components/src/chart/funnel.rs b/crates/rustmotion-components/src/chart/funnel.rs index ccb02bc..64a05d2 100644 --- a/crates/rustmotion-components/src/chart/funnel.rs +++ b/crates/rustmotion-components/src/chart/funnel.rs @@ -16,11 +16,7 @@ impl Chart { h: f32, progress: f32, ) -> Result<()> { - let horizontal = self - .direction - .as_deref() - .map(|d| d == "horizontal") - .unwrap_or(false); + let horizontal = self.direction == Some(super::ChartDirection::Horizontal); if horizontal { self.render_funnel_horizontal(canvas, w, h, progress) diff --git a/crates/rustmotion-components/src/chart/mod.rs b/crates/rustmotion-components/src/chart/mod.rs index f115e98..3409e12 100644 --- a/crates/rustmotion-components/src/chart/mod.rs +++ b/crates/rustmotion-components/src/chart/mod.rs @@ -20,10 +20,21 @@ mod radial; mod scatter; mod waterfall; -pub(crate) const DEFAULT_PALETTE: &[&str] = &[ +/// The engine's default series palette. `pub` so the studio can prefill an +/// empty `colors` list with what the canvas actually renders. +pub const DEFAULT_PALETTE: &[&str] = &[ "#3B82F6", "#EF4444", "#22C55E", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316", ]; +/// Funnel flow direction. Closed set (painter matches both variants); JSON +/// values unchanged ("vertical"/"horizontal"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ChartDirection { + Vertical, + Horizontal, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ChartType { @@ -123,7 +134,7 @@ pub struct Chart { // Funnel direction /// Direction for funnel chart: "vertical" (default) or "horizontal". #[serde(default)] - pub direction: Option, + pub direction: Option, // Axes, grid, labels #[serde(default)] diff --git a/crates/rustmotion-components/src/cursor.rs b/crates/rustmotion-components/src/cursor.rs index 28e4ab6..f97ec8c 100644 --- a/crates/rustmotion-components/src/cursor.rs +++ b/crates/rustmotion-components/src/cursor.rs @@ -47,11 +47,11 @@ pub struct Cursor { pub click_duration: f32, /// Visual cursor style: "default" (arrow) or "pointer" (hand). /// Currently both render as a bar; this is metadata for future SVG cursors. - #[serde(default = "default_cursor_style")] - pub cursor_style: String, + #[serde(default)] + pub cursor_style: CursorStyle, /// Easing for movement between waypoints: "ease_in_out" (default), "linear", "ease_out". - #[serde(default = "default_path_easing")] - pub path_easing: String, + #[serde(default)] + pub path_easing: CursorPathEasing, #[serde(flatten)] pub timing: TimingConfig, #[serde(default)] @@ -86,12 +86,24 @@ fn default_click_duration() -> f32 { 0.3 } -fn default_cursor_style() -> String { - "default".to_string() +/// Visual cursor style. Closed documented set; JSON values unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CursorStyle { + #[default] + Default, + Pointer, } -fn default_path_easing() -> String { - "ease_in_out".to_string() +/// Easing of the cursor's waypoint path. Closed set, now matched +/// exhaustively; previously-silent unknown values fail the typed parse. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CursorPathEasing { + Linear, + EaseOut, + #[default] + EaseInOut, } rustmotion_core::impl_traits!(Cursor { @@ -161,11 +173,10 @@ impl Cursor { let raw_t = ((time - move_start) / move_duration).clamp(0.0, 1.0); // Apply easing - let t = match self.path_easing.as_str() { - "linear" => raw_t, - "ease_out" => 1.0 - (1.0 - raw_t).powi(3), - _ => { - // ease_in_out + let t = match self.path_easing { + CursorPathEasing::Linear => raw_t, + CursorPathEasing::EaseOut => 1.0 - (1.0 - raw_t).powi(3), + CursorPathEasing::EaseInOut => { if raw_t < 0.5 { 4.0 * raw_t * raw_t * raw_t } else { diff --git a/crates/rustmotion-components/src/stepper.rs b/crates/rustmotion-components/src/stepper.rs index 49248ef..080646f 100644 --- a/crates/rustmotion-components/src/stepper.rs +++ b/crates/rustmotion-components/src/stepper.rs @@ -20,8 +20,16 @@ fn default_transition_duration() -> f64 { 0.5 } -fn default_orientation() -> String { - "horizontal".to_string() +/// Layout axis of the stepper. Closed set, matched exhaustively by the +/// painter; serde snake_case keeps the JSON values identical +/// ("horizontal"/"vertical") — an unknown value now fails the typed parse +/// (blocking validate error, by design). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum StepperOrientation { + #[default] + Horizontal, + Vertical, } fn default_active_color() -> String { @@ -64,8 +72,8 @@ pub struct Stepper { #[serde(default = "default_transition_duration")] pub transition_duration: f64, /// Layout direction: "horizontal" or "vertical". - #[serde(default = "default_orientation")] - pub orientation: String, + #[serde(default)] + pub orientation: StepperOrientation, /// Color of the active step node. #[serde(default = "default_active_color")] pub active_color: String, @@ -146,7 +154,7 @@ impl Stepper { let emoji_desc_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, desc_font_size)); - let is_horizontal = self.orientation == "horizontal"; + let is_horizontal = self.orientation == StepperOrientation::Horizontal; if is_horizontal { let padding = r + 8.0; diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 30bc84b..4200ac0 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -22,8 +22,8 @@ use super::view::RevSignal; use super::annotations::AnnotationBox; use super::properties::{ component_props, css_family, css_section_props, display_number, effective_element, - engine_placeholder, fill_to_value, is_multiline, parse_fill, slider_range, visible_sections, - FillMode, PropKind, + engine_placeholder, fill_to_value, is_multiline, palette_prefill, parse_fill, slider_range, + visible_sections, FillMode, PropKind, }; // ── Debounce context ───────────────────────────────────────────────────────── @@ -711,6 +711,7 @@ fn RootPropsSection(pointer: String, kind: String, element: serde_json::Value) - prop_kind: spec.kind.clone(), value: prop_str(&effective, &spec.name), is_style: false, + host_tag: kind.clone(), is_default: element.get(&spec.name).is_none() && effective.get(&spec.name).map(|v| !v.is_null()).unwrap_or(false), } @@ -913,6 +914,9 @@ fn GenericRow( /// control folds it into the whole-object write. #[props(default)] custom_commit: Option>, + /// Component tag of the host element (palette prefill lookup). + #[props(default)] + host_tag: String, ) -> Element { let shared = use_context::(); @@ -1095,7 +1099,12 @@ fn GenericRow( } PropKind::ColorList => { rsx! { - ColorListControl { pointer: pointer.clone(), name: name.clone(), value: value.clone() } + ColorListControl { + pointer: pointer.clone(), + name: name.clone(), + value: value.clone(), + host_tag: host_tag.clone(), + } } } PropKind::Fill => { @@ -1121,7 +1130,12 @@ fn GenericRow( NumberListControl { pointer: pointer.clone(), name: name.clone(), value: value.clone() } } } - PropKind::Complex | PropKind::Object(_) | PropKind::NumberList => { + PropKind::StringList if !is_style => { + rsx! { + StringListControl { pointer: pointer.clone(), name: name.clone(), value: value.clone() } + } + } + PropKind::Complex | PropKind::Object(_) | PropKind::NumberList | PropKind::StringList => { let commit = commit.clone(); rsx! { JsonArea { @@ -1571,10 +1585,65 @@ fn ObjectControl( } } +/// Generic per-entry list editor — the shared shell of ColorRows / +/// NumberListControl / StringListControl (rule of three): one row per entry +/// rendered by `render_item`, a remove button per row and an add button. +/// `on_add` overrides the default push (palette prefill). Reordering: none. +#[component] +fn ListRows( + entries: Signal>, + on_change: EventHandler<()>, + add_label: String, + add_value: String, + render_item: Callback<(usize, String), Element>, + #[props(default)] on_add: Option>, +) -> Element { + rsx! { + div { style: "display:flex; flex-direction:column; gap:6px; width:100%;", + for (i, e) in entries().iter().cloned().enumerate() { + div { key: "{i}", style: "display:flex; flex-wrap:wrap; align-items:center; gap:6px;", + {render_item.call((i, e))} + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::IconSm, + title: "Remove entry", + onclick: { + let mut entries = entries; + move |_| { + if entries.read().len() > i { + entries.write().remove(i); + } + on_change.call(()); + } + }, + X { size: 13, stroke: "var(--rm-text-muted)" } + } + } + } + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Xs, + onclick: { + let mut entries = entries; + let add_value = add_value.clone(); + move |_| { + if let Some(cb) = &on_add { + cb.call(()); + } else { + entries.write().push(add_value.clone()); + on_change.call(()); + } + } + }, + "{add_label}" + } + } + } +} + /// Per-entry editor for arrays of numbers (`sparkline_data`, dash patterns…): -/// number input + remove per entry, "+ Add". Writes the whole array (typed) -/// once every entry parses; an emptied list removes the field. Kept separate -/// from `ColorRows` — the item controls share almost nothing. +/// number input per entry via [`ListRows`]. Writes the whole array (typed) +/// once every entry parses; an emptied list removes the field. #[component] fn NumberListControl(pointer: String, name: String, value: String) -> Element { let shared = use_context::(); @@ -1614,134 +1683,153 @@ fn NumberListControl(pointer: String, name: String, value: String) -> Element { }; rsx! { - div { style: "display:flex; flex-direction:column; gap:6px; width:100%;", - for (i, e) in entries().iter().cloned().enumerate() { - div { key: "{i}", style: "display:flex; flex-wrap:wrap; align-items:center; gap:6px;", - input { - r#type: "number", - step: "0.1", - style: "{INPUT_STYLE}", - value: "{e}", - oninput: { - let mut entries = entries; - let write = write.clone(); - move |ev: FormEvent| { + ListRows { + entries, + on_change: write.clone(), + add_label: "+ Add".to_string(), + add_value: "0".to_string(), + render_item: { + let write = write.clone(); + Callback::new(move |(i, e): (usize, String)| { + let mut entries = entries; + let write = write.clone(); + rsx! { + input { + r#type: "number", + step: "0.1", + style: "{INPUT_STYLE}", + value: "{e}", + oninput: move |ev: FormEvent| { if let Some(slot) = entries.write().get_mut(i) { *slot = ev.value(); } write(()); - } - }, - } - Button { - variant: ButtonVariant::Ghost, - size: ButtonSize::IconSm, - title: "Remove entry", - onclick: { - let mut entries = entries; - let write = write.clone(); - move |_| { - if entries.read().len() > i { - entries.write().remove(i); - } - write(()); - } - }, - X { size: 13, stroke: "var(--rm-text-muted)" } + }, + } } - } - } - Button { - variant: ButtonVariant::Outline, - size: ButtonSize::Xs, - onclick: { + }) + }, + } + } +} + +/// Per-entry editor for arrays of non-color strings (chart `axes` / +/// `categories`…): text input per entry via [`ListRows`]. Writes the whole +/// array; an emptied list removes the field. +#[component] +fn StringListControl(pointer: String, name: String, value: String) -> Element { + let shared = use_context::(); + let entries = use_signal(|| serde_json::from_str::>(&value).unwrap_or_default()); + + let write = { + let shared = shared.clone(); + let p = pointer.clone(); + let n = name.clone(); + move |_| { + let list: Vec = entries + .read() + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(); + let value = if list.is_empty() { + serde_json::Value::Null + } else { + serde_json::Value::Array(list) + }; + write_root_field(&shared, &p, &n, value); + } + }; + + rsx! { + ListRows { + entries, + on_change: write.clone(), + add_label: "+ Add".to_string(), + add_value: String::new(), + render_item: { + let write = write.clone(); + Callback::new(move |(i, e): (usize, String)| { let mut entries = entries; let write = write.clone(); - move |_| { - entries.write().push("0".to_string()); - write(()); + rsx! { + input { + r#type: "text", + style: "{INPUT_STYLE}", + value: "{e}", + oninput: move |ev: FormEvent| { + if let Some(slot) = entries.write().get_mut(i) { + *slot = ev.value(); + } + write(()); + }, + } } - }, - "+ Add" - } + }) + }, } } } // ── Color-list & fill editors ──────────────────────────────────────────────── -/// Editable list of color rows (swatch + hex + delete, plus "+ Add color"). -/// Operates on a shared signal so add/remove render immediately (the panel -/// itself is memoized per selection). Reordering: v1 = none. +/// Editable list of color rows (swatch + hex per entry) on the generic +/// [`ListRows`] shell. Operates on a shared signal so add/remove render +/// immediately (the panel itself is memoized per selection). `on_add` +/// overrides the add behavior (palette prefill). Reordering: none. #[component] -fn ColorRows(colors: Signal>, on_change: EventHandler<()>) -> Element { +fn ColorRows( + colors: Signal>, + on_change: EventHandler<()>, + #[props(default)] on_add: Option>, +) -> Element { rsx! { - div { style: "display:flex; flex-direction:column; gap:6px; width:100%;", - for (i, c) in colors().iter().cloned().enumerate() { - div { key: "{i}", style: "display:flex; flex-wrap:wrap; align-items:center; gap:6px;", + ListRows { + entries: colors, + on_change, + add_label: "+ Add color".to_string(), + add_value: "#ffffff".to_string(), + on_add, + render_item: Callback::new(move |(i, c): (usize, String)| { + let mut colors = colors; + rsx! { ColorPicker { color: parse_hsv(&c), - on_color_change: { - let mut colors = colors; - move |hsv: Hsv| { - if let Some(slot) = colors.write().get_mut(i) { - *slot = hsv_to_hex(hsv); - } - on_change.call(()); + on_color_change: move |hsv: Hsv| { + if let Some(slot) = colors.write().get_mut(i) { + *slot = hsv_to_hex(hsv); } + on_change.call(()); }, } input { r#type: "text", style: "{HEX_STYLE}", value: "{c}", - oninput: { - let mut colors = colors; - move |e: FormEvent| { - if let Some(slot) = colors.write().get_mut(i) { - *slot = e.value(); - } - on_change.call(()); - } - }, - } - Button { - variant: ButtonVariant::Ghost, - size: ButtonSize::IconSm, - title: "Remove color", - onclick: { - let mut colors = colors; - move |_| { - if colors.read().len() > i { - colors.write().remove(i); - } - on_change.call(()); + oninput: move |e: FormEvent| { + if let Some(slot) = colors.write().get_mut(i) { + *slot = e.value(); } + on_change.call(()); }, - X { size: 13, stroke: "var(--rm-text-muted)" } } } - } - Button { - variant: ButtonVariant::Outline, - size: ButtonSize::Xs, - onclick: { - let mut colors = colors; - move |_| { - colors.write().push("#ffffff".to_string()); - on_change.call(()); - } - }, - "+ Add color" - } + }), } } } -/// Control for [`PropKind::ColorList`] fields (gradient_text `colors`): -/// per-entry pickers writing the whole array (typed). +/// Control for [`PropKind::ColorList`] fields (gradient_text/chart `colors`): +/// per-entry pickers writing the whole array (typed). For an EMPTY +/// `chart.colors`, "+ Add color" prefills the engine's default palette so the +/// user edits what the canvas renders (see `palette_prefill`). #[component] -fn ColorListControl(pointer: String, name: String, value: String) -> Element { +fn ColorListControl( + pointer: String, + name: String, + value: String, + #[props(default)] host_tag: String, +) -> Element { let shared = use_context::(); let colors = use_signal(|| serde_json::from_str::>(&value).unwrap_or_default()); let write = { @@ -1758,8 +1846,25 @@ fn ColorListControl(pointer: String, name: String, value: String) -> Element { write_root_field(&shared, &p, &n, serde_json::Value::Array(list)); } }; + + let is_empty = colors.read().is_empty(); + let prefill = palette_prefill(&host_tag, &name, is_empty); + let on_add = prefill.map(|palette| { + let write = write.clone(); + Callback::new(move |_: ()| { + let mut colors = colors; + colors.set(palette.iter().map(|c| c.to_string()).collect()); + write(()); + }) + }); + rsx! { - ColorRows { colors, on_change: write } + if is_empty && prefill.is_some() { + div { style: "color:var(--rm-text-muted); font-size:10px; opacity:0.7;", + "engine palette" + } + } + ColorRows { colors, on_change: write.clone(), on_add } } } diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs index 7ddb353..26699d1 100644 --- a/crates/rustmotion-studio/src/editor/properties.rs +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -41,6 +41,7 @@ pub const EXCLUDED_FIELDS: &[&str] = &[ "timeline", // structured data arrays "data", + "radar_data", "spans", "words", "lines", @@ -87,6 +88,9 @@ pub enum PropKind { Object(Vec), /// Array of numbers (stat `sparkline_data`, …) → per-entry number rows. NumberList, + /// Array of non-color strings (chart `axes`/`categories`, …) → per-entry + /// text rows. + StringList, /// Objects/arrays without a known shape → JSON textarea. Complex, } @@ -107,6 +111,24 @@ pub struct PropSpec { pub kind: PropKind, } +/// Default-palette prefill for an EMPTY color list: for `chart.colors`, +/// "+ Add color" seeds the list with the engine's actual rendering palette so +/// the user edits what the canvas shows instead of starting from nothing. +/// Extend the map here when other components gain a palette. +pub fn palette_prefill( + tag: &str, + field: &str, + list_is_empty: bool, +) -> Option<&'static [&'static str]> { + if !list_is_empty { + return None; + } + match (tag, field) { + ("chart", "colors") => Some(rustmotion::components::chart::DEFAULT_PALETTE), + _ => None, + } +} + /// Mutate one sub-key of an object value: `Null` prunes the key; an object /// left empty collapses to `Null` (the whole field gets removed). pub fn mutate_object_field(current: &Value, key: &str, new: Value) -> Value { @@ -325,6 +347,7 @@ fn kind_of_schema_inner( Some("string") => PropKind::String, Some("array") if is_color_string_array(name, schema, defs, depth) => PropKind::ColorList, Some("array") if is_number_array(schema, defs, depth) => PropKind::NumberList, + Some("array") if is_string_array(schema, defs, depth) => PropKind::StringList, _ => PropKind::Complex, } } @@ -347,6 +370,21 @@ fn object_specs(schema: &Value, defs: &Value, depth: u8, obj_level: u8) -> Optio ) } +/// Array whose items resolve to plain strings (labels, axes, categories) — +/// color-string arrays are caught earlier by `is_color_string_array`. +fn is_string_array(schema: &Value, defs: &Value, depth: u8) -> bool { + let Some(items) = schema.get("items") else { + return false; + }; + let resolved = resolve_arm(items, defs, depth + 1); + if primary_type(&resolved) == Some("string") { + return true; + } + let mut info = UnionInfo::default(); + collect_union(items, defs, depth + 1, &mut info); + info.has_string && !info.has_number +} + /// Array whose items resolve to numbers (`Vec`, dash patterns, …). fn is_number_array(schema: &Value, defs: &Value, depth: u8) -> bool { let Some(items) = schema.get("items") else { @@ -805,6 +843,60 @@ mod tests { } } + // ── Round 6: string lists / palette prefill / typed enums ─────────── + + #[test] + fn chart_axes_and_categories_are_string_lists() { + let props = component_props("chart").expect("chart in schema"); + assert_eq!(kind_of(props, "axes"), Some(&PropKind::StringList)); + assert_eq!(kind_of(props, "categories"), Some(&PropKind::StringList)); + // colors keeps the color editor, sparkline stays numeric (stat). + assert_eq!(kind_of(props, "colors"), Some(&PropKind::ColorList)); + let stat = component_props("stat").unwrap(); + assert_eq!(kind_of(stat, "sparkline_data"), Some(&PropKind::NumberList)); + } + + #[test] + fn radar_data_is_excluded_from_properties() { + let props = component_props("chart").expect("chart in schema"); + assert!( + !props.iter().any(|p| p.name == "radar_data"), + "radar_data is chart data — excluded from the generic editor" + ); + } + + #[test] + fn palette_prefill_only_for_empty_chart_colors() { + let palette = palette_prefill("chart", "colors", true).expect("chart palette"); + assert_eq!(palette.len(), 8); + assert_eq!(palette[0], "#3B82F6"); + // Non-empty list → no prefill (don't clobber user colors). + assert_eq!(palette_prefill("chart", "colors", false), None); + // Other tags/fields → None. + assert_eq!(palette_prefill("gradient_text", "colors", true), None); + assert_eq!(palette_prefill("chart", "axes", true), None); + } + + #[test] + fn converted_enum_fields_expose_exact_variants() { + let stepper = component_props("stepper").expect("stepper in schema"); + match kind_of(stepper, "orientation") { + Some(PropKind::Enum(v)) => { + assert!(v.contains(&"horizontal".to_string()), "{v:?}"); + assert!(v.contains(&"vertical".to_string()), "{v:?}"); + } + other => panic!("orientation should be Enum, got {other:?}"), + } + let chart = component_props("chart").expect("chart in schema"); + match kind_of(chart, "direction") { + Some(PropKind::Enum(v)) => { + assert!(v.contains(&"vertical".to_string()), "{v:?}"); + assert!(v.contains(&"horizontal".to_string()), "{v:?}"); + } + other => panic!("direction should be Enum, got {other:?}"), + } + } + // ── Round 5: object / number-list editors ─────────────────────────── #[test] @@ -852,11 +944,11 @@ mod tests { // Color arrays keep their dedicated editor. let gt = component_props("gradient_text").unwrap(); assert_eq!(kind_of(gt, "colors"), Some(&PropKind::ColorList)); - // String arrays are neither. + // Plain string arrays get their own editor (round 6). let strings = serde_json::json!({"type": "array", "items": {"type": "string"}}); assert_eq!( kind_of_schema("labels", &strings, &Value::Null, 0), - PropKind::Complex + PropKind::StringList ); } diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 7a52a79..0cb3163 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -11,7 +11,10 @@ mod component_smoke { ("icon", r#"{"type":"icon","icon":"check"}"#), ("svg", r#"{"type":"svg","content":""}"#), ("counter", r#"{"type":"counter","from":0,"to":100}"#), - ("cursor", r#"{"type":"cursor"}"#), + ( + "cursor", + r#"{"type":"cursor","cursor_style":"pointer","path_easing":"linear"}"#, + ), ("codeblock", r#"{"type":"codeblock","code":"fn main() {}"}"#), ("badge", r#"{"type":"badge","text":"New"}"#), ("callout", r#"{"type":"callout","text":"Hello"}"#), @@ -19,6 +22,10 @@ mod component_smoke { "chart", r#"{"type":"chart","chart_type":"bar","data":[{"value":10}]}"#, ), + ( + "chart", + r#"{"type":"chart","chart_type":"funnel","direction":"horizontal","data":[{"value":10}]}"#, + ), ("countdown", r#"{"type":"countdown","seconds":60}"#), ("divider", r#"{"type":"divider"}"#), ("gauge", r#"{"type":"gauge","value":50}"#), @@ -49,7 +56,7 @@ mod component_smoke { ("stat", r#"{"type":"stat","value":"42","label":"Users"}"#), ( "stepper", - r#"{"type":"stepper","steps":[{"label":"Step 1"},{"label":"Step 2"}]}"#, + r#"{"type":"stepper","orientation":"vertical","steps":[{"label":"Step 1"},{"label":"Step 2"}]}"#, ), ("switch", r#"{"type":"switch"}"#), ( @@ -221,6 +228,16 @@ mod component_smoke { } } + #[test] + fn unknown_enum_value_fails_the_typed_parse() { + // Converted stringly-typed fields (stepper.orientation, + // chart.direction, cursor.cursor_style/path_easing) now reject + // unknown values at the typed parse — a blocking validate error + // instead of a silent fallback (philosophy of #33). + let bad = r#"{"type":"stepper","orientation":"diagonal","steps":[{"label":"A"}]}"#; + assert!(serde_json::from_str::(bad).is_err()); + } + #[test] fn corpus_covers_every_component_tag() { // Every `type` tag advertised by the generated JSON schema must have From 1595accdfabda5194dab9da9a5ff67a9da4453ea Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 16:39:49 +0200 Subject: [PATCH 2/3] fix(studio): data-driven color prefill and pixel-proven chart edit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosis of 'cannot modify the chart color' on the round-6 build: - data path exonerated by a new integration test that drives the exact button writes through apply_optimistic on the real promo scenario (nested scene->div->card->chart pointer) and asserts red pixels appear in the re-rendered frame - the fragile link was the prefill's on_add callback forwarded through two prop layers; replaced by a pure, tested next_entries_on_add(current, add_value, prefill) consumed directly by ListRows with the palette as data — one code path for all three list kinds, empty-list gating evaluated at click time (immune to panel memoization) - UX trap proven by a dedicated assertion: the prefill write renders a canvas strictly identical to the default palette, so a user who clicks Add sees no change until editing a swatch — hint reinforced to 'engine palette — click + to edit' - NumberList/StringList empty-add covered (exactly one seeded entry, no lost clicks) --- .../rustmotion-studio/src/editor/inspector.rs | 38 ++--- .../src/editor/properties.rs | 38 +++++ .../src/scenario/optimistic.rs | 134 ++++++++++++++++++ 3 files changed, 187 insertions(+), 23 deletions(-) diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 4200ac0..686213f 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -22,8 +22,8 @@ use super::view::RevSignal; use super::annotations::AnnotationBox; use super::properties::{ component_props, css_family, css_section_props, display_number, effective_element, - engine_placeholder, fill_to_value, is_multiline, palette_prefill, parse_fill, slider_range, - visible_sections, FillMode, PropKind, + engine_placeholder, fill_to_value, is_multiline, next_entries_on_add, palette_prefill, + parse_fill, slider_range, visible_sections, FillMode, PropKind, }; // ── Debounce context ───────────────────────────────────────────────────────── @@ -1588,7 +1588,8 @@ fn ObjectControl( /// Generic per-entry list editor — the shared shell of ColorRows / /// NumberListControl / StringListControl (rule of three): one row per entry /// rendered by `render_item`, a remove button per row and an add button. -/// `on_add` overrides the default push (palette prefill). Reordering: none. +/// The add decision is the pure `next_entries_on_add` (an empty list with a +/// `prefill` gets seeded with it). Reordering: none. #[component] fn ListRows( entries: Signal>, @@ -1596,7 +1597,7 @@ fn ListRows( add_label: String, add_value: String, render_item: Callback<(usize, String), Element>, - #[props(default)] on_add: Option>, + #[props(default)] prefill: Option<&'static [&'static str]>, ) -> Element { rsx! { div { style: "display:flex; flex-direction:column; gap:6px; width:100%;", @@ -1627,12 +1628,9 @@ fn ListRows( let mut entries = entries; let add_value = add_value.clone(); move |_| { - if let Some(cb) = &on_add { - cb.call(()); - } else { - entries.write().push(add_value.clone()); - on_change.call(()); - } + let next = next_entries_on_add(&entries.read(), &add_value, prefill); + entries.set(next); + on_change.call(()); } }, "{add_label}" @@ -1781,7 +1779,7 @@ fn StringListControl(pointer: String, name: String, value: String) -> Element { fn ColorRows( colors: Signal>, on_change: EventHandler<()>, - #[props(default)] on_add: Option>, + #[props(default)] prefill: Option<&'static [&'static str]>, ) -> Element { rsx! { ListRows { @@ -1789,7 +1787,7 @@ fn ColorRows( on_change, add_label: "+ Add color".to_string(), add_value: "#ffffff".to_string(), - on_add, + prefill, render_item: Callback::new(move |(i, c): (usize, String)| { let mut colors = colors; rsx! { @@ -1847,24 +1845,18 @@ fn ColorListControl( } }; + // Palette lookup is static per (tag, field); the pure add decision in + // ListRows gates on CURRENT emptiness at click time. + let prefill = palette_prefill(&host_tag, &name, true); let is_empty = colors.read().is_empty(); - let prefill = palette_prefill(&host_tag, &name, is_empty); - let on_add = prefill.map(|palette| { - let write = write.clone(); - Callback::new(move |_: ()| { - let mut colors = colors; - colors.set(palette.iter().map(|c| c.to_string()).collect()); - write(()); - }) - }); rsx! { if is_empty && prefill.is_some() { div { style: "color:var(--rm-text-muted); font-size:10px; opacity:0.7;", - "engine palette" + "engine palette — click + to edit" } } - ColorRows { colors, on_change: write.clone(), on_add } + ColorRows { colors, on_change: write.clone(), prefill } } } diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs index 26699d1..31f875f 100644 --- a/crates/rustmotion-studio/src/editor/properties.rs +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -129,6 +129,25 @@ pub fn palette_prefill( } } +/// Next entries after a "+ Add" click (pure, single decision point for every +/// list editor): an EMPTY list with a prefill (chart palette) is seeded with +/// it — the user gets all 8 editable rows at once; otherwise one default +/// entry is appended. +pub fn next_entries_on_add( + current: &[String], + add_value: &str, + prefill: Option<&'static [&'static str]>, +) -> Vec { + match prefill { + Some(palette) if current.is_empty() => palette.iter().map(|c| c.to_string()).collect(), + _ => { + let mut v = current.to_vec(); + v.push(add_value.to_string()); + v + } + } +} + /// Mutate one sub-key of an object value: `Null` prunes the key; an object /// left empty collapses to `Null` (the whole field gets removed). pub fn mutate_object_field(current: &Value, key: &str, new: Value) -> Value { @@ -897,6 +916,25 @@ mod tests { } } + // ── Chart-colors bug fix: unified add decision ────────────────────── + + #[test] + fn add_click_seeds_palette_or_appends() { + let palette = palette_prefill("chart", "colors", true).unwrap(); + // Empty chart colors + prefill → all 8 editable rows at once. + let seeded = next_entries_on_add(&[], "#ffffff", Some(palette)); + assert_eq!(seeded.len(), 8); + assert_eq!(seeded[0], "#3B82F6"); + // Non-empty list → plain append even with a prefill available. + let appended = next_entries_on_add(&seeded, "#ffffff", Some(palette)); + assert_eq!(appended.len(), 9); + assert_eq!(appended[8], "#ffffff"); + // NumberList / StringList (no prefill): empty list appends ONE default + // entry — no palette-style seeding, no lost click. + assert_eq!(next_entries_on_add(&[], "0", None), vec!["0".to_string()]); + assert_eq!(next_entries_on_add(&[], "", None), vec![String::new()]); + } + // ── Round 5: object / number-list editors ─────────────────────────── #[test] diff --git a/crates/rustmotion-studio/src/scenario/optimistic.rs b/crates/rustmotion-studio/src/scenario/optimistic.rs index 3d350be..adf3ae3 100644 --- a/crates/rustmotion-studio/src/scenario/optimistic.rs +++ b/crates/rustmotion-studio/src/scenario/optimistic.rs @@ -237,6 +237,140 @@ mod tests { { "type": "text", "content": "Hi", "style": { "font-size": 48 } } ] } ] }"##; + // ── Integration: full chart-colors write path (user bug repro) ────── + + /// End-to-end repro of "editing chart colors does nothing": promo file → + /// optimistic Field mutation with the 8-color palette (the prefill + /// write), second mutation turning colors[0] red, rebuild, pixel check on + /// the chart's scene. Proves the DATA path (pointer → set_field_value → + /// rebuild → painter `get_color`) end to end. + #[test] + fn chart_colors_edit_reaches_the_rendered_pixels() { + let promo = std::path::Path::new("../../examples/rustmotion-promo.json"); + if !promo.exists() { + panic!("examples/rustmotion-promo.json missing"); + } + // Real open path: typed scenario loaded from the file (model_for uses + // an empty scenario and would have no frames before the first edit). + let loaded = rustmotion::loader::load_input(&promo.to_path_buf()).expect("promo loads"); + let shared: Shared = Arc::new(Mutex::new(StudioModel::new( + loaded, + None, + Some(promo.to_path_buf()), + ))); + + // Find the first chart component in the raw (nested scene→div→card→chart). + fn find_chart(node: &Value, ptr: String, out: &mut Option) { + if out.is_some() { + return; + } + if node.get("type").and_then(|t| t.as_str()) == Some("chart") { + *out = Some(ptr); + return; + } + if let Some(children) = node.get("children").and_then(|c| c.as_array()) { + for (i, c) in children.iter().enumerate() { + find_chart(c, format!("{ptr}/children/{i}"), out); + } + } + } + let (pointer, scene_idx) = { + let m = shared.lock().unwrap(); + let scenes = m.raw["scenes"].as_array().expect("promo scenes").clone(); + let mut found = None; + let mut scene_idx = 0usize; + for (si, scene) in scenes.iter().enumerate() { + let mut ptr = None; + find_chart(scene, format!("/scenes/{si}"), &mut ptr); + if let Some(p) = ptr { + found = Some(p); + scene_idx = si; + break; + } + } + (found.expect("promo contains a chart"), scene_idx) + }; + + let render_scene = |shared: &Shared| -> Vec { + let m = shared.lock().unwrap(); + let base = m + .tasks + .iter() + .position(|t| { + matches!(t, rustmotion::encode::video::FrameTask::Normal { scene_idx: s, .. } + if *s == scene_idx) + }) + .expect("scene has frames"); + // Mid-scene so staggered entrances have landed. + let idx = (base + 45).min(m.tasks.len() - 1); + rustmotion::encode::render_frame_task_scaled( + &m.scenario.video, + &m.scenario, + &m.tasks[idx], + 0.25, + ) + .expect("render") + }; + let count_red = |rgba: &[u8]| { + rgba.chunks_exact(4) + .filter(|p| p[0] > 180 && p[1] < 90 && p[2] < 90) + .count() + }; + + let before = render_scene(&shared); + + // Edit 1: the prefill write (exactly what "+ Add color" commits). + let palette: Vec = rustmotion::components::chart::DEFAULT_PALETTE + .iter() + .map(|c| Value::String(c.to_string())) + .collect(); + apply_optimistic( + &shared, + &Mutation::Field { + pointer: pointer.clone(), + field: "colors".into(), + value: Value::Array(palette.clone()), + }, + ) + .expect("palette write applies"); + // Identical palette → the canvas must NOT change (the UX trap). + let after_prefill = render_scene(&shared); + assert_eq!( + count_red(&before), + count_red(&after_prefill), + "prefill palette renders identically by design" + ); + + // Edit 2: the user picks red for the first series. + let mut reddened = palette; + reddened[0] = Value::String("#FF0000".into()); + apply_optimistic( + &shared, + &Mutation::Field { + pointer: pointer.clone(), + field: "colors".into(), + value: Value::Array(reddened), + }, + ) + .expect("red write applies"); + { + let m = shared.lock().unwrap(); + assert_eq!( + m.raw.pointer(&pointer).unwrap()["colors"][0], + serde_json::json!("#FF0000") + ); + } + let after_red = render_scene(&shared); + // At 0.25 scale the red series line is thin — a clear nonzero jump + // is the signal (measured ~14 px; before: 0). + assert!( + count_red(&after_red) >= count_red(&before) + 10, + "chart must actually turn red: before={} after={}", + count_red(&before), + count_red(&after_red) + ); + } + // ── Self-write skip decision ──────────────────────────────────────── #[test] From 92e3c6f57089d7d665604cbce739765320298593 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 21:05:03 +0200 Subject: [PATCH 3/3] feat(studio): export toast and effective CSS defaults everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the export status leaves the topbar (its unbounded spans were overlapping the centered title) for a floating toast at the canvas's bottom-right: live progress, success auto-dismissed after ~6s (guarded against a new export starting meanwhile), persistent error with close; remaining topbar center occupants verified bounded (Saving…, ellipsized write_error) - css_effective_default table (each entry sourced: painter, taffy, CSS spec, builder) + per-component typography defaults (text 48 / terminal-codeblock 14, unknown tag stays a placeholder rather than a wrong number) + engine placeholders (auto/none/–) route through css_row_value(raw, tag, prop) -> (shown, is_default): the raw always wins, cleared controls fall back visually to the default state; applied to both curated rows (opacity slider shows 1.0, Position preselects static) and generic registry rows, with the same dimmed default dot as the Properties section --- crates/rustmotion-studio/src/editor/export.rs | 63 ++++++++ .../rustmotion-studio/src/editor/inspector.rs | 35 ++++- .../src/editor/properties.rs | 145 +++++++++++++++++- crates/rustmotion-studio/src/editor/topbar.rs | 17 -- crates/rustmotion-studio/src/editor/view.rs | 5 +- 5 files changed, 237 insertions(+), 28 deletions(-) diff --git a/crates/rustmotion-studio/src/editor/export.rs b/crates/rustmotion-studio/src/editor/export.rs index f0cc248..3a613a1 100644 --- a/crates/rustmotion-studio/src/editor/export.rs +++ b/crates/rustmotion-studio/src/editor/export.rs @@ -210,6 +210,69 @@ fn ffmpeg_available() -> bool { .unwrap_or(false) } +/// Floating export-status toast, bottom-right of the canvas area (absolute in +/// the canvas container — no measurement). Running → live progression; +/// success → auto-dismissed after ~6 s; failure → persistent with a close +/// button. Replaces the old topbar status spans, which overlapped the +/// centered scenario title. +#[component] +pub fn ExportToast() -> Element { + let export = use_hook(export_slot); + let status = use_signal(|| ExportStatus::Idle); + use_export_poll(export.clone(), status); + let mut dismissed = use_signal(|| None::); + + // Auto-dismiss success ~6 s after it appears (a NEW export produces a + // different status value, so the toast reappears naturally). + use_effect(move || { + let s = status(); + if matches!(s, ExportStatus::Done(_)) && dismissed.peek().as_ref() != Some(&s) { + spawn(async move { + tokio::time::sleep(Duration::from_secs(6)).await; + if *status.peek() == s { + dismissed.set(Some(s)); + } + }); + } + }); + + let s = status(); + if matches!(s, ExportStatus::Idle) || dismissed() == Some(s.clone()) { + return rsx! {}; + } + + rsx! { + div { style: "position:absolute; right:16px; bottom:16px; z-index:900; display:flex; align-items:center; gap:8px; max-width:60%; padding:8px 12px; font-size:12px; background:var(--rm-surface-2); border:1px solid var(--rm-border); border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,0.35);", + match &s { + ExportStatus::Running { .. } => rsx! { + span { style: "color:var(--rm-text); white-space:nowrap;", "{export_label(&s)}" } + }, + ExportStatus::Done(path) => rsx! { + span { + title: "{path.display()}", + style: "color:var(--rm-text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;", + "Exported: {path.display()}" + } + }, + ExportStatus::Failed(reason) => rsx! { + span { + title: "{reason}", + style: "color:var(--rm-error); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;", + "Export failed: {reason}" + } + button { + style: "background:none; border:none; color:var(--rm-text-muted); cursor:pointer; font-size:13px; padding:0 2px;", + title: "Dismiss", + onclick: move |_| dismissed.set(Some(s.clone())), + "✕" + } + }, + ExportStatus::Idle => rsx! {}, + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 686213f..50b7125 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -21,9 +21,9 @@ use super::view::RevSignal; use super::annotations::AnnotationBox; use super::properties::{ - component_props, css_family, css_section_props, display_number, effective_element, - engine_placeholder, fill_to_value, is_multiline, next_entries_on_add, palette_prefill, - parse_fill, slider_range, visible_sections, FillMode, PropKind, + component_props, css_family, css_row_value, css_section_props, display_number, + effective_element, engine_placeholder, fill_to_value, is_multiline, next_entries_on_add, + palette_prefill, parse_fill, slider_range, visible_sections, FillMode, PropKind, }; // ── Debounce context ───────────────────────────────────────────────────────── @@ -649,6 +649,7 @@ pub fn InspectorPanel( section: *section, pointer: pointer.clone(), style: style.clone(), + tag: kind.clone(), } } GenericCssSections { pointer: pointer.clone(), kind: kind.clone(), style: style.clone() } @@ -824,8 +825,9 @@ fn GenericCssSections(pointer: String, kind: String, style: serde_json::Value) - pointer: pointer.clone(), name: spec.name.clone(), prop_kind: spec.kind.clone(), - value: prop_str(&style, &spec.name), + value: css_row_value(prop_str(&style, &spec.name), &kind, &spec.name).0, is_style: true, + is_default: css_row_value(prop_str(&style, &spec.name), &kind, &spec.name).1, } } } @@ -1220,7 +1222,12 @@ fn ContentEditor(pointer: String, content: String) -> Element { /// One labelled, uppercase-headed section with its rows. #[component] -fn SectionView(section: Section, pointer: String, style: serde_json::Value) -> Element { +fn SectionView( + section: Section, + pointer: String, + style: serde_json::Value, + tag: String, +) -> Element { rsx! { div { style: "display:flex; flex-direction:column; gap:8px; padding:12px 14px; border-top:1px solid var(--rm-border);", div { style: "{SECTION_HEADER}", "{section.title}" } @@ -1230,6 +1237,7 @@ fn SectionView(section: Section, pointer: String, style: serde_json::Value) -> E field: *field, pointer: pointer.clone(), style: style.clone(), + tag: tag.clone(), } } } @@ -1238,9 +1246,13 @@ fn SectionView(section: Section, pointer: String, style: serde_json::Value) -> E /// One property row: label on the left, the typed control on the right. #[component] -fn FieldRow(field: Field, pointer: String, style: serde_json::Value) -> Element { +fn FieldRow(field: Field, pointer: String, style: serde_json::Value, tag: String) -> Element { let shared = use_context::(); - let value = prop_str(&style, field.name); + // Effective view: an unset property displays the ENGINE default (opacity + // slider at 1.0, Position preselecting "static", spacing at 0…) with the + // dimmed default marker; the raw value always wins. Emptying a control + // still removes the key (back to the default state). + let (value, is_default) = css_row_value(prop_str(&style, field.name), &tag, field.name); let control = match field.ctrl { Ctrl::Text | Ctrl::Number => { @@ -1510,7 +1522,14 @@ fn FieldRow(field: Field, pointer: String, style: serde_json::Value) -> Element rsx! { div { style: "display:flex; align-items:center; gap:8px; min-height:26px;", - span { style: "width:76px; flex:none; color:var(--rm-text-muted); font-size:11px;", "{field.label}" } + span { + style: "width:76px; flex:none; color:var(--rm-text-muted); font-size:11px;", + title: if is_default { "{field.label} — engine default (not set in the source)" } else { "{field.label}" }, + "{field.label}" + if is_default { + span { style: "margin-left:4px; color:var(--rm-text-muted); opacity:0.6;", "•" } + } + } div { style: "flex:1; min-width:0; display:flex; justify-content:flex-end;", {control} } } } diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs index 31f875f..ef85025 100644 --- a/crates/rustmotion-studio/src/editor/properties.rs +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -236,15 +236,93 @@ pub fn is_multiline(name: &str, value: &str) -> bool { matches!(name, "code" | "content" | "message") || value.contains('\n') } -/// Engine-default placeholder for string inputs (cascade defaults the schema -/// can't know): `font-family` → "Inter". +/// Engine-default placeholder for string inputs — properties whose effective +/// default has no displayable VALUE (auto-sized boxes, unset backgrounds). pub fn engine_placeholder(name: &str) -> Option<&'static str> { match name { "font-family" => Some("Inter"), + "background" => Some("none"), + "width" | "height" | "min-width" | "min-height" | "max-width" | "max-height" => { + Some("auto") + } + "line-height" | "letter-spacing" => Some("–"), _ => None, } } +/// Effective ENGINE defaults for CSS properties left unset in the source — +/// what the renderer actually uses, so the inspector shows the truth instead +/// of empty controls (same honesty as the Properties round-trip). `CssStyle` +/// is all-`Option`, so this is a semantic table (source noted per entry), +/// not schema-derived. +pub fn css_effective_default(prop: &str) -> Option { + let v = match prop { + // Painter: full opacity when unset. + "opacity" => Value::from(1.0), + // CSS initial value; the layout treats unset as static flow. + "position" => Value::from("static"), + // Engine absolute-offset defaults (unset offset = 0). + "top" | "right" | "bottom" | "left" => Value::from(0), + // Paint order default. + "z-index" => Value::from(0), + // Taffy defaults: no spacing when unset. + "padding" | "margin" | "gap" => Value::from(0), + // Painter: square corners when unset. + "border-radius" => Value::from(0), + // CSS initial / engine clipping default. + "overflow" | "overflow-x" | "overflow-y" => Value::from("visible"), + "visibility" => Value::from("visible"), + // Scene/box-builder default (scenes flow as columns). + "flex-direction" => Value::from("column"), + // Taffy defaults for unset alignment. + "align-items" => Value::from("start"), + "justify-content" => Value::from("start"), + // CSS spec initial values (taffy follows them). + "flex-grow" => Value::from(0), + "flex-shrink" => Value::from(1), + // Cascade default weight (Regular · 400 — matches the curated select). + "font-weight" => Value::from("400"), + _ => return None, + }; + Some(v) +} + +/// Painter-constant text sizes per component (the typographic default is NOT +/// global: text paints at 48px, terminal/codeblock at 14px). Unknown tags → +/// None (placeholder instead of a wrong number). +pub fn text_default_font_size(tag: &str) -> Option { + match tag { + // text.rs: `font_size_px_or(48.0)`. + "text" | "caption" | "gradient_text" => Some(48.0), + // terminal.rs `FONT_SIZE: f32 = 14.0`; codeblock uses the same size. + "terminal" | "codeblock" => Some(14.0), + _ => None, + } +} + +/// Display default for one CSS row of a given component: per-tag font-size, +/// white text for the text family (renderer default), else the global table. +pub fn css_display_default(tag: &str, prop: &str) -> Option { + match prop { + "font-size" => text_default_font_size(tag).map(Value::from), + "color" if css_family(tag) == CssFamily::TextLike => Some(Value::from("#FFFFFF")), + _ => css_effective_default(prop), + } +} + +/// `(displayed value, is_default)` for a CSS row: the raw value wins; when +/// absent, the effective default is shown with the dimmed default marker. +pub fn css_row_value(raw: String, tag: &str, prop: &str) -> (String, bool) { + if !raw.is_empty() { + return (raw, false); + } + match css_display_default(tag, prop) { + Some(Value::String(s)) => (s, true), + Some(other) => (display_number(&other.to_string()), true), + None => (String::new(), false), + } +} + /// Root fields (name + kind) for a component tag, schema order. `None` for /// unknown tags. pub fn component_props(tag: &str) -> Option<&'static Vec> { @@ -916,6 +994,69 @@ mod tests { } } + // ── Round 7: effective CSS defaults ───────────────────────────────── + + #[test] + fn css_effective_default_table_entries() { + assert_eq!(css_effective_default("opacity"), Some(Value::from(1.0))); + assert_eq!( + css_effective_default("position"), + Some(Value::from("static")) + ); + assert_eq!(css_effective_default("padding"), Some(Value::from(0))); + assert_eq!( + css_effective_default("overflow"), + Some(Value::from("visible")) + ); + assert_eq!(css_effective_default("flex-shrink"), Some(Value::from(1))); + // No displayable default → placeholder territory. + assert_eq!(css_effective_default("background"), None); + assert_eq!(css_effective_default("width"), None); + assert_eq!(engine_placeholder("background"), Some("none")); + assert_eq!(engine_placeholder("width"), Some("auto")); + } + + #[test] + fn font_size_default_is_per_component() { + assert_eq!(text_default_font_size("terminal"), Some(14.0)); + assert_eq!(text_default_font_size("codeblock"), Some(14.0)); + assert_eq!(text_default_font_size("text"), Some(48.0)); + assert_eq!(text_default_font_size("made_up_tag"), None); + // Routed through the display default too. + assert_eq!( + css_display_default("terminal", "font-size"), + Some(Value::from(14.0)) + ); + assert_eq!( + css_display_default("text", "color"), + Some(Value::from("#FFFFFF")) + ); + assert_eq!(css_display_default("shape", "color"), None); + } + + #[test] + fn css_row_value_prefers_raw_and_marks_defaults() { + // Raw present → raw, not a default. + assert_eq!( + css_row_value("12px".into(), "text", "opacity"), + ("12px".to_string(), false) + ); + // Raw absent + known default → displayed value with the marker. + assert_eq!( + css_row_value(String::new(), "text", "opacity"), + ("1".to_string(), true) + ); + assert_eq!( + css_row_value(String::new(), "card", "position"), + ("static".to_string(), true) + ); + // Raw absent, no default → empty, no marker (placeholder shows). + assert_eq!( + css_row_value(String::new(), "card", "background"), + (String::new(), false) + ); + } + // ── Chart-colors bug fix: unified add decision ────────────────────── #[test] diff --git a/crates/rustmotion-studio/src/editor/topbar.rs b/crates/rustmotion-studio/src/editor/topbar.rs index 41b06a1..6c7a9f2 100644 --- a/crates/rustmotion-studio/src/editor/topbar.rs +++ b/crates/rustmotion-studio/src/editor/topbar.rs @@ -159,23 +159,6 @@ pub fn TopBar( "Changes not saved: {msg}" } } - match &status { - ExportStatus::Done(path) => rsx! { - span { - title: "{path.display()}", - style: "color:var(--rm-text-muted); font-size:11px; white-space:nowrap; max-width:240px; overflow:hidden; text-overflow:ellipsis;", - "Exported: {path.display()}" - } - }, - ExportStatus::Failed(reason) => rsx! { - span { - title: "{reason}", - style: "color:var(--rm-error); font-size:11px; white-space:nowrap; max-width:240px; overflow:hidden; text-overflow:ellipsis;", - "Export failed: {reason}" - } - }, - _ => rsx! {}, - } Button { variant: ButtonVariant::Ghost, size: ButtonSize::IconSm, diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index a12d3e5..c8496c5 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -10,6 +10,7 @@ use crate::scenario::{ use super::annotations::AnnotationsPanel; use super::diff_panel::{DiffPanel, DiffSide}; +use super::export::ExportToast; use super::frames::{baseline_arcs, frame_hits, render_frame, scene_prefix, HitPct}; use super::inspector::InspectorPanel; use super::playback::{ @@ -419,7 +420,8 @@ fn Canvas( rsx! { div { - style: "flex:1; min-width:0; min-height:0; display:flex; align-items:center; justify-content:center; padding:16px; overflow:hidden;", + // position:relative anchors the export toast (bottom-right). + style: "position:relative; flex:1; min-width:0; min-height:0; display:flex; align-items:center; justify-content:center; padding:16px; overflow:hidden;", // Clicking the empty canvas (not an element) clears the selection. onclick: move |_| selected.set(None), // Wrapper carries the video's aspect ratio and is capped at 100% of the @@ -434,6 +436,7 @@ fn Canvas( } Overlay { hits, selected, diff_marks } } + ExportToast {} } } }