Skip to main content

aetna_core/
metrics.rs

1//! Component sizing vocabulary.
2//!
3//! Stock controls (button / input / badge / tab / choice / slider /
4//! progress) carry a t-shirt `size` that maps 1:1 to shadcn's `size`
5//! prop. Container surfaces (card / form / list / menu / table / panel)
6//! bake their padding / gap / height / radius recipes directly in their
7//! constructors — there is no global density knob, the way Tailwind /
8//! shadcn picks padding per component class.
9
10use crate::tree::{El, Sides, Size};
11
12/// T-shirt size for stock controls.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
14#[non_exhaustive]
15pub enum ComponentSize {
16    Xs,
17    Sm,
18    #[default]
19    Md,
20    Lg,
21}
22
23/// Theme-facing stock metrics role for a widget surface.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
25#[non_exhaustive]
26pub enum MetricsRole {
27    Button,
28    IconButton,
29    Input,
30    TextArea,
31    Badge,
32    Card,
33    CardHeader,
34    CardContent,
35    CardFooter,
36    Form,
37    FormItem,
38    Panel,
39    MenuItem,
40    ListItem,
41    PreferenceRow,
42    TableHeader,
43    TableRow,
44    TabTrigger,
45    TabList,
46    ChoiceControl,
47    ChoiceItem,
48    Slider,
49    Progress,
50}
51
52/// Theme-owned layout metrics for stock widgets.
53#[derive(Clone, Debug)]
54pub struct ThemeMetrics {
55    default_component_size: ComponentSize,
56    button_size: Option<ComponentSize>,
57    input_size: Option<ComponentSize>,
58    badge_size: Option<ComponentSize>,
59    tab_size: Option<ComponentSize>,
60    choice_size: Option<ComponentSize>,
61    slider_size: Option<ComponentSize>,
62    progress_size: Option<ComponentSize>,
63}
64
65impl ThemeMetrics {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    pub fn default_component_size(&self) -> ComponentSize {
71        self.default_component_size
72    }
73
74    pub fn with_default_component_size(mut self, size: ComponentSize) -> Self {
75        self.default_component_size = size;
76        self
77    }
78
79    pub fn with_button_size(mut self, size: ComponentSize) -> Self {
80        self.button_size = Some(size);
81        self
82    }
83
84    pub fn with_input_size(mut self, size: ComponentSize) -> Self {
85        self.input_size = Some(size);
86        self
87    }
88
89    pub fn with_badge_size(mut self, size: ComponentSize) -> Self {
90        self.badge_size = Some(size);
91        self
92    }
93
94    pub fn with_tab_size(mut self, size: ComponentSize) -> Self {
95        self.tab_size = Some(size);
96        self
97    }
98
99    pub fn with_choice_size(mut self, size: ComponentSize) -> Self {
100        self.choice_size = Some(size);
101        self
102    }
103
104    pub fn with_slider_size(mut self, size: ComponentSize) -> Self {
105        self.slider_size = Some(size);
106        self
107    }
108
109    pub fn with_progress_size(mut self, size: ComponentSize) -> Self {
110        self.progress_size = Some(size);
111        self
112    }
113
114    pub(crate) fn apply_to_tree(&self, root: &mut El) {
115        self.apply_to_el(root);
116        for child in &mut root.children {
117            self.apply_to_tree(child);
118        }
119    }
120
121    fn apply_to_el(&self, el: &mut El) {
122        match el.metrics_role {
123            Some(MetricsRole::Button) => {
124                let size = el
125                    .component_size
126                    .or(self.button_size)
127                    .unwrap_or(self.default_component_size);
128                apply_control(el, control_metrics(size, ControlKind::Button));
129            }
130            Some(MetricsRole::IconButton) => {
131                let size = el
132                    .component_size
133                    .or(self.button_size)
134                    .unwrap_or(self.default_component_size);
135                apply_control(el, control_metrics(size, ControlKind::IconButton));
136            }
137            Some(MetricsRole::Input) => {
138                let size = el
139                    .component_size
140                    .or(self.input_size)
141                    .unwrap_or(self.default_component_size);
142                apply_control(el, control_metrics(size, ControlKind::Input));
143            }
144            Some(MetricsRole::TextArea) => {
145                // TextArea bakes its padding + radius recipe directly
146                // in the constructor (`widgets/text_area.rs`). The
147                // metrics pass leaves it alone.
148            }
149            Some(MetricsRole::Badge) => {
150                let size = el
151                    .component_size
152                    .or(self.badge_size)
153                    .unwrap_or(self.default_component_size);
154                apply_badge(el, badge_metrics(size));
155            }
156            Some(MetricsRole::Card) => {
157                // Card surfaces do not participate in the metrics-driven
158                // density override. Padding, gap, and radius are baked
159                // into the constructors in `widgets/card.rs` (shadcn's
160                // stock recipe). Override per-call with `.padding(...)`,
161                // `.pt(...)` / `.px(...)` / etc.
162                //
163                // What the metrics pass *does* do for a card: propagate
164                // the card's top corner radii onto a leading
165                // `card_header` child's *fill* (and symmetric for a
166                // trailing `card_footer`). Without this, a
167                // `card_header([...]).fill(MUTED)` strip paints sharp
168                // top corners that poke past the card's rounded curve;
169                // see `propagate_card_corner_radii`.
170                propagate_card_corner_radii(el);
171            }
172            Some(MetricsRole::CardHeader | MetricsRole::CardContent | MetricsRole::CardFooter) => {
173                // See above: padding / gap / radius baked into the
174                // constructors. Corner-radii inheritance is stamped by
175                // the parent `Card` branch above.
176            }
177            Some(
178                MetricsRole::Form
179                | MetricsRole::FormItem
180                | MetricsRole::Panel
181                | MetricsRole::MenuItem
182                | MetricsRole::ListItem
183                | MetricsRole::PreferenceRow
184                | MetricsRole::TableHeader
185                | MetricsRole::TableRow,
186            ) => {
187                // These surfaces bake their padding / gap / height /
188                // radius recipe directly in their constructors (see
189                // `widgets/{form,alert,dialog,sheet,overlay,popover,
190                // dropdown_menu,accordion,sidebar,command,table}.rs`).
191                // The metrics pass does not touch them. Override per
192                // call with `.padding(...)` / `.height(...)` / etc.
193            }
194            Some(MetricsRole::TabTrigger) => {
195                let size = el
196                    .component_size
197                    .or(self.tab_size)
198                    .unwrap_or(self.default_component_size);
199                apply_control(el, control_metrics(size, ControlKind::Button));
200            }
201            Some(MetricsRole::TabList) => {
202                // Padding, gap, and radius are baked into
203                // `tabs_list()`. The metrics pass only propagates the
204                // optional `ComponentSize` down to TabTrigger children.
205                if let Some(size) = el.component_size {
206                    apply_tab_trigger_size_to_children(el, size);
207                }
208            }
209            Some(MetricsRole::ChoiceControl) => {
210                let size = el
211                    .component_size
212                    .or(self.choice_size)
213                    .unwrap_or(self.default_component_size);
214                apply_choice_control(el, choice_control_metrics(size));
215            }
216            Some(MetricsRole::ChoiceItem) => {
217                // Padding, gap, and radius are baked into `radio_item()`.
218                // The metrics pass only propagates `ComponentSize` down
219                // to the ChoiceControl child.
220                if let Some(size) = el.component_size {
221                    apply_choice_control_size_to_children(el, size);
222                }
223            }
224            Some(MetricsRole::Slider) => {
225                let size = el
226                    .component_size
227                    .or(self.slider_size)
228                    .unwrap_or(self.default_component_size);
229                apply_single_axis_height(el, slider_metrics(size));
230            }
231            Some(MetricsRole::Progress) => {
232                let size = el
233                    .component_size
234                    .or(self.progress_size)
235                    .unwrap_or(self.default_component_size);
236                apply_single_axis_height(el, progress_metrics(size));
237            }
238            None => {}
239        }
240    }
241}
242
243impl Default for ThemeMetrics {
244    fn default() -> Self {
245        Self {
246            // Aetna's baseline component size is `Sm` so desktop apps
247            // land in a denser-than-web baseline. Bump everything one
248            // rung with `Theme::with_default_component_size(Md)`, or
249            // override per-call with `.size(...)` / `.medium()` /
250            // `.large()`.
251            default_component_size: ComponentSize::Sm,
252            button_size: None,
253            input_size: None,
254            badge_size: None,
255            tab_size: None,
256            choice_size: None,
257            slider_size: None,
258            progress_size: None,
259        }
260    }
261}
262
263#[derive(Clone, Copy)]
264enum ControlKind {
265    Button,
266    IconButton,
267    Input,
268}
269
270#[derive(Clone, Copy)]
271struct ControlMetrics {
272    height: f32,
273    padding_x: f32,
274    radius: f32,
275    gap: f32,
276}
277
278fn control_metrics(size: ComponentSize, kind: ControlKind) -> ControlMetrics {
279    let (mut height, padding_x, radius, gap): (f32, f32, f32, f32) = match size {
280        ComponentSize::Xs => (28.0, 8.0, 5.0, 4.0),
281        ComponentSize::Sm => (32.0, 10.0, 6.0, 6.0),
282        ComponentSize::Md => (36.0, 12.0, 7.0, 8.0),
283        ComponentSize::Lg => (40.0, 14.0, 8.0, 8.0),
284    };
285    if matches!(kind, ControlKind::Input) && matches!(size, ComponentSize::Lg) {
286        height = 44.0;
287    }
288    match kind {
289        ControlKind::IconButton => ControlMetrics {
290            height,
291            padding_x: 0.0,
292            radius,
293            gap,
294        },
295        ControlKind::Input => ControlMetrics {
296            height,
297            padding_x: padding_x.max(10.0),
298            radius,
299            gap,
300        },
301        ControlKind::Button => ControlMetrics {
302            height,
303            padding_x,
304            radius,
305            gap,
306        },
307    }
308}
309
310fn apply_control(el: &mut El, metrics: ControlMetrics) {
311    if !el.explicit_height {
312        el.height = Size::Fixed(metrics.height);
313    }
314    if matches!(el.metrics_role, Some(MetricsRole::IconButton)) && !el.explicit_width {
315        el.width = Size::Fixed(metrics.height);
316    }
317    if !el.explicit_padding && !matches!(el.metrics_role, Some(MetricsRole::IconButton)) {
318        el.padding = Sides::xy(metrics.padding_x, 0.0);
319    }
320    if !el.explicit_radius {
321        el.radius = crate::tree::Corners::all(metrics.radius);
322    }
323    if !el.explicit_gap {
324        el.gap = metrics.gap;
325    }
326}
327
328#[derive(Clone, Copy)]
329struct BadgeMetrics {
330    height: f32,
331    padding_x: f32,
332}
333
334fn badge_metrics(size: ComponentSize) -> BadgeMetrics {
335    match size {
336        ComponentSize::Xs => BadgeMetrics {
337            height: 18.0,
338            padding_x: 6.0,
339        },
340        ComponentSize::Sm => BadgeMetrics {
341            height: 20.0,
342            padding_x: 7.0,
343        },
344        ComponentSize::Md => BadgeMetrics {
345            height: 24.0,
346            padding_x: 8.0,
347        },
348        ComponentSize::Lg => BadgeMetrics {
349            height: 28.0,
350            padding_x: 10.0,
351        },
352    }
353}
354
355fn apply_badge(el: &mut El, metrics: BadgeMetrics) {
356    if !el.explicit_height {
357        el.height = Size::Fixed(metrics.height);
358    }
359    if !el.explicit_padding {
360        el.padding = Sides::xy(metrics.padding_x, 0.0);
361    }
362}
363
364fn apply_tab_trigger_size_to_children(el: &mut El, size: ComponentSize) {
365    for child in &mut el.children {
366        if matches!(child.metrics_role, Some(MetricsRole::TabTrigger))
367            && child.component_size.is_none()
368        {
369            child.component_size = Some(size);
370        }
371    }
372}
373
374#[derive(Clone, Copy)]
375struct ChoiceControlMetrics {
376    edge: f32,
377}
378
379fn choice_control_metrics(size: ComponentSize) -> ChoiceControlMetrics {
380    let edge = match size {
381        ComponentSize::Xs => 14.0,
382        ComponentSize::Sm => 16.0,
383        ComponentSize::Md => 16.0,
384        ComponentSize::Lg => 18.0,
385    };
386    ChoiceControlMetrics { edge }
387}
388
389fn apply_choice_control(el: &mut El, metrics: ChoiceControlMetrics) {
390    if !el.explicit_width {
391        el.width = Size::Fixed(metrics.edge);
392    }
393    if !el.explicit_height {
394        el.height = Size::Fixed(metrics.edge);
395    }
396}
397
398fn apply_choice_control_size_to_children(el: &mut El, size: ComponentSize) {
399    for child in &mut el.children {
400        if matches!(child.metrics_role, Some(MetricsRole::ChoiceControl))
401            && child.component_size.is_none()
402        {
403            child.component_size = Some(size);
404        }
405    }
406}
407
408fn slider_metrics(size: ComponentSize) -> f32 {
409    match size {
410        ComponentSize::Xs => 14.0,
411        ComponentSize::Sm => 16.0,
412        ComponentSize::Md => 18.0,
413        ComponentSize::Lg => 22.0,
414    }
415}
416
417fn progress_metrics(size: ComponentSize) -> f32 {
418    match size {
419        ComponentSize::Xs => 4.0,
420        ComponentSize::Sm => 6.0,
421        ComponentSize::Md => 8.0,
422        ComponentSize::Lg => 10.0,
423    }
424}
425
426fn apply_single_axis_height(el: &mut El, height: f32) {
427    if !el.explicit_height {
428        el.height = Size::Fixed(height);
429    }
430}
431
432/// Propagate the parent card's top/bottom corner radii onto a leading
433/// `card_header` / trailing `card_footer` child whose `.fill(...)` would
434/// otherwise paint sharp corners over the card's rounded curve.
435///
436/// Triggers only when:
437/// - The card has a non-zero corner radius (the only case the strip
438///   pokes through).
439/// - The card has zero padding on the corresponding edge (the slot's
440///   own padding is inside it; the slot's outer rect is the card's
441///   inner rect). If the card has top padding, the header's top is
442///   inset from the card edge — no leak, no inheritance.
443/// - The slot has `.fill(...)` set. A no-fill `card_header` doesn't
444///   draw anything in the corner band, so corner inheritance would be
445///   invisible (and could surprise authors who later add stroke).
446/// - The slot has no explicit `.radius(...)` — author overrides win.
447///
448/// Top inherits from `card.radius.tl` / `tr`; bottom from `bl` / `br`.
449/// The matching opposite corners are zeroed so the strip's interior
450/// edge stays straight against the body slot.
451fn propagate_card_corner_radii(card: &mut El) {
452    if !card.radius.any_nonzero() || card.children.is_empty() {
453        return;
454    }
455    let card_radius = card.radius;
456    let pad_top = card.padding.top;
457    let pad_bottom = card.padding.bottom;
458    let last_idx = card.children.len() - 1;
459    for (idx, child) in card.children.iter_mut().enumerate() {
460        if child.fill.is_none() || child.explicit_radius {
461            continue;
462        }
463        match child.metrics_role {
464            Some(MetricsRole::CardHeader) if idx == 0 && pad_top == 0.0 => {
465                child.radius = crate::tree::Corners {
466                    tl: card_radius.tl,
467                    tr: card_radius.tr,
468                    br: 0.0,
469                    bl: 0.0,
470                };
471            }
472            Some(MetricsRole::CardFooter) if idx == last_idx && pad_bottom == 0.0 => {
473                child.radius = crate::tree::Corners {
474                    tl: 0.0,
475                    tr: 0.0,
476                    br: card_radius.br,
477                    bl: card_radius.bl,
478                };
479            }
480            _ => {}
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::{button, tabs_list, text_input, titled_card, tokens};
489
490    #[test]
491    fn theme_default_component_size_applies_to_stock_control() {
492        let mut el = button("Save");
493
494        ThemeMetrics::default()
495            .with_default_component_size(ComponentSize::Lg)
496            .apply_to_tree(&mut el);
497
498        assert_eq!(el.height, Size::Fixed(40.0));
499    }
500
501    #[test]
502    fn local_component_size_overrides_theme_default() {
503        let mut el = button("Save").large();
504
505        ThemeMetrics::default()
506            .with_default_component_size(ComponentSize::Xs)
507            .apply_to_tree(&mut el);
508
509        assert_eq!(el.height, Size::Fixed(40.0));
510    }
511
512    #[test]
513    fn input_uses_spacious_field_height_at_large_size() {
514        let mut el = text_input("Search", &crate::Selection::default(), "search").large();
515
516        ThemeMetrics::default().apply_to_tree(&mut el);
517
518        assert_eq!(el.height, Size::Fixed(44.0));
519    }
520
521    #[test]
522    fn explicit_height_overrides_component_metrics() {
523        let mut el = button("Save").height(Size::Fixed(44.0));
524
525        ThemeMetrics::default()
526            .with_default_component_size(ComponentSize::Sm)
527            .apply_to_tree(&mut el);
528
529        assert_eq!(el.height, Size::Fixed(44.0));
530    }
531
532    #[test]
533    fn card_slot_defaults_match_shadcn_stock() {
534        // card_header / card_content / card_footer bake shadcn's `p-6`
535        // / `p-6 pt-0` recipe directly via `default_padding(...)` in
536        // the constructor. The metrics pass leaves those slots alone.
537        let mut t = titled_card("Settings", [crate::text("Body")]);
538        ThemeMetrics::default().apply_to_tree(&mut t);
539
540        // Outer card is unpadded; the slots own all the spacing.
541        assert_eq!(t.padding, Sides::zero());
542        // Header: SPACE_6 on all four sides.
543        assert_eq!(t.children[0].padding, Sides::all(tokens::SPACE_6));
544        // Content: SPACE_6 on left / right / bottom, 0 on top (`p-6 pt-0`).
545        assert_eq!(
546            t.children[1].padding,
547            Sides {
548                left: tokens::SPACE_6,
549                right: tokens::SPACE_6,
550                top: 0.0,
551                bottom: tokens::SPACE_6,
552            }
553        );
554    }
555
556    #[test]
557    fn card_header_with_fill_inherits_card_top_corner_radii() {
558        use crate::tree::Corners;
559        use crate::{card, card_content, card_header, text};
560        // The canonical "tinted strip" recipe blessed by the
561        // `card_header` doc comment. Without inheritance the strip
562        // paints sharp top corners that poke past the card's curve.
563        let mut tree = card([
564            card_header([text("Header")]).fill(tokens::MUTED),
565            card_content([text("Body")]),
566        ]);
567        ThemeMetrics::default().apply_to_tree(&mut tree);
568
569        assert_eq!(
570            tree.children[0].radius,
571            Corners {
572                tl: tokens::RADIUS_LG,
573                tr: tokens::RADIUS_LG,
574                br: 0.0,
575                bl: 0.0,
576            },
577            "header strip should adopt the card's top corner radii"
578        );
579        // Body slot has no fill → no inheritance, no surprise.
580        assert_eq!(tree.children[1].radius, Corners::ZERO);
581    }
582
583    #[test]
584    fn card_footer_with_fill_inherits_card_bottom_corner_radii() {
585        use crate::tree::Corners;
586        use crate::{card, card_content, card_footer, text};
587        let mut tree = card([
588            card_content([text("Body")]),
589            card_footer([text("Footer")]).fill(tokens::MUTED),
590        ]);
591        ThemeMetrics::default().apply_to_tree(&mut tree);
592
593        let footer = tree.children.last().expect("footer slot");
594        assert_eq!(
595            footer.radius,
596            Corners {
597                tl: 0.0,
598                tr: 0.0,
599                br: tokens::RADIUS_LG,
600                bl: tokens::RADIUS_LG,
601            }
602        );
603    }
604
605    #[test]
606    fn card_header_explicit_radius_wins_over_inheritance() {
607        use crate::tree::Corners;
608        use crate::{card, card_content, card_header, text};
609        let mut tree = card([
610            card_header([text("Header")])
611                .fill(tokens::MUTED)
612                .radius(Corners::ZERO),
613            card_content([text("Body")]),
614        ]);
615        ThemeMetrics::default().apply_to_tree(&mut tree);
616
617        assert_eq!(
618            tree.children[0].radius,
619            Corners::ZERO,
620            "author override must win over auto-inheritance"
621        );
622    }
623
624    #[test]
625    fn card_header_without_fill_does_not_inherit() {
626        use crate::tree::Corners;
627        use crate::{card, card_content, card_header, text};
628        let mut tree = card([card_header([text("Header")]), card_content([text("Body")])]);
629        ThemeMetrics::default().apply_to_tree(&mut tree);
630        assert_eq!(
631            tree.children[0].radius,
632            Corners::ZERO,
633            "no fill means no corner stackup to fix"
634        );
635    }
636
637    #[test]
638    fn card_with_top_padding_skips_header_inheritance() {
639        use crate::tree::Corners;
640        use crate::{card, card_content, card_header, text};
641        // Explicit padding on the card insets the header from the
642        // card's edge, so there's no corner stackup to inherit away.
643        let mut tree = card([
644            card_header([text("Header")]).fill(tokens::MUTED),
645            card_content([text("Body")]),
646        ])
647        .padding(tokens::SPACE_2);
648        ThemeMetrics::default().apply_to_tree(&mut tree);
649        assert_eq!(tree.children[0].radius, Corners::ZERO);
650    }
651
652    #[test]
653    fn theme_tab_size_applies_to_tab_triggers() {
654        let mut el = tabs_list("settings", &"account", [("account", "Account")]);
655
656        ThemeMetrics::default()
657            .with_tab_size(ComponentSize::Lg)
658            .apply_to_tree(&mut el);
659
660        assert_eq!(el.children[0].height, Size::Fixed(40.0));
661    }
662
663    #[test]
664    fn local_tab_list_size_applies_to_tab_triggers() {
665        let mut el =
666            tabs_list("settings", &"account", [("account", "Account")]).size(ComponentSize::Lg);
667
668        ThemeMetrics::default().apply_to_tree(&mut el);
669
670        assert_eq!(el.children[0].height, Size::Fixed(40.0));
671    }
672
673    #[test]
674    fn local_choice_item_size_applies_to_child_control() {
675        let control =
676            El::new(crate::Kind::Custom("choice-control")).metrics_role(MetricsRole::ChoiceControl);
677        let mut el = El::new(crate::Kind::Custom("choice"))
678            .metrics_role(MetricsRole::ChoiceItem)
679            .child(control)
680            .size(ComponentSize::Lg);
681
682        ThemeMetrics::default().apply_to_tree(&mut el);
683
684        assert_eq!(el.children[0].width, Size::Fixed(18.0));
685        assert_eq!(el.children[0].height, Size::Fixed(18.0));
686    }
687
688    #[test]
689    fn progress_size_uses_component_scale() {
690        let mut el = El::new(crate::Kind::Custom("progress")).metrics_role(MetricsRole::Progress);
691
692        ThemeMetrics::default()
693            .with_progress_size(ComponentSize::Sm)
694            .apply_to_tree(&mut el);
695
696        assert_eq!(el.height, Size::Fixed(6.0));
697    }
698
699    #[test]
700    fn raw_metrics_role_tags_no_longer_override_widget_defaults() {
701        // After the density removal, surfaces like Form / FormItem /
702        // ListItem / MenuItem / TableRow / PreferenceRow / ChoiceItem /
703        // TextArea / TabList / Panel bake their padding / gap / height /
704        // radius recipes into their constructors. The metrics pass does
705        // not stamp anything onto bare-tagged Els (it only propagates
706        // ComponentSize down to TabTrigger / ChoiceControl children).
707        // This test asserts the absence — a bare El tagged with one of
708        // those roles comes out with zero padding, zero gap, and Hug
709        // height, exactly as if the role was unset.
710        for role in [
711            MetricsRole::Form,
712            MetricsRole::FormItem,
713            MetricsRole::ListItem,
714            MetricsRole::MenuItem,
715            MetricsRole::PreferenceRow,
716            MetricsRole::TableRow,
717            MetricsRole::TableHeader,
718            MetricsRole::ChoiceItem,
719            MetricsRole::TextArea,
720            MetricsRole::TabList,
721            MetricsRole::Panel,
722        ] {
723            let mut el = El::new(crate::Kind::Custom("bare")).metrics_role(role);
724            ThemeMetrics::default().apply_to_tree(&mut el);
725            assert_eq!(el.padding, Sides::zero(), "role {role:?} stamped padding");
726            assert_eq!(el.gap, 0.0, "role {role:?} stamped gap");
727            assert_eq!(el.height, Size::Hug, "role {role:?} stamped height");
728            assert_eq!(
729                el.radius,
730                crate::tree::Corners::ZERO,
731                "role {role:?} stamped radius"
732            );
733        }
734    }
735
736    #[test]
737    fn form_constructor_bakes_default_gap() {
738        // Smoke test for the constructor-baked recipe: form() picks up
739        // SPACE_3 between items, form_item() picks up SPACE_2.
740        let mut f = crate::form([crate::form_item([crate::text("body")])]);
741        ThemeMetrics::default().apply_to_tree(&mut f);
742        assert_eq!(f.gap, tokens::SPACE_3);
743        assert_eq!(f.children[0].gap, tokens::SPACE_2);
744    }
745
746    #[test]
747    fn default_metrics_are_compact_desktop_defaults() {
748        let metrics = ThemeMetrics::default();
749
750        assert_eq!(metrics.default_component_size(), ComponentSize::Sm);
751    }
752}