Skip to main content

rustmotion_components/
box_builder.rs

1//! Bridge from the `Component` tree to the new `BoxNode` tree.
2//!
3//! Each `ChildComponent` becomes one `BoxNode`. The component's
4//! `style: CssStyle` is augmented with:
5//! - `position: absolute` + `top` / `left` when `child.position` is set
6//! - `width` / `height` from the component's `size` field (if any)
7//! - `z-index` from the child's `z_index` field
8//!
9//! Container components (Card / Flex / Grid / Container / Positioned)
10//! recursively build child boxes. Leaf components produce an empty-children
11//! box that the dispatcher will paint.
12//!
13//! The builder also returns a flat `Vec<&Component>` indexed by NodeId so
14//! the painter can resolve a node back to its component.
15
16use std::sync::Arc;
17
18use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize};
19use rustmotion_core::css::{apply_animated_props, LengthPercentage as CLP};
20use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties};
21use rustmotion_core::engine::box_tree::{BoxKind, BoxNode, NodeId};
22use rustmotion_core::schema::video::{AnimationEffect, MotionBlurConfig, TrailConfig};
23
24use crate::callout::ArrowDirection as CalloutArrowDirection;
25use crate::chart::ChartType;
26use crate::divider::DividerDirection;
27use crate::mockup::MockupDevice;
28use crate::skeleton::SkeletonVariant;
29use crate::stepper::StepperOrientation;
30use crate::timeline::TimelineDirection;
31use crate::tooltip::TooltipArrow;
32use crate::{ChildComponent, Component};
33
34/// Frame-level context passed into the builder so animations can be resolved
35/// per-node and merged into the resulting `CssStyle`. When `None`, the box
36/// tree is built without any animation overrides (resting state).
37#[derive(Debug, Clone, Copy)]
38pub struct BuildAnimationCtx {
39    pub time: f64,
40    /// Seconds since the scenario started, as opposed to `time`, which
41    /// restarts at every scene. Only the `audio-reactive` binding reads it:
42    /// the audio analysis is indexed on the scenario's timeline, so using
43    /// `time` gave a scene starting at t=73 s the analysis at 73 s *into that
44    /// scene*. Every animation stays on `time`, which is what a delay, a
45    /// stagger or a preset is written against.
46    pub scenario_time: f64,
47    pub scene_duration: f64,
48    /// Frames per second of the output video. Required to convert the
49    /// `shutter` fraction (in `MotionBlurConfig`) into an absolute temporal
50    /// offset: `shutter_window = shutter / fps` seconds.
51    pub fps: u32,
52}
53
54/// Padding allowance to keep arrow/connector heads inside the box.
55const ARROW_BBOX_PADDING: f32 = 16.0;
56
57/// Result of building a box tree from a scene description.
58pub struct BuiltScene<'a> {
59    /// Root box (a flex column container at viewport dimensions).
60    pub root: BoxNode,
61    /// Lookup table — `components[id as usize]` is the component for `id`.
62    /// `None` for synthetic boxes (the root scene wrapper).
63    pub components: Vec<Option<&'a ChildComponent>>,
64    /// Per-node animation delay accumulated from ancestor containers'
65    /// `stagger` (indexed like `components`). Consumed by the paint
66    /// dispatcher so internal animations shift by the same amount as the
67    /// CSS overrides resolved at build time.
68    pub stagger_delays: Vec<f64>,
69    /// Per-node affine time remap accumulated from ancestor containers'
70    /// `time_scale`/`time_offset`. Entry `i` is `(scale, shift)` where
71    /// `t_local = scale * t_global + shift`. Default `(1.0, 0.0)` = identity.
72    /// Indexed like `components` and `stagger_delays`.
73    pub time_params: Vec<(f64, f64)>,
74}
75
76/// Build a box tree for a flat list of scene-level children at a given
77/// viewport size.
78///
79/// The implicit scene root is a `display: flex; flex-direction: column;
80/// width/height: 100%`; children flow vertically unless they specify
81/// `position: { x, y }`, in which case they become `position: absolute`.
82pub fn build_scene<'a>(children: &'a [ChildComponent], viewport: (f32, f32)) -> BuiltScene<'a> {
83    build_scene_with_root(children, viewport, default_root_css(viewport))
84}
85
86/// Same as [`build_scene`] but lets the caller supply the root container's
87/// `CssStyle`. Width/height are forced to the viewport regardless.
88pub fn build_scene_with_root<'a>(
89    children: &'a [ChildComponent],
90    viewport: (f32, f32),
91    root_css: CssStyle,
92) -> BuiltScene<'a> {
93    build_scene_from_refs(children.iter(), viewport, root_css, None)
94}
95
96/// Like [`build_scene_with_root`] but resolves animations at `time` (seconds)
97/// for each node and merges the result into its `CssStyle`. Use this when
98/// rendering an animated frame.
99pub fn build_scene_at_time<'a>(
100    children: &'a [ChildComponent],
101    viewport: (f32, f32),
102    root_css: CssStyle,
103    anim: BuildAnimationCtx,
104) -> BuiltScene<'a> {
105    build_scene_from_refs(children.iter(), viewport, root_css, Some(anim))
106}
107
108/// Like [`build_scene`] but with an animation context. Convenience wrapper
109/// that uses the default root CSS (full-viewport flex column).
110pub fn build_scene_with_anim<'a>(
111    children: &'a [ChildComponent],
112    viewport: (f32, f32),
113    anim: BuildAnimationCtx,
114) -> BuiltScene<'a> {
115    build_scene_from_refs(
116        children.iter(),
117        viewport,
118        default_root_css(viewport),
119        Some(anim),
120    )
121}
122
123/// Same as [`build_scene_with_root`] but accepts an iterator over
124/// `&ChildComponent` references. Useful when the caller has filtered or
125/// re-ordered the scene's children and doesn't want to clone.
126pub fn build_scene_from_refs<'a, I>(
127    children: I,
128    viewport: (f32, f32),
129    mut root_css: CssStyle,
130    anim: Option<BuildAnimationCtx>,
131) -> BuiltScene<'a>
132where
133    I: IntoIterator<Item = &'a ChildComponent>,
134{
135    let mut components: Vec<Option<&'a ChildComponent>> = vec![None];
136    let mut stagger_delays: Vec<f64> = vec![0.0];
137    let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)]; // slot 0 = root (identity)
138    let mut next_id: NodeId = 1;
139
140    let mut child_boxes = Vec::new();
141    for (i, c) in children.into_iter().enumerate() {
142        child_boxes.extend(build_child(
143            c,
144            &mut components,
145            &mut stagger_delays,
146            &mut time_params,
147            &mut next_id,
148            anim,
149            format!("/children/{i}"),
150            0.0,
151            (1.0, 0.0),
152            &root_css,
153        ));
154    }
155
156    // Force the root to viewport dimensions even if the caller didn't set them.
157    root_css.width = Some(CSize::Length(CLP::Px(viewport.0)));
158    root_css.height = Some(CSize::Length(CLP::Px(viewport.1)));
159
160    let root = BoxNode {
161        id: 0,
162        kind: BoxKind::Container,
163        css: root_css,
164        children: child_boxes,
165        intrinsic: None,
166        source_path: None,
167        window: None,
168    };
169
170    BuiltScene {
171        root,
172        components,
173        stagger_delays,
174        time_params,
175    }
176}
177
178fn default_root_css(viewport: (f32, f32)) -> CssStyle {
179    CssStyle {
180        display: Some(rustmotion_core::css::style::Display::Flex),
181        flex_direction: Some(rustmotion_core::css::style::FlexDirection::Column),
182        width: Some(CSize::Length(CLP::Px(viewport.0))),
183        height: Some(CSize::Length(CLP::Px(viewport.1))),
184        ..Default::default()
185    }
186}
187
188/// Detect motion_blur and trail configs from a merged effect list.
189///
190/// Returns `(motion_blur, trail)`. Only the first occurrence of each type is
191/// used; duplicate effects of the same kind are ignored.
192fn detect_ghost_effects(
193    effects: &[AnimationEffect],
194) -> (Option<MotionBlurConfig>, Option<TrailConfig>) {
195    let mut mb: Option<MotionBlurConfig> = None;
196    let mut tr: Option<TrailConfig> = None;
197    for e in effects {
198        match e {
199            AnimationEffect::MotionBlur(c) if mb.is_none() => mb = Some(c.clone()),
200            AnimationEffect::Trail(c) if tr.is_none() => tr = Some(c.clone()),
201            _ => {}
202        }
203    }
204    (mb, tr)
205}
206
207/// Build ghost `BoxNode`s for motion-blur or trail effects.
208///
209/// Returns a `Vec<BoxNode>` of ghosts to be prepended (painted underneath)
210/// the principal node. Each ghost gets a fresh `NodeId` and its own slot in
211/// `components`/`stagger_delays`, both pointing to the same `ChildComponent`
212/// as the principal (v1 approximation: internal content uses frame time, not
213/// ghost time).
214///
215/// # Ghost CSS
216///
217/// For **motion_blur**: the CSS is resolved at `t_ghost = t - i * (shutter/fps)/samples`
218/// and opacity is `base_opacity / (samples + 1)`. The principal's opacity is
219/// kept at its base value so the static (no-motion) case stays visually full.
220///
221/// For **trail**: the CSS is resolved at `t_ghost = t - i * spacing` and
222/// opacity is `base_opacity * falloff^i`. The principal is unchanged.
223///
224/// Only one of `mb` / `tr` is used at a time; if both are present, motion_blur
225/// takes priority.
226#[allow(clippy::too_many_arguments)]
227fn build_ghosts<'a>(
228    child: &'a ChildComponent,
229    components: &mut Vec<Option<&'a ChildComponent>>,
230    stagger_delays: &mut Vec<f64>,
231    time_params: &mut Vec<(f64, f64)>,
232    next_id: &mut NodeId,
233    actx: BuildAnimationCtx,
234    stagger_delay: f64,
235    time_remap: (f64, f64),
236    effects: &[AnimationEffect],
237    parent_css: &CssStyle,
238) -> Vec<BoxNode> {
239    let (mb, tr) = detect_ghost_effects(effects);
240
241    // Choose the ghost generation strategy.
242    enum Strategy {
243        MotionBlur {
244            samples: u32,
245            shutter_window: f64,
246        },
247        Trail {
248            copies: u32,
249            spacing: f64,
250            falloff: f32,
251        },
252    }
253    let strategy = if let Some(mc) = mb {
254        let samples = mc.samples.clamp(1, 16);
255        if samples <= 1 {
256            // Degenerate: no ghosts needed (ghost = principal position).
257            return Vec::new();
258        }
259        let shutter_window = mc.shutter / actx.fps.max(1) as f64;
260        Strategy::MotionBlur {
261            samples,
262            shutter_window,
263        }
264    } else if let Some(tc) = tr {
265        let copies = tc.copies.clamp(1, 12);
266        Strategy::Trail {
267            copies,
268            spacing: tc.spacing,
269            falloff: tc.falloff,
270        }
271    } else {
272        return Vec::new();
273    };
274
275    let base_css_for_ghost = |ghost_time: f64, ghost_opacity_scale: f32| -> CssStyle {
276        // Start from the same base CSS as the principal.
277        let mut css = component_css(&child.component);
278        if let Some((x, y)) = child.absolute_position() {
279            css.position = Some(Position::Absolute);
280            css.left = Some(CLP::Px(x));
281            css.top = Some(CLP::Px(y));
282        }
283        if let Some(z) = child.z_index {
284            css.z_index = Some(z);
285        }
286        // Cascade: a ghost is the same component as the principal, painted
287        // at a different sampled time, so it inherits from the same parent.
288        rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
289        // Apply timeline style states at the ghost time.
290        if let Some(animatable) = child.component.as_animatable() {
291            let steps = animatable.timeline_steps();
292            if steps.iter().any(|s| s.style.is_some()) {
293                let skip_opacity = css.transition.is_some();
294                apply_style_states(&mut css, steps, ghost_time - stagger_delay, skip_opacity);
295                // Same `border-radius`/`background` smoothing as the
296                // principal path in `build_child`, sampled at `ghost_time`
297                // so a motion-blur/trail ghost mid-transition matches what
298                // the principal will look like at that same instant.
299                let overrides = resolve_transition_css_overrides(
300                    child.component.as_styled().style_config(),
301                    steps,
302                    ghost_time - stagger_delay,
303                );
304                if let Some(br) = overrides.border_radius {
305                    css.border_radius = Some(br);
306                }
307                if let Some(bg) = overrides.background {
308                    css.background = Some(bg);
309                }
310            }
311        }
312        // Resolve animation props at the *ghost* time.
313        let ghost_actx = BuildAnimationCtx {
314            time: ghost_time,
315            scenario_time: actx.scenario_time,
316            scene_duration: actx.scene_duration,
317            fps: actx.fps,
318        };
319        if let Some(ghost_effects) = effective_effects(&child.component, stagger_delay) {
320            let props = resolve_props_for_effects(
321                &ghost_effects,
322                ghost_actx.time,
323                ghost_actx.scene_duration,
324            );
325            if props_has_paint_overrides(&props) {
326                apply_animated_props(&mut css, &props);
327            }
328            apply_glow_effect(&mut css, &ghost_effects);
329            carry_paint_pass_effects(&mut css, &ghost_effects);
330        }
331        // Scale opacity: multiply the base opacity by the ghost opacity factor.
332        let base_opacity = css.opacity.unwrap_or(1.0);
333        css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0));
334        css
335    };
336
337    let mut ghosts = Vec::new();
338
339    match strategy {
340        Strategy::MotionBlur {
341            samples,
342            shutter_window,
343        } => {
344            // Ghost opacity: 1 / (samples + 1) of base opacity.
345            // Principal stays at full base opacity (handled in build_child).
346            let ghost_opacity_scale = 1.0 / (samples + 1) as f32;
347            for i in 1..=samples {
348                let ghost_time = actx.time - (i as f64 * shutter_window / samples as f64);
349                let ghost_css = base_css_for_ghost(ghost_time, ghost_opacity_scale);
350
351                let ghost_id = *next_id;
352                *next_id += 1;
353                // Register a slot so the dispatcher can look up the component.
354                components.push(Some(child));
355                stagger_delays.push(stagger_delay);
356                time_params.push(time_remap);
357
358                ghosts.push(BoxNode {
359                    id: ghost_id,
360                    kind: BoxKind::Ghost(Arc::new(ghost_id)),
361                    css: ghost_css,
362                    children: Vec::new(), // v1: no child recursion in ghosts
363                    intrinsic: None,      // v1: ghosts have no layout-measured content
364                    source_path: None,
365                    window: None,
366                });
367            }
368        }
369        Strategy::Trail {
370            copies,
371            spacing,
372            falloff,
373        } => {
374            // Ghosts painted from oldest (most-trailing) to newest (closest to principal).
375            // We build them in reverse order (copies → 1) so that index i=copies
376            // is the oldest ghost (lowest opacity), and we then reverse to get the
377            // correct under-to-over paint order.
378            let mut trail_nodes = Vec::with_capacity(copies as usize);
379            for i in 1..=copies {
380                let ghost_time = actx.time - i as f64 * spacing;
381                let ghost_opacity_scale = falloff.powi(i as i32);
382                let ghost_css = base_css_for_ghost(ghost_time, ghost_opacity_scale);
383
384                let ghost_id = *next_id;
385                *next_id += 1;
386                components.push(Some(child));
387                stagger_delays.push(stagger_delay);
388                time_params.push(time_remap);
389
390                trail_nodes.push(BoxNode {
391                    id: ghost_id,
392                    kind: BoxKind::Ghost(Arc::new(ghost_id)),
393                    css: ghost_css,
394                    children: Vec::new(),
395                    intrinsic: None,
396                    source_path: None,
397                    window: None,
398                });
399            }
400            // Oldest ghost (most-trailing) painted first → prepend in reverse.
401            trail_nodes.reverse();
402            ghosts = trail_nodes;
403        }
404    }
405
406    ghosts
407}
408
409/// Convert a single `ChildComponent` into one or more `BoxNode`s.
410///
411/// Returns a `Vec` whose elements are inserted in order into the parent's
412/// `children`. The last element is the principal node; any preceding elements
413/// are `BoxKind::Ghost` nodes inserted *before* (painted underneath) the
414/// principal for motion-blur or trail effects.
415///
416/// `stagger_delay` is the animation delay accumulated from ancestor
417/// containers' `stagger` fields.
418/// `time_remap` is the accumulated affine time transform `(scale, shift)` where
419/// `t_local = scale * t_global + shift`. Default is `(1.0, 0.0)` (identity).
420#[allow(clippy::too_many_arguments)]
421fn build_child<'a>(
422    child: &'a ChildComponent,
423    components: &mut Vec<Option<&'a ChildComponent>>,
424    stagger_delays: &mut Vec<f64>,
425    time_params: &mut Vec<(f64, f64)>,
426    next_id: &mut NodeId,
427    anim: Option<BuildAnimationCtx>,
428    path: String,
429    stagger_delay: f64,
430    time_remap: (f64, f64),
431    parent_css: &CssStyle,
432) -> Vec<BoxNode> {
433    // Compute the local animation context for this node — remapped by the
434    // accumulated affine time transform from ancestor containers.
435    // `t_local = scale * t_global + shift`
436    let local_actx = anim.map(|a| {
437        let (scale, shift) = time_remap;
438        BuildAnimationCtx {
439            time: a.time * scale + shift,
440            // A container's `time_scale`/`time_offset` remaps the *animation*
441            // clock of its subtree. The audio is not part of that subtree —
442            // it plays at wall-clock speed regardless — so the scenario clock
443            // passes through unremapped.
444            scenario_time: a.scenario_time,
445            scene_duration: a.scene_duration,
446            fps: a.fps,
447        }
448    });
449
450    // ── Ghost generation (motion_blur / trail) ───────────────────────────────
451    // Must happen before allocating the principal's id so that ghost ids are
452    // lower (earlier in the slot table). The principal's id is allocated below.
453    let mut ghosts: Vec<BoxNode> = Vec::new();
454    if let Some(actx) = local_actx {
455        if let Some(effects) = effective_effects(&child.component, stagger_delay) {
456            ghosts = build_ghosts(
457                child,
458                components,
459                stagger_delays,
460                time_params,
461                next_id,
462                actx,
463                stagger_delay,
464                time_remap,
465                &effects,
466                parent_css,
467            );
468        }
469    }
470
471    let id = *next_id;
472    *next_id += 1;
473    components.push(Some(child));
474    stagger_delays.push(stagger_delay);
475    time_params.push(time_remap);
476
477    let mut css = component_css(&child.component);
478
479    // Apply per-child position/z-index from the wrapper.
480    if let Some((x, y)) = child.absolute_position() {
481        css.position = Some(Position::Absolute);
482        css.left = Some(CLP::Px(x));
483        css.top = Some(CLP::Px(y));
484    }
485    if let Some(z) = child.z_index {
486        css.z_index = Some(z);
487    }
488
489    // CSS cascade (round 4 audit, lot LAYOUT, constat 2): propagate
490    // inheritable properties (color, font-*, text-align, white-space, ...)
491    // from the parent's already-cascaded style into any of this node's own
492    // unset properties — mirrors CSS's "specified value" resolution, which
493    // happens before state/animation overrides compute the final value.
494    // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but
495    // nothing called it until this fix.
496    rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
497
498    // Timeline style states: merge every state whose (at + stagger) <= t
499    // into the box CSS. Opacity is excluded when a `transition` smooths it
500    // (the synthesized keyframes then own its whole history). States affect
501    // box-model properties (layout, background, border, opacity, transform,
502    // filter); painter-internal properties like text color flow through the
503    // keyframes path below instead.
504    if let Some(animatable) = child.component.as_animatable() {
505        let steps = animatable.timeline_steps();
506        if steps.iter().any(|s| s.style.is_some()) {
507            let t = local_actx.map(|a| a.time).unwrap_or(0.0);
508            let skip_opacity = css.transition.is_some();
509            apply_style_states(&mut css, steps, t - stagger_delay, skip_opacity);
510            // `border-radius`/`background` (solid colour, uniform absolute
511            // px only — see `resolve_transition_css_overrides`'s doc
512            // comment) smooth the same way opacity does above, but land
513            // directly on `css` instead of through the generic effects
514            // pipeline: no `AnimatedProperties` field for them is ever read
515            // by a painter, so that pipeline is a dead end for these two.
516            let overrides = resolve_transition_css_overrides(
517                child.component.as_styled().style_config(),
518                steps,
519                t - stagger_delay,
520            );
521            if let Some(br) = overrides.border_radius {
522                css.border_radius = Some(br);
523            }
524            if let Some(bg) = overrides.background {
525                css.background = Some(bg);
526            }
527        }
528    }
529
530    // Resolve animations and apply transform/opacity/filter overrides on the
531    // box's CSS. Paint-time properties (transform, opacity, filter,
532    // perspective) plus the box size (`width`/`height`, which taffy needs so a
533    // resize reflows its children instead of stretching pixels) flow into CSS
534    // — internal animations like draw_progress or char_animation remain on the
535    // `AnimatedProperties` legacy path.
536    if let Some(actx) = local_actx {
537        if let Some(effects) = effective_effects(&child.component, stagger_delay) {
538            let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration);
539            if props_has_paint_overrides(&props) {
540                apply_animated_props(&mut css, &props);
541            }
542            apply_glow_effect(&mut css, &effects);
543            carry_paint_pass_effects(&mut css, &effects);
544        }
545    }
546
547    // ── Audio-reactive binding ────────────────────────────────────────────────
548    // Reads the audio analysis cache and lerps the target CSS property between
549    // min and max. Cache-miss → value = min (deterministic fallback).
550    if let Some(ar) = css.audio_reactive.take() {
551        use rustmotion_core::css::style::{AudioReactiveProperty, AudioSource, AudioSourceTag};
552        use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache;
553
554        if let Some(actx) = local_actx {
555            let cache = audio_analysis_cache();
556            let analysis_opt = if let Some(ref src) = ar.track {
557                cache.get(src).map(|r| r.clone())
558            } else {
559                cache.iter().next().map(|r| r.value().clone())
560            };
561
562            let raw = if let Some(analysis) = analysis_opt {
563                match &ar.source {
564                    AudioSource::Amplitude(AudioSourceTag::Amplitude) => {
565                        analysis.amplitude_smoothed(actx.scenario_time, ar.smoothing_frames)
566                    }
567                    AudioSource::Band { band } => {
568                        analysis.band_smoothed(actx.scenario_time, *band, ar.smoothing_frames)
569                    }
570                }
571            } else {
572                0.0 // cache empty → use min
573            };
574
575            let lerped = ar.min as f32 + raw * (ar.max - ar.min) as f32;
576
577            match ar.property {
578                AudioReactiveProperty::Opacity => {
579                    let base = css.opacity.unwrap_or(1.0);
580                    css.opacity = Some(base * lerped.clamp(0.0, 1.0));
581                }
582                AudioReactiveProperty::Scale => {
583                    let s = lerped.max(0.0);
584                    let tx = css.transform.get_or_insert_with(Vec::new);
585                    tx.push(rustmotion_core::css::style::TransformFn::Scale { x: s, y: s });
586                }
587                AudioReactiveProperty::TranslateY => {
588                    use rustmotion_core::css::units::LengthPercentage;
589                    let tx = css.transform.get_or_insert_with(Vec::new);
590                    tx.push(rustmotion_core::css::style::TransformFn::TranslateY {
591                        y: LengthPercentage::Px(lerped),
592                    });
593                }
594                AudioReactiveProperty::Rotation => {
595                    let tx = css.transform.get_or_insert_with(Vec::new);
596                    tx.push(rustmotion_core::css::style::TransformFn::Rotate { deg: lerped });
597                }
598            }
599        }
600    }
601
602    // Visibility window (start_at/end_at) — enforced by the paint pass.
603    // The stagger delay shifts the window too, so a hard-cut child appears
604    // in step with its staggered siblings.
605    // When there is an accumulated time remap, the window times (which are in
606    // local/remapped time) must be converted back to global time so the paint
607    // pass (which operates on global time) can apply them correctly.
608    // If `t_local = scale * t_global + shift`, then `t_global = (t_local - shift) / scale`.
609    let window = child.component.as_timed().and_then(|t| {
610        let (start, end) = t.timing();
611        (start.is_some() || end.is_some()).then_some({
612            let (scale, shift) = time_remap;
613            let to_global = |t_local: f64| -> f64 {
614                if scale.abs() < 1e-10 {
615                    t_local
616                } else {
617                    (t_local - shift) / scale
618                }
619            };
620            rustmotion_core::engine::box_tree::PaintWindow {
621                start: start.map(|s| to_global(s + stagger_delay)),
622                end: end.map(|e| to_global(e + stagger_delay)),
623            }
624        })
625    });
626
627    let children_boxes = container_children(
628        &child.component,
629        components,
630        stagger_delays,
631        time_params,
632        next_id,
633        anim,
634        &path,
635        stagger_delay,
636        time_remap,
637        &css,
638    );
639    let intrinsic = component_intrinsic(&child.component);
640
641    let principal = BoxNode {
642        id,
643        kind: BoxKind::Component(Arc::new(id)),
644        css,
645        children: children_boxes,
646        intrinsic,
647        source_path: Some(path),
648        window,
649    };
650
651    // Ghosts prepended (painted underneath the principal).
652    let mut result = ghosts;
653    result.push(principal);
654    result
655}
656
657/// The full effect list for a component at paint time: `style.animation`,
658/// plus `timeline` steps shifted by their `at`, plus keyframes synthesized
659/// from timeline style-state changes (`style.transition`), plus the
660/// container-stagger delay applied to everything. Returns `None` when there
661/// is nothing to resolve, `Some(Cow::Borrowed)` on the no-merge fast path.
662pub fn effective_effects(
663    component: &Component,
664    extra_delay: f64,
665) -> Option<std::borrow::Cow<'_, [rustmotion_core::schema::AnimationEffect]>> {
666    let animatable = component.as_animatable()?;
667    let effects = animatable.animation_effects();
668    let steps = animatable.timeline_steps();
669    // `color` keyframes drive AnimatedProperties.color, consumed by the
670    // text-like painters only.
671    let smooth_color = matches!(component, Component::Text(_) | Component::Counter(_));
672    let synthesized =
673        transition_keyframes(component.as_styled().style_config(), steps, smooth_color);
674    if steps.is_empty() && synthesized.is_empty() && extra_delay == 0.0 {
675        return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects));
676    }
677    let mut merged = effects.to_vec();
678    for step in steps {
679        for effect in &step.animation {
680            let mut e = effect.clone();
681            e.shift_delay(step.at);
682            merged.push(e);
683        }
684    }
685    merged.extend(synthesized);
686    if extra_delay != 0.0 {
687        for e in &mut merged {
688            e.shift_delay(extra_delay);
689        }
690    }
691    (!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged))
692}
693
694/// Merge every timeline style state whose `at <= t` into `css`, in `at`
695/// order. Serialize-merge keeps this schema-complete; `null`s and the empty
696/// `animation` array never erase existing values. `skip_opacity` leaves
697/// opacity to the synthesized transition keyframes.
698pub(crate) fn apply_style_states(
699    css: &mut CssStyle,
700    steps: &[rustmotion_core::schema::TimelineStep],
701    t: f64,
702    skip_opacity: bool,
703) {
704    let mut due: Vec<&rustmotion_core::schema::TimelineStep> = steps
705        .iter()
706        .filter(|s| s.style.is_some() && s.at <= t)
707        .collect();
708    if due.is_empty() {
709        return;
710    }
711    due.sort_by(|a, b| a.at.total_cmp(&b.at));
712
713    let Ok(serde_json::Value::Object(mut base)) = serde_json::to_value(&*css) else {
714        return;
715    };
716    for step in due {
717        let Some(style) = step.style.as_deref() else {
718            continue;
719        };
720        let Ok(serde_json::Value::Object(state)) = serde_json::to_value(style) else {
721            continue;
722        };
723        for (k, v) in state {
724            if v.is_null() {
725                continue;
726            }
727            if k == "animation" && v.as_array().is_some_and(|a| a.is_empty()) {
728                continue;
729            }
730            if skip_opacity && k == "opacity" {
731                continue;
732            }
733            base.insert(k, v);
734        }
735    }
736    if let Ok(merged) = serde_json::from_value::<CssStyle>(serde_json::Value::Object(base)) {
737        *css = merged;
738    }
739}
740
741/// Synthesize `Keyframes` effects smoothing timeline style-state changes per
742/// the component's `style.transition`. Supported: `opacity` (as ratios of the
743/// base opacity — `apply_animated_props` multiplies) and, on text-like
744/// components, `color` (absolute, via `AnimatedProperties.color`). Color
745/// states without a transition still synthesize a near-instant ramp because
746/// text painters only see color through this path.
747pub(crate) fn transition_keyframes(
748    base: &CssStyle,
749    steps: &[rustmotion_core::schema::TimelineStep],
750    smooth_color: bool,
751) -> Vec<rustmotion_core::schema::AnimationEffect> {
752    use rustmotion_core::schema::{
753        Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig,
754    };
755
756    let has_states = steps.iter().any(|s| s.style.is_some());
757    if !has_states {
758        return Vec::new();
759    }
760    let (duration, easing) = match base.transition.as_ref() {
761        Some(tr) if tr.duration() > 0.0 => (tr.duration(), tr.easing()),
762        // Color snaps still need the keyframes path (see doc above); a 1ms
763        // ramp is visually a hard cut.
764        _ => (0.001, rustmotion_core::schema::EasingType::Linear),
765    };
766    let smooth_opacity = base.transition.is_some();
767
768    let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
769        steps.iter().filter(|s| s.style.is_some()).collect();
770    sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
771
772    let base_opacity = base.opacity.unwrap_or(1.0) as f64;
773    let mut opacity_kfs: Vec<Keyframe> = Vec::new();
774    let mut color_kfs: Vec<Keyframe> = Vec::new();
775    let mut prev_opacity = base_opacity;
776    let mut prev_color = base.color.as_ref().map(|c| c.to_css_string());
777
778    let kf_num = |time: f64, v: f64| Keyframe {
779        time,
780        value: KeyframeValue::Number(v),
781        easing: None,
782    };
783    let kf_color = |time: f64, c: String| Keyframe {
784        time,
785        value: KeyframeValue::Color(c),
786        easing: None,
787    };
788    // Keep keyframe times strictly ascending even when states overlap a
789    // still-running transition (the resolver walks ordered segments).
790    let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
791        let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
792        let start = at.max(floor);
793        let mut from = from;
794        let mut to = to;
795        from.time = start;
796        to.time = to.time.max(start + 1e-6);
797        kfs.push(from);
798        kfs.push(to);
799    };
800
801    for step in sorted {
802        let style = step.style.as_deref().unwrap();
803        if smooth_opacity && base_opacity > 1e-6 {
804            if let Some(o) = style.opacity {
805                let target = o as f64;
806                if (target - prev_opacity).abs() > 1e-6 {
807                    push_pair(
808                        &mut opacity_kfs,
809                        step.at,
810                        kf_num(step.at, prev_opacity / base_opacity),
811                        kf_num(step.at + duration, target / base_opacity),
812                    );
813                    prev_opacity = target;
814                }
815            }
816        }
817        if smooth_color {
818            if let Some(c) = style.color.as_ref() {
819                let target = c.to_css_string();
820                if prev_color.as_ref() != Some(&target) {
821                    if let Some(from) = prev_color.clone() {
822                        push_pair(
823                            &mut color_kfs,
824                            step.at,
825                            kf_color(step.at, from),
826                            kf_color(step.at + duration, target.clone()),
827                        );
828                    }
829                    prev_color = Some(target);
830                }
831            }
832        }
833    }
834
835    let mut out = Vec::new();
836    let mut push_effect = |property: &str, keyframes: Vec<Keyframe>| {
837        if keyframes.is_empty() {
838            return;
839        }
840        out.push(AnimationEffect::Keyframes(KeyframesConfig {
841            keyframes: vec![Animation {
842                property: property.to_string(),
843                keyframes,
844                easing: easing.clone(),
845                spring: None,
846            }],
847            delay: 0.0,
848            duration: 0.0,
849            repeat: false,
850        }));
851    };
852    push_effect("opacity", opacity_kfs);
853    push_effect("color", color_kfs);
854    out
855}
856
857/// Resolved `style.transition` smoothing for `border-radius`/`background`,
858/// ready to be written straight onto a `CssStyle`.
859pub(crate) struct TransitionCssOverrides {
860    pub border_radius: Option<rustmotion_core::css::style::BorderRadius>,
861    pub background: Option<rustmotion_core::css::style::Background>,
862}
863
864/// CSS-native smoothing for `border-radius` (uniform, absolute-px only) and
865/// `background` (solid colour only) timeline style-state changes.
866///
867/// Unlike `opacity`/`color` in `transition_keyframes` above, neither
868/// property has anywhere to land in `AnimatedProperties` that any painter or
869/// the CSS bridge (`css/animation.rs::apply_animated_props`) actually reads
870/// (see `KNOWN_ANIMATABLE_PROPERTIES`'s doc comment in `animator.rs`) —
871/// every painter reads `css.border_radius`/`css.background` straight off
872/// the node's own `CssStyle` (`paint_pass.rs`, frozen, already does this for
873/// the static case). So instead of synthesizing an `AnimationEffect` for the
874/// generic effects pipeline (a dead end for these two), this resolves the
875/// interpolated value directly and returns it for the caller to write onto
876/// the box's `CssStyle` by hand — reusing `animator::resolve_keyframe_track`
877/// for the actual segment/easing/spring math rather than reinventing it.
878///
879/// Gated on `style.transition` being set, mirroring `opacity`'s gate above
880/// (not `color`'s forced near-instant ramp — nothing downstream *requires*
881/// these two to smooth the way text painters require `color` to). Absent an
882/// explicit `style.transition`, this returns an all-`None` result, so every
883/// existing scenario without one renders byte-identical to before this
884/// workstream.
885///
886/// **"Unités mixtes" decision** (see workstream report): resolves only when
887/// *both* the origin and the target value are the exact shape
888/// `BorderRadius::absolute_px`/`Background::solid_hex` can resolve without a
889/// `LengthContext` — uniform absolute px, solid colour. Anything else
890/// (per-corner radii, `%`/`em`/`rem`/`vw`/`vh`, gradients, image layers) is
891/// refused rather than guessed: that property just falls back to
892/// `apply_style_states`'s existing snap. `validate_schema.rs` calls the
893/// exact same two predicates so the diagnostic and the runtime can never
894/// disagree about what's interpolable.
895pub(crate) fn resolve_transition_css_overrides(
896    base: &CssStyle,
897    steps: &[rustmotion_core::schema::TimelineStep],
898    t: f64,
899) -> TransitionCssOverrides {
900    use rustmotion_core::css::style::{Background, BorderRadius, Color};
901    use rustmotion_core::css::units::LengthPercentage as CssLP;
902    use rustmotion_core::engine::animator::resolve_keyframe_track;
903    use rustmotion_core::schema::{Animation, Keyframe, KeyframeValue};
904
905    let mut out = TransitionCssOverrides {
906        border_radius: None,
907        background: None,
908    };
909    let Some(tr) = base.transition.as_ref() else {
910        return out;
911    };
912    if tr.duration() <= 0.0 {
913        return out;
914    }
915    let duration = tr.duration();
916    let easing = tr.easing();
917
918    let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
919        steps.iter().filter(|s| s.style.is_some()).collect();
920    sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
921
922    let mut prev_radius = base
923        .border_radius
924        .as_ref()
925        .and_then(BorderRadius::absolute_px);
926    let mut prev_bg = base.background.as_ref().and_then(Background::solid_hex);
927    let mut radius_kfs: Vec<Keyframe> = Vec::new();
928    let mut bg_kfs: Vec<Keyframe> = Vec::new();
929
930    // Same ascending-time bookkeeping as `transition_keyframes`'s
931    // `push_pair` above (kept local — a shared closure can't easily borrow
932    // two different `Vec`s across both loops below without upsetting the
933    // borrow checker for no real benefit at this size).
934    let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
935        let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
936        let start = at.max(floor);
937        let mut from = from;
938        let mut to = to;
939        from.time = start;
940        to.time = to.time.max(start + 1e-6);
941        kfs.push(from);
942        kfs.push(to);
943    };
944
945    for step in sorted {
946        let style = step.style.as_deref().unwrap();
947        if let Some(br) = style.border_radius.as_ref() {
948            match br.absolute_px() {
949                Some(target) => {
950                    if prev_radius != Some(target) {
951                        if let Some(from) = prev_radius {
952                            push_pair(
953                                &mut radius_kfs,
954                                step.at,
955                                Keyframe {
956                                    time: step.at,
957                                    value: KeyframeValue::Number(from as f64),
958                                    easing: None,
959                                },
960                                Keyframe {
961                                    time: step.at + duration,
962                                    value: KeyframeValue::Number(target as f64),
963                                    easing: None,
964                                },
965                            );
966                        }
967                        prev_radius = Some(target);
968                    }
969                }
970                // Unresolvable shape (per-corner, %/em/rem/vw/vh) — lose the
971                // interpolation origin for *this* transition only;
972                // `validate_schema.rs` diagnoses this exact step, and a
973                // later resolvable value simply resumes smoothing from
974                // itself onward (see the doc comment above).
975                None => prev_radius = None,
976            }
977        }
978        if let Some(bg) = style.background.as_ref() {
979            match bg.solid_hex() {
980                Some(target) => {
981                    if prev_bg.as_deref() != Some(target.as_str()) {
982                        if let Some(from) = prev_bg.clone() {
983                            push_pair(
984                                &mut bg_kfs,
985                                step.at,
986                                Keyframe {
987                                    time: step.at,
988                                    value: KeyframeValue::Color(from),
989                                    easing: None,
990                                },
991                                Keyframe {
992                                    time: step.at + duration,
993                                    value: KeyframeValue::Color(target.clone()),
994                                    easing: None,
995                                },
996                            );
997                        }
998                        prev_bg = Some(target);
999                    }
1000                }
1001                None => prev_bg = None,
1002            }
1003        }
1004    }
1005
1006    if !radius_kfs.is_empty() {
1007        let anim = Animation {
1008            property: "border_radius".to_string(),
1009            keyframes: radius_kfs,
1010            easing: easing.clone(),
1011            spring: None,
1012        };
1013        if let KeyframeValue::Number(v) = resolve_keyframe_track(&anim, t) {
1014            out.border_radius = Some(BorderRadius::Uniform(CssLP::Px(v as f32)));
1015        }
1016    }
1017    if !bg_kfs.is_empty() {
1018        let anim = Animation {
1019            property: "background".to_string(),
1020            keyframes: bg_kfs,
1021            easing,
1022            spring: None,
1023        };
1024        if let KeyframeValue::Color(c) = resolve_keyframe_track(&anim, t) {
1025            out.background = Some(Background::Color(Color::String(c)));
1026        }
1027    }
1028    out
1029}
1030
1031/// Quick gate: does this resolved `AnimatedProperties` carry any property
1032/// that we know how to translate to CSS? Avoids allocating a transform Vec
1033/// when there's nothing to apply.
1034fn props_has_paint_overrides(p: &AnimatedProperties) -> bool {
1035    p.translate_x != 0.0
1036        || p.translate_y != 0.0
1037        || (p.scale_x - 1.0).abs() > 1e-4
1038        || (p.scale_y - 1.0).abs() > 1e-4
1039        || p.rotation.abs() > 1e-3
1040        || p.rotate_x.abs() > 1e-3
1041        || p.rotate_y.abs() > 1e-3
1042        || (p.opacity - 1.0).abs() > 1e-4
1043        || p.blur > 0.0
1044        || (p.glow_radius > 0.0 && p.glow_intensity > 0.0)
1045        || p.perspective > 0.0
1046        // An animated box size is a *layout* override rather than a paint one,
1047        // but it travels through the same bridge, so the gate has to let it
1048        // through or `apply_animated_props` never runs for a scenario whose
1049        // only animated property is `width`/`height` (the card-resize case).
1050        // -1.0 is the animator's "never animated" sentinel.
1051        || p.width >= 0.0
1052        || p.height >= 0.0
1053}
1054
1055/// Hand the effects that the *paint pass* resolves for itself down to the box
1056/// node, in their delay-shifted form.
1057///
1058/// Everything else on this path is resolved here and lands on `css` as a
1059/// finished value. `shimmer` cannot be: it composites against the pixels the
1060/// node paints, which do not exist until the paint pass has run. So the paint
1061/// pass reads it off `css.animation` — and it has to read the *shifted* copy
1062/// (container stagger, `timeline` step `at`) rather than the author's raw
1063/// `style.animation`, or a shimmer inside a staggered list would sweep in step
1064/// with the list's first item instead of its own.
1065fn carry_paint_pass_effects(
1066    css: &mut CssStyle,
1067    effects: &[rustmotion_core::schema::AnimationEffect],
1068) {
1069    use rustmotion_core::schema::AnimationEffect;
1070    if effects
1071        .iter()
1072        .any(|e| matches!(e, AnimationEffect::Shimmer(_)))
1073    {
1074        css.animation = effects
1075            .iter()
1076            .filter(|e| matches!(e, AnimationEffect::Shimmer(_)))
1077            .cloned()
1078            .collect();
1079    }
1080}
1081
1082/// M3: apply the static `glow` animation effect (a coloured halo — not
1083/// time-varying, see `AnimationEffect::shift_delay`'s doc comment) as a CSS
1084/// `filter: drop-shadow(...)`.
1085///
1086/// This is deliberately *not* routed through `resolve_props_for_effects` /
1087/// `AnimatedProperties::glow_radius`+`glow_intensity` even though those
1088/// fields exist: `AnimatedProperties`'s public shape is frozen for this
1089/// workstream (can't add a `glow_color` field), and the existing bridge that
1090/// *does* consume those two fields — `css::animation::apply_animated_props`,
1091/// in a file outside this workstream's scope — hardcodes `color: None` on
1092/// the `DropShadow` it builds, which resolves to black. Piping the `glow`
1093/// effect through that path would either still render black, or — if we did
1094/// find a way to set the color fields too — double up into two stacked
1095/// drop-shadows (one colourless from the frozen bridge, one coloured from
1096/// here). Building the filter directly here, from the raw `GlowConfig`
1097/// (found via `animator::find_glow_effect`), keeps it a single, correctly
1098/// coloured shadow and needs no change to any frozen file.
1099///
1100/// The pre-existing `glow_radius`/`glow_intensity` numeric-property path
1101/// (animating those as arbitrary `keyframes` targets, unrelated to this
1102/// named `glow` effect) is untouched and still produces a colourless halo —
1103/// that is `css::animation.rs`'s DropShadow-with-`color:None` bug, out of
1104/// this workstream's file ownership.
1105fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::AnimationEffect]) {
1106    use rustmotion_core::css::style::{Color, FilterFn};
1107    use rustmotion_core::css::units::Length;
1108    use rustmotion_core::engine::animator::find_glow_effect;
1109
1110    let Some(glow) = find_glow_effect(effects) else {
1111        return;
1112    };
1113
1114    let (r, g, b, a) = rustmotion_core::engine::renderer::parse_css_color(&glow.color)
1115        .unwrap_or((255, 255, 255, 255));
1116    let alpha = ((a as f32 / 255.0) * glow.intensity.max(0.0)).clamp(0.0, 1.0);
1117    let radius = glow.radius.max(0.0);
1118    if radius <= 0.0 || alpha <= 0.0 {
1119        return;
1120    }
1121
1122    let shadow = FilterFn::DropShadow {
1123        offset_x: Length::Px(0.0),
1124        offset_y: Length::Px(0.0),
1125        blur: Some(Length::Px(radius)),
1126        color: Some(Color::Rgba { r, g, b, a: alpha }),
1127    };
1128    css.filter.get_or_insert_with(Vec::new).push(shadow);
1129}
1130
1131/// Build an [`IntrinsicMeasure`] for components whose box size depends on
1132/// their content (text, codeblock, terminal, etc.). Returns `None` for
1133/// components with explicit dimensions or pure containers.
1134fn component_intrinsic(
1135    component: &Component,
1136) -> Option<Arc<dyn rustmotion_core::engine::box_tree::IntrinsicMeasure>> {
1137    use Component::*;
1138    match component {
1139        Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))),
1140        GradientText(t) => Some(Arc::new(
1141            crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t),
1142        )),
1143        Caption(c) => Some(Arc::new(crate::intrinsic::CaptionIntrinsic::from_caption(
1144            c,
1145        ))),
1146        Kbd(k) => Some(Arc::new(crate::intrinsic::KbdIntrinsic::from_kbd(k))),
1147        Counter(c) => Some(Arc::new(crate::intrinsic::CounterIntrinsic::from_counter(
1148            c,
1149        ))),
1150        NumberWheel(w) => Some(Arc::new(
1151            crate::intrinsic::NumberWheelIntrinsic::from_number_wheel(w),
1152        )),
1153        Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))),
1154        Terminal(t) => Some(Arc::new(
1155            crate::intrinsic::TerminalIntrinsic::from_terminal(t),
1156        )),
1157        Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))),
1158        Codeblock(c) => Some(Arc::new(
1159            crate::intrinsic::CodeblockIntrinsic::from_codeblock(c),
1160        )),
1161        // M2: rich_text had no intrinsic measurer at all, so it laid out
1162        // 0×0 and rendered nothing unless the author guessed an explicit
1163        // width/height.
1164        RichText(rt) => Some(Arc::new(
1165            crate::intrinsic::RichTextIntrinsic::from_rich_text(rt),
1166        )),
1167        _ => None,
1168    }
1169}
1170
1171/// If the component is a container, recurse into its children. Otherwise
1172/// return an empty Vec. A container's `stagger` adds `index * stagger`
1173/// to each child's inherited animation delay (cumulative across nesting).
1174/// `time_remap` is the accumulated affine time transform `(scale, shift)` for
1175/// this container node; the container's own `time_scale`/`time_offset` are
1176/// composed in to produce the remap for children.
1177#[allow(clippy::too_many_arguments)]
1178fn container_children<'a>(
1179    component: &'a Component,
1180    components: &mut Vec<Option<&'a ChildComponent>>,
1181    stagger_delays: &mut Vec<f64>,
1182    time_params: &mut Vec<(f64, f64)>,
1183    next_id: &mut NodeId,
1184    anim: Option<BuildAnimationCtx>,
1185    parent_path: &str,
1186    inherited_delay: f64,
1187    time_remap: (f64, f64),
1188    parent_css: &CssStyle,
1189) -> Vec<BoxNode> {
1190    let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option<f32>, f64, f64) =
1191        match component {
1192            Component::Card(c) => (
1193                &c.children,
1194                c.stagger,
1195                c.time_scale.unwrap_or(1.0),
1196                c.time_offset.unwrap_or(0.0),
1197            ),
1198            Component::Flex(c) => (
1199                &c.children,
1200                c.stagger,
1201                c.time_scale.unwrap_or(1.0),
1202                c.time_offset.unwrap_or(0.0),
1203            ),
1204            Component::Grid(c) => (
1205                &c.children,
1206                c.stagger,
1207                c.time_scale.unwrap_or(1.0),
1208                c.time_offset.unwrap_or(0.0),
1209            ),
1210            Component::Container(c) => (
1211                &c.children,
1212                c.stagger,
1213                c.time_scale.unwrap_or(1.0),
1214                c.time_offset.unwrap_or(0.0),
1215            ),
1216            Component::Positioned(c) => (
1217                &c.children,
1218                None,
1219                c.time_scale.unwrap_or(1.0),
1220                c.time_offset.unwrap_or(0.0),
1221            ),
1222            _ => return Vec::new(),
1223        };
1224
1225    // Clamp scale defensively to avoid division-by-zero downstream.
1226    let child_scale = child_scale.max(1e-6);
1227
1228    // Compose the container's time remap with the inherited (accumulated) remap.
1229    // Accumulated remap: `t_parent = scale_acc * t_global + shift_acc`
1230    // Container formula: `t_child = (t_parent - child_offset) * child_scale`
1231    //   = child_scale * (scale_acc * t_global + shift_acc) - child_scale * child_offset
1232    //   = (child_scale * scale_acc) * t_global + child_scale * (shift_acc - child_offset)
1233    let (scale_acc, shift_acc) = time_remap;
1234    let new_scale = child_scale * scale_acc;
1235    let new_shift = child_scale * (shift_acc - child_offset);
1236    let child_remap = (new_scale, new_shift);
1237
1238    // `anim` stays GLOBAL all the way down the recursion — each `build_child`
1239    // derives its node-local time from the accumulated `child_remap`. Passing
1240    // a pre-remapped ctx here would double-apply the transform.
1241    let step = stagger.unwrap_or(0.0) as f64;
1242    let mut result = Vec::new();
1243    for (j, c) in children.iter().enumerate() {
1244        result.extend(build_child(
1245            c,
1246            components,
1247            stagger_delays,
1248            time_params,
1249            next_id,
1250            anim,
1251            format!("{parent_path}/children/{j}"),
1252            inherited_delay + j as f64 * step,
1253            child_remap,
1254            parent_css,
1255        ));
1256    }
1257    result
1258}
1259
1260/// Pull the component's `CssStyle`, augmented with intrinsic `width`/`height`
1261/// for components that carry a fixed size.
1262fn component_css(component: &Component) -> CssStyle {
1263    let mut css = component_style(component).clone();
1264    apply_default_display(component, &mut css);
1265    apply_intrinsic_overrides(component, &mut css);
1266    css
1267}
1268
1269/// Set `display` from the component kind when the user didn't specify one.
1270/// `card` / `flex` → `flex`, `grid` → `grid`. The taffy bridge defaults to
1271/// `block` otherwise, which would silently ignore `flex-direction` & friends.
1272fn apply_default_display(component: &Component, css: &mut CssStyle) {
1273    use rustmotion_core::css::style::Display;
1274    if css.display.is_some() {
1275        return;
1276    }
1277    css.display = match component {
1278        Component::Card(_) | Component::Flex(_) | Component::Container(_) => Some(Display::Flex),
1279        Component::Grid(_) => Some(Display::Grid),
1280        _ => return,
1281    };
1282}
1283
1284/// Measure a single line of text with the exact same Skia font metrics the
1285/// affected painters (`callout`, `tooltip`, `pill_nav`, `stepper`) already use
1286/// to draw it (`measure_text_with_fallback`), so a size computed here matches
1287/// the pixels those painters actually paint instead of guessing at an average
1288/// character width. Returns `0.0` if the font family can't be resolved —
1289/// matches those painters' own silent-return-on-font-load-failure behaviour.
1290fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) -> f32 {
1291    use rustmotion_core::engine::renderer::{
1292        emoji_typeface, measure_text_with_fallback, typeface_with_fallback,
1293    };
1294    let style = if bold {
1295        skia_safe::FontStyle::bold()
1296    } else {
1297        skia_safe::FontStyle::normal()
1298    };
1299    let Ok(typeface) = typeface_with_fallback(family, style) else {
1300        return 0.0;
1301    };
1302    let font = skia_safe::Font::from_typeface(typeface, font_size);
1303    let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
1304    measure_text_with_fallback(text, &font, &emoji_font, 0.0)
1305}
1306
1307/// Apply per-component CSS overrides for things that the legacy
1308/// `Widget::measure` derived from constraints (e.g. divider stretching to its
1309/// parent, line bounding box from its endpoints).
1310fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
1311    use Component::*;
1312    match component {
1313        Text(t) => {
1314            // M1: `white-space: nowrap|pre` must style the *content*, not
1315            // silently resize the *box*. Without this, CSS's "automatic
1316            // minimum size" (min-width: auto + the default overflow:
1317            // visible) lets a nowrap text's own auto-width box grow to its
1318            // full natural width inside a flex container — which can push
1319            // or resize flex siblings the author never touched, a
1320            // surprising failure mode for a tool whose whole model is
1321            // authors declaring explicit positions/sizes. Forcing
1322            // `min-width: 0` (only when the author hasn't set their own)
1323            // keeps the box within whatever space its container gives it;
1324            // the painter (`text.rs`) still draws the full unwrapped line
1325            // regardless of that box width, so the text visibly bleeds past
1326            // it exactly as `white-space: nowrap` should — it's the box
1327            // that stays put, not the content.
1328            let nowrap = matches!(
1329                t.style.white_space,
1330                Some(
1331                    rustmotion_core::css::style::WhiteSpace::Nowrap
1332                        | rustmotion_core::css::style::WhiteSpace::Pre
1333                )
1334            );
1335            if nowrap && css.min_width.is_none() {
1336                css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1337            }
1338        }
1339        // M1 follow-up: same reasoning as the `Text` arm above — now that
1340        // `gradient_text.rs` and `caption.rs` word-wrap and respect
1341        // `white-space: nowrap|pre` too (see `intrinsic.rs`'s
1342        // `GradientTextIntrinsic`/`CaptionIntrinsic`), their auto-width
1343        // boxes can hit the same CSS automatic-minimum-size growth when
1344        // nowrap/pre is set with no explicit width.
1345        GradientText(t) => {
1346            let nowrap = matches!(
1347                t.style.white_space,
1348                Some(
1349                    rustmotion_core::css::style::WhiteSpace::Nowrap
1350                        | rustmotion_core::css::style::WhiteSpace::Pre
1351                )
1352            );
1353            if nowrap && css.min_width.is_none() {
1354                css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1355            }
1356        }
1357        Caption(c) => {
1358            let nowrap = matches!(
1359                c.style.white_space,
1360                Some(
1361                    rustmotion_core::css::style::WhiteSpace::Nowrap
1362                        | rustmotion_core::css::style::WhiteSpace::Pre
1363                )
1364            );
1365            if nowrap && css.min_width.is_none() {
1366                css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1367            }
1368        }
1369        Divider(d) => match d.direction {
1370            DividerDirection::Horizontal => {
1371                // Stretch horizontally in flex row/column parents (cross-axis
1372                // for column = horizontal). Width stays auto.
1373                if css.height.is_none() {
1374                    css.height = Some(CSize::Length(CLP::Px(d.thickness)));
1375                }
1376                if css.width.is_none() {
1377                    css.width = match d.length {
1378                        Some(l) => Some(CSize::Length(CLP::Px(l))),
1379                        None => Some(CSize::Length(CLP::String("100%".into()))),
1380                    };
1381                }
1382                if css.align_self.is_none() {
1383                    css.align_self = Some(AlignSelf::Stretch);
1384                }
1385            }
1386            DividerDirection::Vertical => {
1387                if css.width.is_none() {
1388                    css.width = Some(CSize::Length(CLP::Px(d.thickness)));
1389                }
1390                if css.height.is_none() {
1391                    css.height = match d.length {
1392                        Some(l) => Some(CSize::Length(CLP::Px(l))),
1393                        None => Some(CSize::Length(CLP::String("100%".into()))),
1394                    };
1395                }
1396            }
1397        },
1398        Line(l) => {
1399            // Line draws inside its bounding box at (x1,y1)→(x2,y2). Use the
1400            // bounding box as the intrinsic size so taffy reserves enough room.
1401            let w = (l.x2 - l.x1).abs().max(1.0);
1402            let h = (l.y2 - l.y1).abs().max(1.0);
1403            if css.width.is_none() {
1404                css.width = Some(CSize::Length(CLP::Px(w)));
1405            }
1406            if css.height.is_none() {
1407                css.height = Some(CSize::Length(CLP::Px(h)));
1408            }
1409        }
1410        Arrow(a) => {
1411            // Endpoint bounding box + padding for the arrowhead/curve overshoot.
1412            let pad = ARROW_BBOX_PADDING + a.arrow_size.max(0.0);
1413            let w = (a.x2 - a.x1).abs().max(1.0) + pad;
1414            let h = (a.y2 - a.y1).abs().max(1.0) + pad;
1415            if css.width.is_none() {
1416                css.width = Some(CSize::Length(CLP::Px(w)));
1417            }
1418            if css.height.is_none() {
1419                css.height = Some(CSize::Length(CLP::Px(h)));
1420            }
1421        }
1422        Connector(c) => {
1423            let pad = ARROW_BBOX_PADDING + c.arrow_size.max(0.0);
1424            let w = (c.to.x - c.from.x).abs().max(1.0) + pad;
1425            let h = (c.to.y - c.from.y).abs().max(1.0) + pad;
1426            if css.width.is_none() {
1427                css.width = Some(CSize::Length(CLP::Px(w)));
1428            }
1429            if css.height.is_none() {
1430                css.height = Some(CSize::Length(CLP::Px(h)));
1431            }
1432        }
1433        Cursor(cur) => {
1434            // Fixed-size pointer; legacy measure returns (width, height).
1435            if css.width.is_none() {
1436                css.width = Some(CSize::Length(CLP::Px(cur.width)));
1437            }
1438            if css.height.is_none() {
1439                css.height = Some(CSize::Length(CLP::Px(cur.height)));
1440            }
1441        }
1442        SuccessCheck(c) => {
1443            // The halo is the box: the entrance scales *within* it (0.72→1),
1444            // so the mark never needs more room than its own diameter.
1445            apply_default_size(css, c.size, c.size);
1446        }
1447        Pointer(p) => {
1448            // The box is the arrow glyph, not the area it travels over: the
1449            // waypoints translate the glyph away from this box, and the
1450            // geometry checker exempts `pointer` for exactly that reason.
1451            // Sizing the box to the travel instead would make the pointer
1452            // shove its flex siblings around.
1453            if css.width.is_none() {
1454                css.width = Some(CSize::Length(CLP::Px(p.size * 0.6)));
1455            }
1456            if css.height.is_none() {
1457                css.height = Some(CSize::Length(CLP::Px(p.size)));
1458            }
1459        }
1460        Particle(_) => {
1461            // Particles fill their parent (legacy returned the max constraints).
1462            if css.width.is_none() {
1463                css.width = Some(CSize::Length(CLP::String("100%".into())));
1464            }
1465            if css.height.is_none() {
1466                css.height = Some(CSize::Length(CLP::String("100%".into())));
1467            }
1468        }
1469        Switch(c) => {
1470            if css.width.is_none() {
1471                css.width = Some(CSize::Length(CLP::Px(c.width)));
1472            }
1473            if css.height.is_none() {
1474                css.height = Some(CSize::Length(CLP::Px(c.height)));
1475            }
1476        }
1477        Slider(c) => {
1478            if css.width.is_none() {
1479                css.width = Some(CSize::Length(CLP::Px(c.width)));
1480            }
1481            if css.height.is_none() {
1482                css.height = Some(CSize::Length(CLP::Px(c.height)));
1483            }
1484        }
1485        Progress(c) => {
1486            if css.width.is_none() {
1487                css.width = Some(CSize::Length(CLP::Px(c.width)));
1488            }
1489            if css.height.is_none() {
1490                css.height = Some(CSize::Length(CLP::Px(c.height)));
1491            }
1492        }
1493        List(c) => {
1494            if css.width.is_none() {
1495                css.width = Some(CSize::Length(CLP::Px(c.width)));
1496            }
1497            if css.height.is_none() {
1498                let font_size = c.style.font_size_px_or(16.0);
1499                let line_height = font_size * 1.3;
1500                let n = c.items.len() as f32;
1501                let h = n * line_height + (n - 1.0).max(0.0) * c.gap;
1502                css.height = Some(CSize::Length(CLP::Px(h)));
1503            }
1504        }
1505        Timeline(c) => {
1506            if css.width.is_none() {
1507                css.width = Some(CSize::Length(CLP::Px(c.width)));
1508            }
1509            if css.height.is_none() {
1510                let r = c.node_radius;
1511                let h = match c.direction {
1512                    TimelineDirection::Horizontal => r * 2.0 + c.font_size * 2.5 + 24.0,
1513                    TimelineDirection::Vertical => {
1514                        let n = c.steps.len().max(1) as f32;
1515                        n * (r * 2.0 + 64.0)
1516                    }
1517                };
1518                css.height = Some(CSize::Length(CLP::Px(h)));
1519            }
1520        }
1521        Notification(c) => {
1522            if css.width.is_none() {
1523                css.width = Some(CSize::Length(CLP::Px(c.width)));
1524            }
1525            if css.height.is_none() {
1526                let h = if c.message.is_some() { 96.0 } else { 64.0 };
1527                css.height = Some(CSize::Length(CLP::Px(h)));
1528            }
1529        }
1530        Rating(c) => {
1531            if css.width.is_none() {
1532                let count = c.max as f32;
1533                let w = count * c.size + (count - 1.0).max(0.0) * c.gap;
1534                css.width = Some(CSize::Length(CLP::Px(w)));
1535            }
1536            if css.height.is_none() {
1537                css.height = Some(CSize::Length(CLP::Px(c.size)));
1538            }
1539        }
1540        Avatar(c) => {
1541            if css.width.is_none() {
1542                css.width = Some(CSize::Length(CLP::Px(c.size)));
1543            }
1544            if css.height.is_none() {
1545                css.height = Some(CSize::Length(CLP::Px(c.size)));
1546            }
1547        }
1548        AvatarGroup(c) => {
1549            if css.width.is_none() {
1550                let visible = c.visible_count() as f32;
1551                let extra = if c.overflow_count() > 0 { 1.0 } else { 0.0 };
1552                let total = visible + extra;
1553                let step = (c.size - c.overlap).max(0.0);
1554                let w = if total <= 0.0 {
1555                    0.0
1556                } else {
1557                    c.size + (total - 1.0) * step
1558                };
1559                css.width = Some(CSize::Length(CLP::Px(w)));
1560            }
1561            if css.height.is_none() {
1562                css.height = Some(CSize::Length(CLP::Px(c.size)));
1563            }
1564        }
1565        QrCode(c) => {
1566            if css.width.is_none() {
1567                css.width = Some(CSize::Length(CLP::Px(c.size)));
1568            }
1569            if css.height.is_none() {
1570                css.height = Some(CSize::Length(CLP::Px(c.size)));
1571            }
1572        }
1573        Countdown(c) if (css.width.is_none() || css.height.is_none()) => {
1574            let visible = [c.show_hours, c.show_minutes, c.show_seconds]
1575                .iter()
1576                .filter(|v| **v)
1577                .count() as f32;
1578            let box_w = c.digit_size * 0.75;
1579            let box_h = c.digit_size * 1.2;
1580            let w = (visible * 2.0 * box_w) + ((visible - 1.0).max(0.0) * c.gap);
1581            if css.width.is_none() {
1582                css.width = Some(CSize::Length(CLP::Px(w)));
1583            }
1584            if css.height.is_none() {
1585                css.height = Some(CSize::Length(CLP::Px(box_h)));
1586            }
1587        }
1588        AudioSpectrum(_) => {
1589            if css.width.is_none() {
1590                css.width = Some(CSize::Length(CLP::Px(400.0)));
1591            }
1592            if css.height.is_none() {
1593                css.height = Some(CSize::Length(CLP::Px(120.0)));
1594            }
1595        }
1596        Waveform(_) => {
1597            if css.width.is_none() {
1598                css.width = Some(CSize::Length(CLP::Px(400.0)));
1599            }
1600            if css.height.is_none() {
1601                css.height = Some(CSize::Length(CLP::Px(80.0)));
1602            }
1603        }
1604
1605        // ── Round 4 audit, lot LAYOUT, constat 4: the 23-components block
1606        // below (`Callout` through `Lottie`) now routes every default size
1607        // through `apply_default_size`, which honours an explicit
1608        // `aspect-ratio` (see its own doc comment) instead of the two
1609        // guards below reaching separate, aspect-ratio-blind defaults —
1610        // `width: 400` + `aspect-ratio: 16/9` used to still get the
1611        // component's unrelated hardcoded default height (e.g. `shape`'s
1612        // 80px) instead of the 225px the ratio implies.
1613        // ── #126 / W3: the 23 components with no size source ─────────────
1614        //
1615        // A card's default flex column gives every child its width via
1616        // `align-items: stretch`, but height stays at the CSS auto-height
1617        // default (0 for a leaf with no intrinsic measurer) — these 23 paint
1618        // nothing. Like the arms above, every default here fires only when
1619        // the author hasn't set the corresponding `style` property, so a
1620        // component that already declares `width`/`height` keeps rendering
1621        // exactly as before. Both width *and* height are always set (not
1622        // just height) so the same defaults also work in a flex *row* (e.g.
1623        // `stat` cards side by side), where width — not height — is the one
1624        // that would otherwise collapse to 0.
1625        //
1626        // Text-bearing "bubble" components (callout/tooltip) and label-flow
1627        // components (pill_nav/stepper) are measured with the exact same
1628        // Skia metrics their own painters use (`measure_text_line_width`),
1629        // so the box matches the ink. Everything else uses either a formula
1630        // derived from the component's own fields (heatmap/skeleton/gauge/
1631        // marquee) or a fixed size justified by a documented convention
1632        // already established in this project's own skill docs
1633        // (`.claude/skills/rustmotion/rules/*.md`) or its own example
1634        // scenarios (`examples/*.json`) — never a number picked by feel.
1635        Callout(t) => {
1636            // Mirrors callout.rs's own `paint()`: 12px text padding, a
1637            // `font_size * 1.4` line height, and the arrow eating into
1638            // whichever axis it points along (width for Left/Right, height
1639            // for Top/Bottom/default). Sized to a single line — since the
1640            // box is fit exactly to the unwrapped text width, the painter's
1641            // own `wrap_text(text, font, Some(text_area_w))` never has a
1642            // reason to wrap, so painted output matches this box exactly.
1643            let font_size = t.style.font_size_px_or(16.0);
1644            let family = t.style.font_family_or("Inter");
1645            let text_w = measure_text_line_width(&t.text, font_size, family, false);
1646            let h_pad = 12.0; // callout.rs's own `let padding = 12.0;`
1647            let v_pad = 16.0; // breathing room around the line, same order of magnitude as h_pad
1648            let line_h = font_size * 1.4; // callout.rs's own `line_height = font_size * 1.4`
1649            let (extra_w, extra_h) = match t.arrow_direction {
1650                CalloutArrowDirection::Left | CalloutArrowDirection::Right => (t.arrow_size, 0.0),
1651                CalloutArrowDirection::Top | CalloutArrowDirection::Bottom => (0.0, t.arrow_size),
1652            };
1653            apply_default_size(
1654                css,
1655                text_w + h_pad * 2.0 + extra_w,
1656                line_h + v_pad + extra_h,
1657            );
1658        }
1659        Tooltip(t) => {
1660            // Same shape as Callout above; padding value borrowed from
1661            // callout.rs since tooltip.rs's own paint() centers text in the
1662            // body with no defined constant of its own.
1663            let font_size = t.style.font_size_px_or(t.font_size);
1664            let family = t.style.font_family_or("Inter");
1665            let text_w = measure_text_line_width(&t.text, font_size, family, false);
1666            let h_pad = 12.0;
1667            let v_pad = 16.0;
1668            let line_h = font_size * 1.4;
1669            let (extra_w, extra_h) = match t.arrow {
1670                TooltipArrow::Left | TooltipArrow::Right => (t.arrow_size, 0.0),
1671                TooltipArrow::Top | TooltipArrow::Bottom | TooltipArrow::None => {
1672                    (0.0, t.arrow_size)
1673                }
1674            };
1675            apply_default_size(
1676                css,
1677                text_w + h_pad * 2.0 + extra_w,
1678                line_h + v_pad + extra_h,
1679            );
1680        }
1681        PillNav(p) => {
1682            // `height` is already a declared field on the component (like
1683            // Progress/Switch/Slider above) — just promote it to CSS. Width
1684            // replicates pill_nav.rs's own private `compute_tab_layout()`
1685            // formula (h_pad = font_size*1.2 per side, `gap` before/after/
1686            // between every pill) using the same public fields and the same
1687            // `measure_text_with_fallback` call it makes internally.
1688            let font_size = p.style.font_size_px_or(14.0);
1689            let family = p.style.font_family_or("Inter");
1690            let h_pad = font_size * 1.2;
1691            let n = p.items.len() as f32;
1692            let labels_w: f32 = p
1693                .items
1694                .iter()
1695                .map(|label| measure_text_line_width(label, font_size, family, false) + h_pad * 2.0)
1696                .sum();
1697            let total_w = labels_w + p.gap * (n + 1.0).max(1.0);
1698            apply_default_size(css, total_w, p.height);
1699        }
1700        Marquee(m) => {
1701            // Marquee's whole purpose is to scroll unbounded content, so
1702            // there's no natural content width. A percentage (mirroring
1703            // `Particle`'s "fills its parent" default above) would be the
1704            // obvious choice, but it resolves to 0 against an indefinite
1705            // parent (a card that itself has no explicit width) — the same
1706            // "card with height: auto" case this issue asks to fix, so a
1707            // percentage default would still paint nothing in that case. A
1708            // fixed width sidesteps that: 800px matches this project's own
1709            // marquee usage (examples/mega-showcase.json and SKILL.md's own
1710            // example both use `style.width: 800`, or scale up from there —
1711            // mega-showcase's 1700px is that same scene's marquee spanning a
1712            // much wider bleed banner). Height follows the font-size-to-
1713            // height ratio both of those same real usages share:
1714            // `font_size: 24` paired with `style.height: 48`, i.e.
1715            // `2 × font_size`.
1716            let font_size = m.style.font_size_px_or(m.font_size);
1717            apply_default_size(css, 800.0, font_size * 2.0);
1718        }
1719        Stepper(s) => {
1720            // Same shape as `Timeline`'s formula above (r*2 + label metrics),
1721            // adapted to stepper.rs's own layout constants: `cy = r + 4.0`,
1722            // label offset `r + 12.0`, description offset
1723            // `label_font_size + 4.0`, and its hardcoded label/description
1724            // font sizes (14px / 11px — not fields, copied from paint()).
1725            let n = (s.steps.len().max(1)) as f32;
1726            let has_desc = s.steps.iter().any(|st| st.description.is_some());
1727            const LABEL_FS: f32 = 14.0;
1728            const DESC_FS: f32 = 11.0;
1729            let max_label_w = s
1730                .steps
1731                .iter()
1732                .map(|st| measure_text_line_width(&st.label, LABEL_FS, "Inter", false))
1733                .fold(0.0_f32, f32::max);
1734            let max_desc_w = s
1735                .steps
1736                .iter()
1737                .filter_map(|st| st.description.as_deref())
1738                .map(|d| measure_text_line_width(d, DESC_FS, "Inter", false))
1739                .fold(0.0_f32, f32::max);
1740            match s.orientation {
1741                StepperOrientation::Horizontal => {
1742                    // Per-step allocation: the node needs ~3 diameters of
1743                    // breathing room (a common stepper-UI spacing
1744                    // convention), or enough for its longest label/desc,
1745                    // whichever is larger.
1746                    let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0);
1747                    let label_h = LABEL_FS * 1.3;
1748                    let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 };
1749                    let h = s.node_size + 4.0 + 12.0 + label_h + desc_h;
1750                    apply_default_size(css, per_step * n, h);
1751                }
1752                StepperOrientation::Vertical => {
1753                    let label_w = max_label_w.max(max_desc_w);
1754                    let w = s.node_size + 12.0 + label_w + 24.0;
1755                    let label_block = if has_desc {
1756                        LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0
1757                    } else {
1758                        LABEL_FS * 1.3 + 8.0
1759                    };
1760                    let per_step = (s.node_size * 2.0).max(label_block);
1761                    apply_default_size(css, w, per_step * n);
1762                }
1763            }
1764        }
1765        TagCloud(tc) => {
1766            // Replicates tag_cloud.rs's own per-tag metrics (bold Inter,
1767            // weight-normalized font size between min/max_font_size, its
1768            // hardcoded h_gap=12/v_gap=8) to sum a single-line content width,
1769            // then wraps that at a conventional card-content width (matching
1770            // this project's own tag_cloud usage in
1771            // examples/mega-showcase.json) to estimate a line count and
1772            // hence a height — an approximation of the real flow-wrap
1773            // algorithm, not a re-implementation of it.
1774            let n = tc.tags.len();
1775            if n > 0 {
1776                let min_w = tc.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min);
1777                let max_w = tc.tags.iter().map(|t| t.weight).fold(f64::MIN, f64::max);
1778                let range = (max_w - min_w).max(0.001);
1779                const H_GAP: f32 = 12.0;
1780                const V_GAP: f32 = 8.0;
1781                let total_w: f32 = tc
1782                    .tags
1783                    .iter()
1784                    .map(|t| {
1785                        let normalized = ((t.weight - min_w) / range) as f32;
1786                        let fs =
1787                            tc.min_font_size + normalized * (tc.max_font_size - tc.min_font_size);
1788                        measure_text_line_width(&t.text, fs, "Inter", true) + H_GAP
1789                    })
1790                    .sum();
1791                const CAP_W: f32 = 600.0;
1792                let box_w = total_w.clamp(CAP_W * 0.3, CAP_W);
1793                let lines = (total_w / box_w).ceil().max(1.0);
1794                let line_h = tc.max_font_size * 1.3;
1795                let box_h = lines * line_h + (lines - 1.0).max(0.0) * V_GAP;
1796                apply_default_size(css, box_w, box_h);
1797            }
1798        }
1799        Heatmap(h) => {
1800            // Fully content-derived from heatmap.rs's own paint() formula:
1801            // `step = cell_size + cell_gap`, cell (col,row) drawn at
1802            // `(col*step, row*step)` sized `cell_size` — so the painted
1803            // extent is exactly `(cols-1)*step + cell_size` per axis.
1804            let rows = h.data.len();
1805            let cols = h.data.iter().map(|r| r.len()).max().unwrap_or(0);
1806            let step = h.cell_size + h.cell_gap;
1807            let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1808            let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1809            apply_default_size(css, w, hh);
1810        }
1811        Sparkline(_) => {
1812            // "Sparkline: no axes, no labels, compact (120x40 default),
1813            // inline use" — documented in
1814            // .claude/skills/rustmotion/rules/data-viz-components.md.
1815            apply_default_size(css, 120.0, 40.0);
1816        }
1817        Stat(_) => {
1818            // Documented default from
1819            // .claude/skills/rustmotion/rules/stat-cards.md's own "GOOD"
1820            // example: `style: { width: 280, height: 180 }`. This is also
1821            // the fix for the issue's second bug: three `stat`s in a flex
1822            // row with no explicit size rendered zero pixels because width
1823            // (not just height) collapsed to 0 in a row context.
1824            apply_default_size(css, 280.0, 180.0);
1825        }
1826        Gauge(g) => {
1827            // Square — gauge.rs's own paint() derives its ring radius from
1828            // `min(w, h)/2 - track_width/2 - 4`. Solved backwards for a
1829            // target radius of 88px (chosen so the value-text font-size —
1830            // this same file's own `radius * 0.45` — comes out to ~40px,
1831            // comfortably legible), so the box scales with the component's
1832            // own `track_width` field rather than a size picked independent
1833            // of it. At the default `track_width` (16px) this lands on
1834            // exactly 200×200, which also matches the midpoint of the
1835            // documented "hero icon" desktop range (160–200px) in
1836            // icon-sizing-hierarchy.md.
1837            const TARGET_RADIUS: f32 = 88.0;
1838            let size = 2.0 * (TARGET_RADIUS + g.track_width / 2.0 + 4.0);
1839            apply_default_size(css, size, size);
1840        }
1841        DotMap(_) => {
1842            // 2:1 — the standard aspect ratio for an equirectangular world
1843            // map (360° longitude : 180° latitude), the projection
1844            // dot_map.rs's own `geo_to_screen` implements. dot_map.rs always
1845            // paints a full-box background rect first, so any positive size
1846            // shows ink even with zero points.
1847            apply_default_size(css, 640.0, 320.0);
1848        }
1849        Comparison(_) => {
1850            // No natural intrinsic size (the painter just splits whatever
1851            // box it's given at the divider) — matches this project's own
1852            // reference usage in examples/mega-showcase.json's `comparison`
1853            // block.
1854            apply_default_size(css, 520.0, 280.0);
1855        }
1856        Treemap(_) => {
1857            // Slice-and-dice treemap fills whatever box it's given — matches
1858            // this project's own reference usage in
1859            // examples/mega-showcase.json's `treemap` block (near-square,
1860            // the conventional treemap aspect since its rectangles are area-
1861            // proportional in both axes).
1862            apply_default_size(css, 416.0, 368.0);
1863        }
1864        Chart(c) => {
1865            // Pie/donut/radar/radial_bar are inherently circular — a square
1866            // box avoids wasting space on one axis or clipping into an
1867            // ellipse. The other 8 chart types (bar/line/area/scatter/
1868            // funnel/waterfall/stacked_bar/horizontal_bar) read axis labels
1869            // best in a landscape 4:3, per data-viz-components.md's guidance
1870            // that charts are "larger, standalone" than a sparkline.
1871            let round = matches!(
1872                c.chart_type,
1873                ChartType::Pie | ChartType::Donut | ChartType::Radar | ChartType::RadialBar
1874            );
1875            let (dw, dh) = if round {
1876                (320.0, 320.0)
1877            } else {
1878                (400.0, 300.0)
1879            };
1880            apply_default_size(css, dw, dh);
1881        }
1882        Skeleton(s) => {
1883            // `rectangle`: documented default from data-viz-components.md's
1884            // own "GOOD" example (`{ "width": 400, "height": 200 }`).
1885            // `circle`: matches this file's own Icon/Avatar-adjacent 64px
1886            // convention (skeleton circles most commonly stand in for an
1887            // avatar). `text`: fully derived from the component's own
1888            // `lines`/`line_height`/`line_gap` fields, mirroring `List`'s
1889            // formula above — matches skeleton.rs's own per-line paint loop
1890            // (`y = i * (line_height + line_gap)`) exactly.
1891            match s.variant {
1892                SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0),
1893                SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0),
1894                SkeletonVariant::Text => {
1895                    let n = s.lines.max(1) as f32;
1896                    let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap;
1897                    apply_default_size(css, 240.0, h);
1898                }
1899            }
1900        }
1901        Mockup(m) => {
1902            // Per-device aspect matches each device's real-world screen
1903            // proportions: phones (iPhone/Android) ~9:19.5 (modern
1904            // flagship aspect), laptop 16:10 (the common MacBook/ultrabook
1905            // ratio), browser 16:9 (the standard desktop viewport ratio).
1906            let (dw, dh) = match m.device {
1907                MockupDevice::Iphone | MockupDevice::Android => (320.0, 690.0),
1908                MockupDevice::Laptop => (640.0, 400.0),
1909                MockupDevice::Browser => (640.0, 360.0),
1910            };
1911            apply_default_size(css, dw, dh);
1912        }
1913        Icon(_) => {
1914            // 64×64 — the midpoint of the documented "card / feature icon"
1915            // role across all three device classes in
1916            // icon-sizing-hierarchy.md (desktop 40–56px, mobile 72–96px,
1917            // square 60–80px), and a size icon asset systems near-universally
1918            // ship as a default export (24/32/48/64 being the common family).
1919            apply_default_size(css, 64.0, 64.0);
1920        }
1921        Svg(_) => {
1922            // 200×200 — square, since an arbitrary vector graphic (icon,
1923            // diagram, or logo) has no single natural aspect; matches the
1924            // common equal-aspect SVG viewBox convention and sits above
1925            // Icon's 64px "card icon" role for the more elaborate content
1926            // `svg` typically carries (illustrations/diagrams, not glyphs).
1927            apply_default_size(css, 200.0, 200.0);
1928        }
1929        Shape(_) => {
1930            // 80×80 — matches the median of this project's own decorative
1931            // (non full-bleed-background, non-divider-line) shape usages in
1932            // examples/*.json, which cluster at 44–70px for accent shapes
1933            // (26, 36, 44, 60, 70, 140 — median ~55, rounded up for
1934            // visibility as a standalone default rather than a same-scene
1935            // accent tuned against neighbours).
1936            apply_default_size(css, 80.0, 80.0);
1937        }
1938        Image(_) => {
1939            // 4:3 (400×300) — the traditional default photo aspect ratio,
1940            // distinct from Video/Gif's 16:9 below so a generic still image
1941            // doesn't presume widescreen framing.
1942            apply_default_size(css, 400.0, 300.0);
1943        }
1944        Video(_) | Gif(_) => {
1945            // 16:9 (400×225) — the industry-standard video aspect ratio
1946            // (matches every render resolution this project documents:
1947            // 1920×1080, 1280×720), scaled down to a card-sized default.
1948            apply_default_size(css, 400.0, 225.0);
1949        }
1950        Lottie(_) => {
1951            // 300×300 — square, matching the aspect the vast majority of
1952            // Lottie animation assets ship at (LottieFiles' own marketplace
1953            // preview convention is a 1:1 canvas).
1954            apply_default_size(css, 300.0, 300.0);
1955        }
1956
1957        _ => {}
1958    }
1959}
1960
1961/// Apply a component's natural default size (`dw` × `dh`) to `css`, honouring
1962/// an explicit `aspect-ratio` instead of always falling back to `dw`/`dh`
1963/// independently (round 4 audit, lot LAYOUT, constat 4 — the previous code
1964/// guarded each axis with its own `is_none()` check and never looked at
1965/// `aspect-ratio`, so `width: 400` + `aspect-ratio: 16/9` still got the
1966/// component's unrelated hardcoded default height instead of 225).
1967///
1968/// - Both axes already set: untouched (the author fully specified the box).
1969/// - One axis set to a fixed pixel length, `aspect-ratio` present: the other
1970///   axis is derived from it (`h = w / ratio` or `w = h * ratio`) — the CSS
1971///   replaced-element sizing rule for a single definite axis plus a
1972///   preferred aspect ratio.
1973/// - Neither axis set: the natural default width is kept (there is no
1974///   author-declared axis to derive from), and height is derived from
1975///   `aspect-ratio` when present, the natural default height otherwise.
1976///
1977/// `min-*`/`max-*` need no equivalent guard here: taffy clamps the final
1978/// used size against them at layout time regardless of what `size` resolves
1979/// to (`style.min_size`/`max_size` in `taffy_bridge::to_taffy_style`), so a
1980/// default below `min-width` is corrected downstream, not silently wrong.
1981fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) {
1982    let ratio = css.aspect_ratio.filter(|r| *r > 0.0);
1983    match (css.width.is_some(), css.height.is_some()) {
1984        (true, true) => {}
1985        (true, false) => {
1986            let h = fixed_px(css.width.as_ref())
1987                .zip(ratio)
1988                .map(|(w, r)| w / r)
1989                .unwrap_or(dh);
1990            css.height = Some(CSize::Length(CLP::Px(h)));
1991        }
1992        (false, true) => {
1993            let w = fixed_px(css.height.as_ref())
1994                .zip(ratio)
1995                .map(|(h, r)| h * r)
1996                .unwrap_or(dw);
1997            css.width = Some(CSize::Length(CLP::Px(w)));
1998        }
1999        (false, false) => {
2000            css.width = Some(CSize::Length(CLP::Px(dw)));
2001            let h = ratio.map(|r| dw / r).unwrap_or(dh);
2002            css.height = Some(CSize::Length(CLP::Px(h)));
2003        }
2004    }
2005}
2006
2007/// Extract a fixed pixel value from a `Size`, if it resolves to one without a
2008/// `LengthContext` (only `Size::Length(LengthPercentage::Px(_))` — a bare
2009/// number or `"NNpx"`). `%`/`vw`/`vh`/`em`/`rem` and `auto` return `None`:
2010/// `apply_default_size` can't derive a ratio from a length it can't resolve
2011/// at build time, so it falls back to the component's hardcoded default.
2012fn fixed_px(size: Option<&CSize>) -> Option<f32> {
2013    match size? {
2014        CSize::Length(lp) => match lp.try_parse()? {
2015            rustmotion_core::css::units::ParsedLength::Px(v) => Some(v),
2016            _ => None,
2017        },
2018        _ => None,
2019    }
2020}
2021
2022/// Borrow the `CssStyle` from any component.
2023fn component_style(c: &Component) -> &CssStyle {
2024    use Component::*;
2025    match c {
2026        Text(c) => &c.style,
2027        Shape(c) => &c.style,
2028        Image(c) => &c.style,
2029        Icon(c) => &c.style,
2030        Svg(c) => &c.style,
2031        Video(c) => &c.style,
2032        Gif(c) => &c.style,
2033        Counter(c) => &c.style,
2034        Cursor(c) => &c.style,
2035        Caption(c) => &c.style,
2036        Codeblock(c) => &c.style,
2037        Connector(c) => &c.style,
2038        Avatar(c) => &c.style,
2039        AvatarGroup(c) => &c.style,
2040        Arrow(c) => &c.style,
2041        Badge(c) => &c.style,
2042        Callout(c) => &c.style,
2043        Chart(c) => &c.style,
2044        Comparison(c) => &c.style,
2045        Countdown(c) => &c.style,
2046        Divider(c) => &c.style,
2047        DotMap(c) => &c.style,
2048        Gauge(c) => &c.style,
2049        GradientText(c) => &c.style,
2050        Heatmap(c) => &c.style,
2051        Kbd(c) => &c.style,
2052        Line(c) => &c.style,
2053        List(c) => &c.style,
2054        Lottie(c) => &c.style,
2055        Marquee(c) => &c.style,
2056        Mockup(c) => &c.style,
2057        Notification(c) => &c.style,
2058        Particle(c) => &c.style,
2059        PillNav(c) => &c.style,
2060        Progress(c) => &c.style,
2061        QrCode(c) => &c.style,
2062        NumberWheel(c) => &c.style,
2063        SuccessCheck(c) => &c.style,
2064        Pointer(c) => &c.style,
2065        Rating(c) => &c.style,
2066        Skeleton(c) => &c.style,
2067        Slider(c) => &c.style,
2068        Sparkline(c) => &c.style,
2069        Stat(c) => &c.style,
2070        Stepper(c) => &c.style,
2071        Switch(c) => &c.style,
2072        RichText(c) => &c.style,
2073        Table(c) => &c.style,
2074        TagCloud(c) => &c.style,
2075        Terminal(c) => &c.style,
2076        Timeline(c) => &c.style,
2077        Tooltip(c) => &c.style,
2078        Treemap(c) => &c.style,
2079        Positioned(c) => &c.style,
2080        Flex(c) => &c.style,
2081        Grid(c) => &c.style,
2082        Card(c) => &c.style,
2083        Container(c) => &c.style,
2084        AudioSpectrum(c) => &c.style,
2085        Waveform(c) => &c.style,
2086    }
2087}
2088
2089/// Short kind label for a component (for studio selection / inspector display).
2090pub fn component_kind(c: &Component) -> &'static str {
2091    use Component::*;
2092    match c {
2093        Text(_) => "text",
2094        Shape(_) => "shape",
2095        Image(_) => "image",
2096        Icon(_) => "icon",
2097        Svg(_) => "svg",
2098        Video(_) => "video",
2099        Gif(_) => "gif",
2100        Counter(_) => "counter",
2101        Cursor(_) => "cursor",
2102        Caption(_) => "caption",
2103        Codeblock(_) => "codeblock",
2104        Connector(_) => "connector",
2105        Avatar(_) => "avatar",
2106        AvatarGroup(_) => "avatar_group",
2107        Arrow(_) => "arrow",
2108        Badge(_) => "badge",
2109        Callout(_) => "callout",
2110        Chart(_) => "chart",
2111        Comparison(_) => "comparison",
2112        Countdown(_) => "countdown",
2113        Divider(_) => "divider",
2114        DotMap(_) => "dot_map",
2115        Gauge(_) => "gauge",
2116        GradientText(_) => "gradient_text",
2117        Heatmap(_) => "heatmap",
2118        Kbd(_) => "kbd",
2119        Line(_) => "line",
2120        List(_) => "list",
2121        Lottie(_) => "lottie",
2122        Marquee(_) => "marquee",
2123        Mockup(_) => "mockup",
2124        Notification(_) => "notification",
2125        Particle(_) => "particle",
2126        PillNav(_) => "pill_nav",
2127        Progress(_) => "progress",
2128        QrCode(_) => "qrcode",
2129        NumberWheel(_) => "number_wheel",
2130        SuccessCheck(_) => "success_check",
2131        Pointer(_) => "pointer",
2132        Rating(_) => "rating",
2133        Skeleton(_) => "skeleton",
2134        Slider(_) => "slider",
2135        Sparkline(_) => "sparkline",
2136        Stat(_) => "stat",
2137        Stepper(_) => "stepper",
2138        Switch(_) => "switch",
2139        RichText(_) => "rich_text",
2140        Table(_) => "table",
2141        TagCloud(_) => "tag_cloud",
2142        Terminal(_) => "terminal",
2143        Timeline(_) => "timeline",
2144        Tooltip(_) => "tooltip",
2145        Treemap(_) => "treemap",
2146        Positioned(_) => "positioned",
2147        Flex(_) => "flex",
2148        Grid(_) => "grid",
2149        Card(_) => "card",
2150        Container(_) => "container",
2151        AudioSpectrum(_) => "audio_spectrum",
2152        Waveform(_) => "waveform",
2153    }
2154}
2155
2156#[cfg(test)]
2157mod tests {
2158    use super::*;
2159    use rustmotion_core::css::style::{
2160        CssStyle, Display, Edges, FlexDirection, Gap, Size as CSize,
2161    };
2162    use rustmotion_core::css::taffy_bridge::ConversionContext;
2163    use rustmotion_core::css::units::LengthPercentage;
2164    use rustmotion_core::css::units::LengthPercentage as CLP;
2165    use rustmotion_core::engine::layout_pass::run_layout;
2166    use serde_json::json;
2167
2168    fn make_card(children: Vec<ChildComponent>, style: CssStyle) -> Component {
2169        Component::Card(crate::card::Card {
2170            children,
2171            timing: Default::default(),
2172            style,
2173            timeline: Vec::new(),
2174            stagger: None,
2175            time_scale: None,
2176            time_offset: None,
2177        })
2178    }
2179
2180    fn make_shape(width: f32, height: f32) -> ChildComponent {
2181        ChildComponent {
2182            component: Component::Shape(crate::shape::Shape {
2183                shape: rustmotion_core::schema::ShapeType::Rect,
2184                text: None,
2185                timing: Default::default(),
2186                style: CssStyle {
2187                    width: Some(CSize::Length(CLP::Px(width))),
2188                    height: Some(CSize::Length(CLP::Px(height))),
2189                    ..Default::default()
2190                },
2191                timeline: Vec::new(),
2192                stagger: None,
2193                fill: None,
2194                stroke: None,
2195            }),
2196            position: None,
2197            x: None,
2198            y: None,
2199            z_index: None,
2200            bleed: false,
2201        }
2202    }
2203
2204    fn make_text(content: &str, style: CssStyle) -> ChildComponent {
2205        ChildComponent {
2206            component: Component::Text(crate::text::Text {
2207                content: content.to_string(),
2208                max_width: None,
2209                timing: Default::default(),
2210                style,
2211                timeline: Vec::new(),
2212                stagger: None,
2213                text_shadow: None,
2214                stroke: None,
2215                text_background: None,
2216                caret: None,
2217                states: Vec::new(),
2218                swap: None,
2219            }),
2220            position: None,
2221            x: None,
2222            y: None,
2223            z_index: None,
2224            bleed: false,
2225        }
2226    }
2227
2228    #[test]
2229    fn empty_scene_has_only_root() {
2230        let built = build_scene(&[], (1920.0, 1080.0));
2231        assert_eq!(built.root.children.len(), 0);
2232        assert_eq!(built.components.len(), 1); // synthetic root slot
2233    }
2234
2235    #[test]
2236    fn component_kind_labels() {
2237        assert_eq!(component_kind(&make_shape(100.0, 50.0).component), "shape");
2238    }
2239
2240    #[test]
2241    fn build_child_records_source_path() {
2242        let card = make_card(
2243            vec![make_shape(10.0, 10.0), make_shape(10.0, 10.0)],
2244            CssStyle::default(),
2245        );
2246        let scene = vec![ChildComponent {
2247            component: card,
2248            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2249            x: None,
2250            y: None,
2251            z_index: None,
2252            bleed: false,
2253        }];
2254        let built = build_scene(&scene, (800.0, 600.0));
2255        let card_box = &built.root.children[0];
2256        assert_eq!(card_box.source_path.as_deref(), Some("/children/0"));
2257        assert_eq!(
2258            card_box.children[1].source_path.as_deref(),
2259            Some("/children/0/children/1")
2260        );
2261    }
2262
2263    #[test]
2264    fn flex_card_with_two_shapes_lays_out_vertically() {
2265        let card = make_card(
2266            vec![make_shape(200.0, 50.0), make_shape(200.0, 50.0)],
2267            CssStyle {
2268                display: Some(Display::Flex),
2269                flex_direction: Some(FlexDirection::Column),
2270                gap: Some(Gap::Uniform(LengthPercentage::Px(10.0))),
2271                padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2272                width: Some(CSize::Length(CLP::Px(300.0))),
2273                height: Some(CSize::Length(CLP::Px(200.0))),
2274                ..Default::default()
2275            },
2276        );
2277        let scene = vec![ChildComponent {
2278            component: card,
2279            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2280            x: None,
2281            y: None,
2282            z_index: None,
2283            bleed: false,
2284        }];
2285        let built = build_scene(&scene, (1920.0, 1080.0));
2286        assert_eq!(built.root.children.len(), 1);
2287        let card_box = &built.root.children[0];
2288        assert_eq!(card_box.css.display, Some(Display::Flex));
2289        assert_eq!(card_box.css.flex_direction, Some(FlexDirection::Column));
2290        assert_eq!(card_box.children.len(), 2);
2291
2292        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2293        let card_layout = layout.get(card_box.id).expect("card laid out");
2294        assert_eq!(card_layout.x, 0.0);
2295        assert_eq!(card_layout.y, 0.0);
2296        assert_eq!(card_layout.width, 300.0);
2297        assert_eq!(card_layout.height, 200.0);
2298
2299        let c1 = layout
2300            .get(card_box.children[0].id)
2301            .expect("shape 1 laid out");
2302        let c2 = layout
2303            .get(card_box.children[1].id)
2304            .expect("shape 2 laid out");
2305        // Padding 20 from top, then first shape 50 high, gap 10 → 80.
2306        assert_eq!(c1.x, 20.0);
2307        assert_eq!(c1.y, 20.0);
2308        assert_eq!(c2.x, 20.0);
2309        assert_eq!(c2.y, 80.0);
2310    }
2311
2312    #[test]
2313    fn absolute_child_uses_top_left() {
2314        let scene = vec![ChildComponent {
2315            component: Component::Shape(crate::shape::Shape {
2316                shape: rustmotion_core::schema::ShapeType::Rect,
2317                text: None,
2318                timing: Default::default(),
2319                style: CssStyle {
2320                    width: Some(CSize::Length(CLP::Px(100.0))),
2321                    height: Some(CSize::Length(CLP::Px(80.0))),
2322                    ..Default::default()
2323                },
2324                timeline: Vec::new(),
2325                stagger: None,
2326                fill: None,
2327                stroke: None,
2328            }),
2329            position: Some(crate::PositionMode::Absolute { x: 40.0, y: 30.0 }),
2330            x: None,
2331            y: None,
2332            z_index: None,
2333            bleed: false,
2334        }];
2335        let built = build_scene(&scene, (400.0, 400.0));
2336        let layout = run_layout(&built.root, (400.0, 400.0), &ConversionContext::default());
2337        let shape_id = built.root.children[0].id;
2338        let l = layout.get(shape_id).expect("shape laid out");
2339        assert_eq!(l.x, 40.0);
2340        assert_eq!(l.y, 30.0);
2341        assert_eq!(l.width, 100.0);
2342        assert_eq!(l.height, 80.0);
2343    }
2344
2345    #[test]
2346    fn horizontal_divider_stretches_to_parent_width() {
2347        let divider = ChildComponent {
2348            component: Component::Divider(crate::divider::Divider {
2349                direction: DividerDirection::Horizontal,
2350                thickness: 4.0,
2351                line_style: Default::default(),
2352                length: None,
2353                timing: Default::default(),
2354                style: CssStyle::default(),
2355                timeline: Vec::new(),
2356                stagger: None,
2357            }),
2358            position: None,
2359            x: None,
2360            y: None,
2361            z_index: None,
2362            bleed: false,
2363        };
2364        let scene = vec![divider];
2365        let built = build_scene(&scene, (800.0, 600.0));
2366        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2367        let id = built.root.children[0].id;
2368        let l = layout.get(id).expect("divider laid out");
2369        assert_eq!(l.height, 4.0);
2370        assert_eq!(l.width, 800.0);
2371    }
2372
2373    #[test]
2374    fn text_child_in_flex_card_gets_cosmic_intrinsic_size() {
2375        // A flex column card with no fixed size — its children's intrinsic
2376        // sizes should determine the card's width/height. The text child
2377        // must be measured via cosmic-text, not collapse to 0×0.
2378        use crate::card::Card;
2379        use crate::text::Text;
2380
2381        use rustmotion_core::css::units::Length;
2382
2383        let text = ChildComponent {
2384            component: Component::Text(Text {
2385                content: "Hello World".into(),
2386                max_width: None,
2387                timing: Default::default(),
2388                style: CssStyle {
2389                    font_size: Some(Length::Px(40.0)),
2390                    ..Default::default()
2391                },
2392                timeline: Vec::new(),
2393                stagger: None,
2394                text_shadow: None,
2395                stroke: None,
2396                text_background: None,
2397                caret: None,
2398                states: Vec::new(),
2399                swap: None,
2400            }),
2401            position: None,
2402            x: None,
2403            y: None,
2404            z_index: None,
2405            bleed: false,
2406        };
2407
2408        let card = ChildComponent {
2409            component: Component::Card(Card {
2410                children: vec![text],
2411                timing: Default::default(),
2412                style: CssStyle {
2413                    display: Some(Display::Flex),
2414                    flex_direction: Some(FlexDirection::Column),
2415                    padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2416                    ..Default::default()
2417                },
2418                timeline: Vec::new(),
2419                stagger: None,
2420                time_scale: None,
2421                time_offset: None,
2422            }),
2423            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2424            x: None,
2425            y: None,
2426            z_index: None,
2427            bleed: false,
2428        };
2429
2430        let scene = vec![card];
2431        let built = build_scene(&scene, (1920.0, 1080.0));
2432        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2433
2434        let card_id = built.root.children[0].id;
2435        let text_id = built.root.children[0].children[0].id;
2436        let text_layout = layout.get(text_id).expect("text laid out");
2437
2438        assert!(
2439            text_layout.width > 0.0,
2440            "text width should be > 0, got {}",
2441            text_layout.width
2442        );
2443        assert!(
2444            text_layout.height >= 40.0,
2445            "text height should be at least one line tall, got {}",
2446            text_layout.height
2447        );
2448
2449        // Card height should hug the text + 2×padding(20) = ~text_h + 40.
2450        let card_layout = layout.get(card_id).expect("card laid out");
2451        assert!(
2452            card_layout.height >= text_layout.height + 40.0 - 1.0,
2453            "card height ({}) should fit text + padding ({}+40)",
2454            card_layout.height,
2455            text_layout.height,
2456        );
2457    }
2458
2459    #[test]
2460    fn arrow_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2461        let arrow = ChildComponent {
2462            component: Component::Arrow(crate::arrow::Arrow {
2463                x1: 10.0,
2464                y1: 20.0,
2465                x2: 110.0,
2466                y2: 80.0,
2467                cp: None,
2468                cp1: None,
2469                cp2: None,
2470                curve: None,
2471                width: 4.0,
2472                color: "#fff".into(),
2473                arrow_end: true,
2474                arrow_start: false,
2475                arrow_size: 12.0,
2476                dashed: None,
2477                timing: Default::default(),
2478                style: CssStyle::default(),
2479                timeline: Vec::new(),
2480                stagger: None,
2481            }),
2482            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2483            x: None,
2484            y: None,
2485            z_index: None,
2486            bleed: false,
2487        };
2488        let scene = vec![arrow];
2489        let built = build_scene(&scene, (800.0, 600.0));
2490        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2491        let l = layout
2492            .get(built.root.children[0].id)
2493            .expect("arrow laid out");
2494        // bbox 100×60 + (16 padding + 12 arrow_size) = 128×88.
2495        assert_eq!(l.width, 128.0);
2496        assert_eq!(l.height, 88.0);
2497    }
2498
2499    #[test]
2500    fn connector_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2501        let conn = ChildComponent {
2502            component: Component::Connector(crate::connector::Connector {
2503                from: crate::connector::ConnectorPoint { x: 50.0, y: 0.0 },
2504                to: crate::connector::ConnectorPoint { x: 150.0, y: 50.0 },
2505                routing: Default::default(),
2506                curvature: 0.4,
2507                width: 2.0,
2508                color: "#fff".into(),
2509                arrow_end: true,
2510                arrow_start: false,
2511                arrow_size: 10.0,
2512                dashed: None,
2513                timing: Default::default(),
2514                style: CssStyle::default(),
2515                timeline: Vec::new(),
2516                stagger: None,
2517            }),
2518            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2519            x: None,
2520            y: None,
2521            z_index: None,
2522            bleed: false,
2523        };
2524        let scene = vec![conn];
2525        let built = build_scene(&scene, (800.0, 600.0));
2526        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2527        let l = layout
2528            .get(built.root.children[0].id)
2529            .expect("connector laid out");
2530        // bbox 100×50 + (16 + 10) = 126×76.
2531        assert_eq!(l.width, 126.0);
2532        assert_eq!(l.height, 76.0);
2533    }
2534
2535    #[test]
2536    fn counter_intrinsic_size_reserves_space_for_max_value() {
2537        // 1234 → 1234 → format with 0 decimals → measure largest absolute value.
2538        // Expectation: width > 0 (cosmic-text didn't fail), height ≈ font_size × line_height.
2539        use crate::counter::Counter;
2540
2541        use rustmotion_core::css::units::Length;
2542        let counter = ChildComponent {
2543            component: Component::Counter(Counter {
2544                duration: None,
2545                from: 0.0,
2546                to: 1234.0,
2547                decimals: 0,
2548                separator: None,
2549                prefix: None,
2550                suffix: None,
2551                easing: Default::default(),
2552                timing: Default::default(),
2553                style: CssStyle {
2554                    font_size: Some(Length::Px(64.0)),
2555                    ..Default::default()
2556                },
2557                timeline: Vec::new(),
2558                stagger: None,
2559                text_shadow: None,
2560                stroke: None,
2561            }),
2562            position: Some(crate::PositionMode::Absolute { x: 10.0, y: 20.0 }),
2563            x: None,
2564            y: None,
2565            z_index: None,
2566            bleed: false,
2567        };
2568        let scene = vec![counter];
2569        let built = build_scene(&scene, (800.0, 600.0));
2570        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2571        let id = built.root.children[0].id;
2572        let l = layout.get(id).expect("counter laid out");
2573        assert!(
2574            l.width > 0.0,
2575            "counter width should be > 0, got {}",
2576            l.width
2577        );
2578        // line_height defaults to font_size × 1.3 = 83.2. Allow some slack.
2579        assert!(
2580            l.height >= 60.0,
2581            "counter height should be ≥ ~one line ({}), got {}",
2582            64.0,
2583            l.height
2584        );
2585    }
2586
2587    #[test]
2588    fn badge_intrinsic_size_includes_padding_and_text() {
2589        // Default size = Md → font_size 14, h_pad 12, v_pad 6, icon 18.
2590        // Without an icon, height ≈ 6×2 + 14×1.3 ≈ 30.2.
2591        use crate::badge::{Badge, BadgeSize, BadgeVariant};
2592
2593        let badge = ChildComponent {
2594            component: Component::Badge(Badge {
2595                text: "New".into(),
2596                icon: None,
2597                variant: BadgeVariant::Solid,
2598                badge_size: BadgeSize::Md,
2599                dot: false,
2600                dot_color: None,
2601                pulse: false,
2602                count: None,
2603                timing: Default::default(),
2604                style: CssStyle::default(),
2605                timeline: Vec::new(),
2606                stagger: None,
2607            }),
2608            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2609            x: None,
2610            y: None,
2611            z_index: None,
2612            bleed: false,
2613        };
2614        let scene = vec![badge];
2615        let built = build_scene(&scene, (400.0, 200.0));
2616        let layout = run_layout(&built.root, (400.0, 200.0), &ConversionContext::default());
2617        let id = built.root.children[0].id;
2618        let l = layout.get(id).expect("badge laid out");
2619        // h_pad×2 = 24 alone, plus the text width.
2620        assert!(
2621            l.width > 24.0,
2622            "badge width should exceed padding alone, got {}",
2623            l.width
2624        );
2625        assert!(
2626            (l.height - 30.2).abs() < 2.0,
2627            "badge height should be ~30.2, got {}",
2628            l.height
2629        );
2630    }
2631
2632    #[test]
2633    fn line_intrinsic_size_matches_endpoint_bounding_box() {
2634        let line = ChildComponent {
2635            component: Component::Line(crate::line::Line {
2636                x1: 10.0,
2637                y1: 20.0,
2638                x2: 110.0,
2639                y2: 80.0,
2640                width: 2.0,
2641                color: "#fff".into(),
2642                dashed: None,
2643                timing: Default::default(),
2644                style: CssStyle::default(),
2645                timeline: Vec::new(),
2646                stagger: None,
2647            }),
2648            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2649            x: None,
2650            y: None,
2651            z_index: None,
2652            bleed: false,
2653        };
2654        let scene = vec![line];
2655        let built = build_scene(&scene, (800.0, 600.0));
2656        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2657        let id = built.root.children[0].id;
2658        let l = layout.get(id).expect("line laid out");
2659        assert_eq!(l.width, 100.0);
2660        assert_eq!(l.height, 60.0);
2661    }
2662
2663    // ─── M2: rich_text intrinsic wired into component_intrinsic ─────────────
2664
2665    #[test]
2666    fn rich_text_child_gets_a_non_zero_intrinsic_size() {
2667        // Before the fix: rich_text had no `component_intrinsic` entry, so
2668        // an auto-sized rich_text laid out at 0×0 and was invisible.
2669        use crate::rich_text::{RichText, RichTextSpan};
2670        use rustmotion_core::css::units::Length;
2671
2672        let rich_text = ChildComponent {
2673            component: Component::RichText(RichText {
2674                spans: vec![
2675                    RichTextSpan {
2676                        text: "Save ".into(),
2677                        color: None,
2678                        font_size: None,
2679                        font_weight: None,
2680                        font_family: None,
2681                        font_style: None,
2682                        letter_spacing: None,
2683                    },
2684                    RichTextSpan {
2685                        text: "40%".into(),
2686                        color: Some("#5C39EE".into()),
2687                        font_size: None,
2688                        font_weight: None,
2689                        font_family: None,
2690                        font_style: None,
2691                        letter_spacing: None,
2692                    },
2693                ],
2694                max_width: None,
2695                timing: Default::default(),
2696                style: CssStyle {
2697                    font_size: Some(Length::Px(40.0)),
2698                    ..Default::default()
2699                },
2700                timeline: Vec::new(),
2701                stagger: None,
2702            }),
2703            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2704            x: None,
2705            y: None,
2706            z_index: None,
2707            bleed: false,
2708        };
2709        let scene = vec![rich_text];
2710        let built = build_scene(&scene, (800.0, 600.0));
2711        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2712        let id = built.root.children[0].id;
2713        let l = layout.get(id).expect("rich_text laid out");
2714        assert!(
2715            l.width > 0.0,
2716            "rich_text width should be > 0, got {}",
2717            l.width
2718        );
2719        assert!(
2720            l.height > 0.0,
2721            "rich_text height should be > 0, got {}",
2722            l.height
2723        );
2724    }
2725
2726    // ─── M3: the `glow` effect renders as a coloured drop-shadow ────────────
2727
2728    #[test]
2729    fn glow_effect_adds_a_coloured_drop_shadow_filter() {
2730        use rustmotion_core::css::style::{Color, FilterFn};
2731        use rustmotion_core::css::units::Length as CLength;
2732        use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2733
2734        let mut shape = make_shape(100.0, 100.0);
2735        if let Component::Shape(ref mut s) = shape.component {
2736            s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2737                color: "#5C39EE".to_string(),
2738                radius: 12.0,
2739                intensity: 1.0,
2740            })];
2741        }
2742        let anim = BuildAnimationCtx {
2743            time: 0.0,
2744            scenario_time: 0.0,
2745            scene_duration: 1.0,
2746            fps: 30,
2747        };
2748        let scene = [shape];
2749        let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2750        let filters = built.root.children[0]
2751            .css
2752            .filter
2753            .as_ref()
2754            .expect("glow must add a filter list");
2755        let shadow = filters
2756            .iter()
2757            .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2758            .expect("a DropShadow filter must be present");
2759        let FilterFn::DropShadow { blur, color, .. } = shadow else {
2760            unreachable!()
2761        };
2762        match blur {
2763            Some(CLength::Px(px)) => {
2764                assert_eq!(*px, 12.0, "blur radius must match GlowConfig.radius")
2765            }
2766            other => panic!("expected Some(Length::Px(12.0)) blur, got {:?}", other),
2767        }
2768        // The defect this fixes: `color: None` resolves to black downstream
2769        // (see `paint_pass.rs`'s `unwrap_or(SColor::BLACK)`). The colour must
2770        // be `Some` and must match the configured glow colour, not black.
2771        match color {
2772            Some(Color::Rgba { r, g, b, a }) => {
2773                assert_eq!(*r, 0x5C);
2774                assert_eq!(*g, 0x39);
2775                assert_eq!(*b, 0xEE);
2776                assert!(*a > 0.0, "alpha must be > 0 for the glow to be visible");
2777            }
2778            other => panic!("expected Some(Color::Rgba{{..}}), got {:?}", other),
2779        }
2780    }
2781
2782    #[test]
2783    fn glow_effect_scales_alpha_by_intensity() {
2784        use rustmotion_core::css::style::{Color, FilterFn};
2785        use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2786
2787        let mut shape = make_shape(100.0, 100.0);
2788        if let Component::Shape(ref mut s) = shape.component {
2789            s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2790                color: "#FFFFFFFF".to_string(), // opaque white
2791                radius: 10.0,
2792                intensity: 0.5,
2793            })];
2794        }
2795        let anim = BuildAnimationCtx {
2796            time: 0.0,
2797            scenario_time: 0.0,
2798            scene_duration: 1.0,
2799            fps: 30,
2800        };
2801        let scene = [shape];
2802        let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2803        let filters = built.root.children[0].css.filter.as_ref().unwrap();
2804        let FilterFn::DropShadow { color, .. } = filters
2805            .iter()
2806            .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2807            .unwrap()
2808        else {
2809            unreachable!()
2810        };
2811        let Some(Color::Rgba { a, .. }) = color else {
2812            panic!("expected Rgba color");
2813        };
2814        assert!(
2815            (*a - 0.5).abs() < 1e-4,
2816            "intensity 0.5 on an opaque colour must scale alpha to ~0.5, got {}",
2817            a
2818        );
2819    }
2820
2821    #[test]
2822    fn no_glow_effect_leaves_filter_untouched() {
2823        let shape = make_shape(100.0, 100.0);
2824        let anim = BuildAnimationCtx {
2825            time: 0.0,
2826            scenario_time: 0.0,
2827            scene_duration: 1.0,
2828            fps: 30,
2829        };
2830        let scene = [shape];
2831        let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2832        assert!(built.root.children[0].css.filter.is_none());
2833    }
2834
2835    // ─── #126 / W3: the 23 components with no size source ───────────────────
2836
2837    /// Build a `ChildComponent` from a JSON literal (matches the `type`-tagged
2838    /// `Component` enum's own `Deserialize` impl) — much less error-prone than
2839    /// a full struct literal for components with a dozen+ fields.
2840    fn child_from_json(json: serde_json::Value) -> ChildComponent {
2841        let component: Component =
2842            serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{e}\n{json:#}"));
2843        ChildComponent {
2844            component,
2845            position: None,
2846            x: None,
2847            y: None,
2848            z_index: None,
2849            bleed: false,
2850        }
2851    }
2852
2853    /// Lay out a single unsized child inside a card and return its final
2854    /// `(width, height)`. The card itself has no explicit width or height
2855    /// either, so this also exercises acceptance criterion #3 ("a card with
2856    /// height: auto sizes to the component instead of collapsing to
2857    /// padding").
2858    fn layout_in_auto_card(child_json: serde_json::Value) -> (f32, f32) {
2859        let card = make_card(vec![child_from_json(child_json)], CssStyle::default());
2860        let scene = vec![ChildComponent {
2861            component: card,
2862            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2863            x: None,
2864            y: None,
2865            z_index: None,
2866            bleed: false,
2867        }];
2868        let built = build_scene(&scene, (1920.0, 1080.0));
2869        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2870        let child_id = built.root.children[0].children[0].id;
2871        let l = layout.get(child_id).expect("child laid out");
2872        (l.width, l.height)
2873    }
2874
2875    /// Every one of the 23 components #126 lists gets a positive width *and*
2876    /// height with no `style` at all — before this file's fix they all laid
2877    /// out at 0×0 inside a card (proven by rendering: see the workstream's
2878    /// scratch `ink-measure` harness, which shows every one of these going
2879    /// from 0 painted pixels to a positive count under the identical fixture).
2880    /// A positive layout box is the necessary precondition for any of that
2881    /// painted ink — this test is the fast, render-free regression guard.
2882    #[test]
2883    fn all_23_unsized_components_get_a_positive_box_in_a_card() {
2884        let cases: &[(&str, serde_json::Value)] = &[
2885            ("callout", json!({"type":"callout","text":"Hello"})),
2886            (
2887                "chart",
2888                json!({"type":"chart","chart_type":"bar","data":[{"value":10}]}),
2889            ),
2890            ("comparison", json!({"type":"comparison"})),
2891            (
2892                "dot_map",
2893                json!({"type":"dot_map","points":[{"lat":10.0,"lng":10.0}]}),
2894            ),
2895            ("gauge", json!({"type":"gauge","value":50})),
2896            ("gif", json!({"type":"gif","src":"a.gif"})),
2897            ("heatmap", json!({"type":"heatmap","data":[[1.0,2.0]]})),
2898            ("icon", json!({"type":"icon","icon":"lucide:home"})),
2899            ("image", json!({"type":"image","src":"a.png"})),
2900            ("lottie", json!({"type":"lottie","data":"{}"})),
2901            ("marquee", json!({"type":"marquee","content":"hi"})),
2902            (
2903                "mockup",
2904                json!({"type":"mockup","device":"iphone","src":"a.png"}),
2905            ),
2906            ("pill_nav", json!({"type":"pill_nav","items":["A","B"]})),
2907            ("shape", json!({"type":"shape","shape":"rect"})),
2908            ("skeleton", json!({"type":"skeleton"})),
2909            (
2910                "skeleton_text",
2911                json!({"type":"skeleton","variant":"text","lines":3}),
2912            ),
2913            (
2914                "sparkline",
2915                json!({"type":"sparkline","data":[1.0,2.0,3.0]}),
2916            ),
2917            ("stat", json!({"type":"stat","value":"42"})),
2918            (
2919                "stepper",
2920                json!({"type":"stepper","steps":[{"label":"A"},{"label":"B"}]}),
2921            ),
2922            ("svg", json!({"type":"svg","data":"<svg></svg>"})),
2923            (
2924                "tag_cloud",
2925                json!({"type":"tag_cloud","tags":[{"text":"rust","weight":1.0}]}),
2926            ),
2927            ("tooltip", json!({"type":"tooltip","text":"hi"})),
2928            ("treemap", json!({"type":"treemap","data":[{"value":10.0}]})),
2929            ("video", json!({"type":"video","src":"a.mp4"})),
2930        ];
2931        for (name, json) in cases {
2932            let (w, h) = layout_in_auto_card(json.clone());
2933            assert!(w > 0.0, "{name}: width should be > 0, got {w}");
2934            assert!(h > 0.0, "{name}: height should be > 0, got {h}");
2935        }
2936    }
2937
2938    #[test]
2939    fn heatmap_intrinsic_size_matches_cell_grid_formula() {
2940        // 2 rows x 3 cols, default cell_size=14, cell_gap=3.
2941        // width = (3-1)*(14+3) + 14 = 48, height = (2-1)*17 + 14 = 31.
2942        let (w, h) =
2943            layout_in_auto_card(json!({"type":"heatmap","data":[[1.0,2.0,3.0],[4.0,5.0,6.0]]}));
2944        assert_eq!(w, 48.0);
2945        assert_eq!(h, 31.0);
2946    }
2947
2948    #[test]
2949    fn sparkline_gets_the_documented_120x40_default() {
2950        // .claude/skills/rustmotion/rules/data-viz-components.md: "Sparkline:
2951        // ... compact (120x40 default), inline use."
2952        let (w, h) = layout_in_auto_card(json!({"type":"sparkline","data":[1.0,2.0,3.0]}));
2953        assert_eq!(w, 120.0);
2954        assert_eq!(h, 40.0);
2955    }
2956
2957    #[test]
2958    fn stat_gets_the_documented_280x180_default() {
2959        // .claude/skills/rustmotion/rules/stat-cards.md's own "GOOD" example.
2960        let (w, h) = layout_in_auto_card(json!({"type":"stat","value":"42"}));
2961        assert_eq!(w, 280.0);
2962        assert_eq!(h, 180.0);
2963    }
2964
2965    #[test]
2966    fn gauge_default_size_is_square() {
2967        let (w, h) = layout_in_auto_card(json!({"type":"gauge","value":50}));
2968        assert_eq!(w, h);
2969        assert!(w > 0.0);
2970    }
2971
2972    #[test]
2973    fn pill_nav_height_promotes_its_own_declared_field() {
2974        // `height` is already a field on PillNav (default 44.0) — the fix
2975        // just promotes it to CSS, like Progress/Switch/Slider above.
2976        let (_, h) = layout_in_auto_card(json!({"type":"pill_nav","items":["Overview"]}));
2977        assert_eq!(h, 44.0);
2978    }
2979
2980    #[test]
2981    fn callout_width_grows_with_its_own_text_content() {
2982        // A longer text must produce a wider box (content-derived, not a
2983        // fixed constant) — proves the measured-text path is actually wired
2984        // up, not just a padding-only fallback.
2985        let (short_w, _) = layout_in_auto_card(json!({"type":"callout","text":"Hi"}));
2986        let (long_w, _) = layout_in_auto_card(
2987            json!({"type":"callout","text":"This is a much longer callout message"}),
2988        );
2989        assert!(
2990            long_w > short_w,
2991            "longer text should measure a wider box: short={short_w}, long={long_w}"
2992        );
2993    }
2994
2995    #[test]
2996    fn three_stats_in_a_flex_row_all_get_a_positive_width() {
2997        // #126's second bug: 3 `stat`s in a flex-row card with no explicit
2998        // size rendered zero pixels because *width* (not height) collapsed
2999        // to 0 — align-items:stretch only helps the cross axis, which is
3000        // height in a row.
3001        let stats = vec![
3002            child_from_json(json!({"type":"stat","value":"45.2K","label":"Users"})),
3003            child_from_json(json!({"type":"stat","value":"12%","label":"Growth"})),
3004            child_from_json(json!({"type":"stat","value":"$8.1M","label":"Revenue"})),
3005        ];
3006        let card = make_card(
3007            stats,
3008            CssStyle {
3009                display: Some(Display::Flex),
3010                flex_direction: Some(FlexDirection::Row),
3011                gap: Some(Gap::Uniform(LengthPercentage::Px(16.0))),
3012                ..Default::default()
3013            },
3014        );
3015        let scene = vec![ChildComponent {
3016            component: card,
3017            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3018            x: None,
3019            y: None,
3020            z_index: None,
3021            bleed: false,
3022        }];
3023        let built = build_scene(&scene, (1920.0, 1080.0));
3024        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3025        for (i, child) in built.root.children[0].children.iter().enumerate() {
3026            let l = layout.get(child.id).expect("stat laid out");
3027            assert!(
3028                l.width > 0.0,
3029                "stat #{i}: width should be > 0, got {}",
3030                l.width
3031            );
3032            assert!(
3033                l.height > 0.0,
3034                "stat #{i}: height should be > 0, got {}",
3035                l.height
3036            );
3037        }
3038    }
3039
3040    // ── Round 4 audit, lot LAYOUT, constat 2: the CSS cascade is wired ──────
3041    // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but
3042    // nothing called it — `color`/`font-*` set on a container never reached
3043    // children lacking their own value.
3044
3045    #[test]
3046    fn card_color_cascades_to_text_child_with_no_color_of_its_own() {
3047        use rustmotion_core::css::style::Color;
3048
3049        let card = make_card(
3050            vec![make_text("hello", CssStyle::default())],
3051            CssStyle {
3052                color: Some(Color::String("#ff0000".into())),
3053                ..Default::default()
3054            },
3055        );
3056        let scene = vec![ChildComponent {
3057            component: card,
3058            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3059            x: None,
3060            y: None,
3061            z_index: None,
3062            bleed: false,
3063        }];
3064        let built = build_scene(&scene, (800.0, 600.0));
3065        let text_box = &built.root.children[0].children[0];
3066        assert_eq!(
3067            text_box.css.color,
3068            Some(Color::String("#ff0000".into())),
3069            "text child declares no color of its own — it should inherit the card's"
3070        );
3071    }
3072
3073    #[test]
3074    fn text_own_color_wins_over_inherited_card_color() {
3075        use rustmotion_core::css::style::Color;
3076
3077        let card = make_card(
3078            vec![make_text(
3079                "hello",
3080                CssStyle {
3081                    color: Some(Color::String("#00ff00".into())),
3082                    ..Default::default()
3083                },
3084            )],
3085            CssStyle {
3086                color: Some(Color::String("#ff0000".into())),
3087                ..Default::default()
3088            },
3089        );
3090        let scene = vec![ChildComponent {
3091            component: card,
3092            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3093            x: None,
3094            y: None,
3095            z_index: None,
3096            bleed: false,
3097        }];
3098        let built = build_scene(&scene, (800.0, 600.0));
3099        let text_box = &built.root.children[0].children[0];
3100        assert_eq!(
3101            text_box.css.color,
3102            Some(Color::String("#00ff00".into())),
3103            "text child's own explicit color must win over the inherited card color"
3104        );
3105    }
3106
3107    #[test]
3108    fn card_display_does_not_cascade_to_text_child() {
3109        // `display` is not an inheritable CSS property — only the documented
3110        // inheritable list (color, font-*, text-align, white-space, ...)
3111        // should propagate.
3112        let card = make_card(
3113            vec![make_text("hello", CssStyle::default())],
3114            CssStyle {
3115                display: Some(Display::Flex),
3116                ..Default::default()
3117            },
3118        );
3119        let scene = vec![ChildComponent {
3120            component: card,
3121            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3122            x: None,
3123            y: None,
3124            z_index: None,
3125            bleed: false,
3126        }];
3127        let built = build_scene(&scene, (800.0, 600.0));
3128        let text_box = &built.root.children[0].children[0];
3129        assert_eq!(text_box.css.display, None);
3130    }
3131
3132    // ── Round 4 audit, lot LAYOUT, constat 4: `apply_intrinsic_overrides`'s
3133    // default size ignored an explicit `aspect-ratio`. ─────────────────────
3134
3135    fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent {
3136        ChildComponent {
3137            component: Component::Shape(crate::shape::Shape {
3138                shape: rustmotion_core::schema::ShapeType::Rect,
3139                text: None,
3140                timing: Default::default(),
3141                style: CssStyle {
3142                    width: Some(CSize::Length(CLP::Px(width))),
3143                    aspect_ratio: Some(aspect_ratio),
3144                    ..Default::default()
3145                },
3146                timeline: Vec::new(),
3147                stagger: None,
3148                fill: None,
3149                stroke: None,
3150            }),
3151            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3152            x: None,
3153            y: None,
3154            z_index: None,
3155            bleed: false,
3156        }
3157    }
3158
3159    #[test]
3160    fn explicit_width_with_aspect_ratio_derives_height_instead_of_the_hardcoded_default() {
3161        // `shape`'s hardcoded default is 80×80 (see `apply_intrinsic_overrides`).
3162        // `width: 400` + `aspect-ratio: 16/9` should derive height = 225, not
3163        // fall back to the unrelated 80px default.
3164        let scene = vec![make_aspect_shape(400.0, 16.0 / 9.0)];
3165        let built = build_scene(&scene, (1920.0, 1080.0));
3166        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3167        let l = layout
3168            .get(built.root.children[0].id)
3169            .expect("shape laid out");
3170        assert!(
3171            (l.width - 400.0).abs() < 1.0,
3172            "width should stay the author's explicit 400, got {}",
3173            l.width
3174        );
3175        assert!(
3176            (l.height - 225.0).abs() < 1.0,
3177            "height should derive from width/aspect-ratio (400/1.778=225), got {}",
3178            l.height
3179        );
3180    }
3181
3182    #[test]
3183    fn neither_axis_set_with_aspect_ratio_derives_height_from_the_default_width() {
3184        // No width/height at all: the natural default width (80 for shape)
3185        // is kept, but height should come from the aspect-ratio, not the
3186        // unrelated 80px default.
3187        let scene = vec![make_aspect_shape_no_width(2.0)];
3188        let built = build_scene(&scene, (1920.0, 1080.0));
3189        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3190        let l = layout
3191            .get(built.root.children[0].id)
3192            .expect("shape laid out");
3193        assert!(
3194            (l.width - 80.0).abs() < 1.0,
3195            "width should keep the natural default (80), got {}",
3196            l.width
3197        );
3198        assert!(
3199            (l.height - 40.0).abs() < 1.0,
3200            "height should derive from the default width/aspect-ratio (80/2=40), got {}",
3201            l.height
3202        );
3203    }
3204
3205    fn make_aspect_shape_no_width(aspect_ratio: f32) -> ChildComponent {
3206        ChildComponent {
3207            component: Component::Shape(crate::shape::Shape {
3208                shape: rustmotion_core::schema::ShapeType::Rect,
3209                text: None,
3210                timing: Default::default(),
3211                style: CssStyle {
3212                    aspect_ratio: Some(aspect_ratio),
3213                    ..Default::default()
3214                },
3215                timeline: Vec::new(),
3216                stagger: None,
3217                fill: None,
3218                stroke: None,
3219            }),
3220            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3221            x: None,
3222            y: None,
3223            z_index: None,
3224            bleed: false,
3225        }
3226    }
3227}