Skip to main content

aetna_core/bundle/
lint.rs

1//! Lint pass — surfaces the kind of issues an LLM iterating on a UI
2//! benefits from knowing about, with provenance so the report only
3//! flags things the user code can fix.
4//!
5//! Categories:
6//!
7//! - **Raw colors / sizes:** values that aren't tokenized. Often fine
8//!   inside library code but a smell in user code.
9//! - **Overflow:** child rects extending past their parent, or text
10//!   exceeding its container's padded content region (centered text
11//!   that spills past the padding reads as visually off-center, even
12//!   when it nominally fits inside the outer rect).
13//! - **Duplicate IDs:** two nodes with the same computed ID (only
14//!   possible via explicit `.key(...)` collisions; pure path IDs are
15//!   unique by construction).
16//!
17//! Provenance: every finding records the source location of the
18//! offending node (via `#[track_caller]` propagation up to the user's
19//! call site). User code is distinguished from aetna's own widget
20//! internals by [`Source::from_library`], which a closure-builder
21//! site sets explicitly via [`crate::tree::El::from_library`] when
22//! `#[track_caller]` won't reach the user. Findings only attribute to
23//! sources where `from_library == false`.
24//!
25//! Overflow findings (rect and text) walk up to the nearest
26//! user-source ancestor for attribution. `#[track_caller]` doesn't
27//! propagate through closures, so a widget that builds children
28//! inside `.map(...)` either forwards the user's caller via
29//! `.at_loc(caller)` (the prevailing pattern in aetna-core today) or
30//! marks itself with `.from_library()` so the lint walks up to the
31//! user's call site. Either way the user gets a finding pointing at
32//! their code, not at aetna-core internals. Raw-color and surface
33//! lints are still self-attributed — those are intentional inside
34//! widgets and should only fire from user code directly.
35
36use std::fmt::Write as _;
37
38use crate::layout;
39use crate::metrics::MetricsRole;
40use crate::state::UiState;
41use crate::tree::*;
42
43/// A single lint finding.
44#[derive(Clone, Debug)]
45#[non_exhaustive]
46pub struct Finding {
47    pub kind: FindingKind,
48    pub node_id: String,
49    pub source: Source,
50    pub message: String,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum FindingKind {
56    RawColor,
57    Overflow,
58    TextOverflow,
59    DuplicateId,
60    Alignment,
61    Spacing,
62    /// `surface_role(SurfaceRole::Panel)` on a node with no fill — the
63    /// role only paints stroke + shadow, so the surface reads as a
64    /// thin border floating over the parent. Either set a fill
65    /// (`tokens::CARD` is the usual choice) or — more often — swap to a
66    /// widget like `card()` / `sidebar()` that bundles role + fill +
67    /// stroke + radius + shadow correctly. (`Raised` is *also*
68    /// decorative but this lint stays narrow to `Panel` since
69    /// `button(...).ghost()` legitimately produces a Raised node with
70    /// no fill.)
71    MissingSurfaceFill,
72    /// A `column` / `row` / `stack` whose visual recipe matches a stock
73    /// widget (card, sidebar, …). Reach for the named widget instead —
74    /// it bundles the right surface role, radius, shadow, and content
75    /// padding. The structural smells live in the widget catalog README;
76    /// this lint catches the two highest-confidence signatures
77    /// (`fill=CARD + stroke=BORDER + radius>0` ⇒ `card()`,
78    /// `fill=CARD + stroke=BORDER + width=SIDEBAR_WIDTH` without a Panel
79    /// surface role ⇒ `sidebar()`).
80    ReinventedWidget,
81    /// A focusable node's `paint_overflow` band (the focus-ring band)
82    /// would render obscured at runtime — either because the nearest
83    /// clipping ancestor's scissor cuts it, or because a later-painted
84    /// sibling's rect overlaps the bleed region and paints on top.
85    ///
86    /// Common fixes:
87    ///
88    /// - **Clipped:** give the clipping ancestor (or an intermediate
89    ///   container) padding ≥ `tokens::RING_WIDTH` on the clipped
90    ///   side so the band lives inside the scissor.
91    /// - **Occluded:** add gap between the focusable element and the
92    ///   neighbor (≥ `tokens::RING_WIDTH`), or restructure so the
93    ///   neighbor doesn't sit on the focusable element's edge.
94    FocusRingObscured,
95    /// A focusable node sits inside a scrolling ancestor whose
96    /// scrollbar thumb is currently rendered (content overflows), and
97    /// the focusable's rect overlaps the thumb's track on the x-axis
98    /// — so the thumb paints on top of the control whenever the user
99    /// scrolls to it.
100    ///
101    /// The trap is that giving the *scroll itself* horizontal padding
102    /// (the natural reading of `FocusRingObscured`'s message) shifts
103    /// `inner` and the thumb together: padding clears the focus-ring
104    /// scissor, but the thumb still sits in the rightmost
105    /// `SCROLLBAR_THUMB_WIDTH + SCROLLBAR_TRACK_INSET` pixels of the
106    /// children's visible area.
107    ///
108    /// Fix: move horizontal padding *inside* the scroll, onto a
109    /// wrapper that constrains children to a narrower content rect,
110    /// so the thumb sits in a reserved gutter to the right of
111    /// content.
112    ScrollbarObscuresFocusable,
113    /// `.tooltip()` on a node that has no `.key()`. Tooltips fire
114    /// through the hit-test pipeline, and `hit_test` only returns
115    /// keyed nodes — hover skips past unkeyed leaves to the nearest
116    /// keyed ancestor (which has a different `computed_id` and a
117    /// different tooltip lookup), so the tooltip is silently dead.
118    ///
119    /// Fix: add `.key("…")` to the same node that carries the
120    /// tooltip. For info-only chrome inside list rows (sha cells,
121    /// timestamps, chips, identicon avatars) the usual key is a
122    /// synthetic one like `"row:{idx}.<part>"` — its only purpose is
123    /// to make the tooltip's hover land. Moving the `.tooltip()` to
124    /// a keyed ancestor instead conflates "I want a hover popover
125    /// here" with "I'm declaring a click/focus target," and is
126    /// usually not what you want.
127    DeadTooltip,
128    /// A filled child paints into a rounded ancestor's corner-curve
129    /// area without rounding its own matching corner. The child's
130    /// flat-cornered fill obscures the parent's curve and stroke,
131    /// producing the "sharp corner superimposed on a radiused
132    /// container" artifact.
133    ///
134    /// The canonical recipe (`card_header([...]).fill(MUTED)` inside
135    /// `card([...])`) is auto-fixed by the metrics pass — see
136    /// [`crate::metrics`]. This lint catches hand-rolled cases:
137    /// reinvented cards with reinvented headers, custom inspector
138    /// frames, accordion-like containers, etc.
139    ///
140    /// Fix: set the matching corner radii on the child
141    /// (`.radius(Corners::top(N))` for a header strip,
142    /// `Corners::bottom(N)` for a footer), or add padding to the
143    /// parent so the child is inset from the curve.
144    CornerStackup,
145    /// A `surface_role=Panel` node whose direct children sit flush
146    /// against one or more of its outer edges with no padding
147    /// (neither on the panel nor on the touching child) to inset the
148    /// content. The canonical trip is `card([...])` called without
149    /// the `card_header` / `card_content` / `card_footer` slot
150    /// wrappers and without an explicit `.padding(...)`: `card()`
151    /// itself carries no inner padding, so titles paint on the top
152    /// stroke, action buttons paint on the bottom stroke, and chip
153    /// rows pin to the left edge.
154    ///
155    /// The check is per-side. A side is treated as "padded" — and so
156    /// is not flagged — when either the panel itself pads on that
157    /// side, or any child whose rect touches that side carries
158    /// inward padding on that side. So the canonical anatomy
159    /// (`card_header` pads top/left/right, `card_footer` pads
160    /// bottom/left/right, both at `SPACE_6`) stays quiet without
161    /// special-casing.
162    ///
163    /// Fixes:
164    ///
165    /// - Wrap content in the slot anatomy: `card([card_header([...]),
166    ///   card_content([...]), card_footer([...])])` — each slot bakes
167    ///   the shadcn `SPACE_6` padding recipe.
168    /// - For dense list-row cards where the slot padding feels too
169    ///   generous, pad the panel itself:
170    ///   `card([...]).padding(Sides::all(tokens::SPACE_4))`.
171    UnpaddedSurfacePanel,
172}
173
174#[derive(Clone, Debug, Default)]
175#[non_exhaustive]
176pub struct LintReport {
177    pub findings: Vec<Finding>,
178}
179
180impl LintReport {
181    pub fn text(&self) -> String {
182        if self.findings.is_empty() {
183            return "no findings\n".to_string();
184        }
185        let mut s = String::new();
186        for f in &self.findings {
187            let _ = writeln!(
188                s,
189                "{kind:?} node={id} {source} :: {msg}",
190                kind = f.kind,
191                id = f.node_id,
192                source = if f.source.line == 0 {
193                    "<no-source>".to_string()
194                } else {
195                    format!("{}:{}", short_path(f.source.file), f.source.line)
196                },
197                msg = f.message,
198            );
199        }
200        s
201    }
202}
203
204/// Run the lint pass over `root`.
205///
206/// Findings are gated on whether the offending node (or its nearest
207/// ancestor) was constructed in user code rather than inside aetna's
208/// own widget closures. The signal is [`Source::from_library`], set
209/// explicitly via [`crate::tree::El::from_library`] at any closure-
210/// builder site that doesn't forward `Location::caller()` back to the
211/// user. The vast majority of nodes propagate user source through
212/// `#[track_caller]` and pass straight through.
213pub fn lint(root: &El, ui_state: &UiState) -> LintReport {
214    let mut r = LintReport::default();
215    let mut seen_ids: std::collections::BTreeMap<String, usize> = Default::default();
216    walk(
217        root,
218        None,
219        None,
220        &ClipCtx::None,
221        ui_state,
222        &mut r,
223        &mut seen_ids,
224    );
225    for (id, n) in seen_ids {
226        if n > 1 {
227            r.findings.push(Finding {
228                kind: FindingKind::DuplicateId,
229                node_id: id.clone(),
230                source: Source::default(),
231                message: format!("{n} nodes share id {id}"),
232            });
233        }
234    }
235    r
236}
237
238fn is_from_user(source: Source) -> bool {
239    !source.from_library
240}
241
242/// Clipping context propagated through `walk`. Carries the nearest
243/// clipping ancestor's scissor rect and, for scrollable ancestors,
244/// the axis along which content can be scrolled into view (clipping
245/// on that axis is benign — focus rings on partially-clipped rows
246/// become visible after auto-scroll-on-focus). The scrolling variant
247/// also carries the ancestor's `node_id` so descendant checks can
248/// look up its `thumb_tracks` entry to detect scrollbar/control
249/// overlap (`ScrollbarObscuresFocusable`).
250#[derive(Clone)]
251enum ClipCtx {
252    None,
253    /// Non-scrolling clip — the rect cuts on every side.
254    Static(Rect),
255    /// Scrolling clip — the rect cuts on the cross axis only;
256    /// `scroll_axis` records the axis where overflow becomes scroll
257    /// (Column = vertical, Row = horizontal).
258    Scrolling {
259        rect: Rect,
260        scroll_axis: Axis,
261        node_id: String,
262    },
263}
264
265fn walk(
266    n: &El,
267    parent_kind: Option<&Kind>,
268    parent_blame: Option<Source>,
269    nearest_clip: &ClipCtx,
270    ui_state: &UiState,
271    r: &mut LintReport,
272    seen: &mut std::collections::BTreeMap<String, usize>,
273) {
274    *seen.entry(n.computed_id.clone()).or_default() += 1;
275    let computed = ui_state.rect(&n.computed_id);
276
277    let from_user_self = is_from_user(n.source);
278    // Nearest user-source location attributable to this node — itself
279    // when self is from user code, otherwise the closest ancestor's
280    // user source. Used by overflow findings so widget-composed leaves
281    // (e.g. `tab_trigger` built inside `tabs_list`'s `.map(...)`
282    // closure, where `Location::caller()` resolves inside aetna-core)
283    // still blame the user code that supplied the offending content.
284    let self_blame = if from_user_self {
285        Some(n.source)
286    } else {
287        parent_blame
288    };
289
290    // Children of an Inlines paragraph are encoded into one
291    // AttributedText draw op by draw_ops; their individual rects are
292    // intentionally zero-size. Skip the per-text overflow + per-child
293    // overflow checks for them — the paragraph as a whole holds the
294    // rect, so any overflow lint applies at the Inlines node level.
295    let inside_inlines = matches!(parent_kind, Some(Kind::Inlines));
296
297    // Raw colors are intentional inside library widgets; only flag
298    // them when the node is itself in user code.
299    if from_user_self {
300        if let Some(c) = n.fill
301            && c.token.is_none()
302            && c.a > 0
303        {
304            r.findings.push(Finding {
305                kind: FindingKind::RawColor,
306                node_id: n.computed_id.clone(),
307                source: n.source,
308                message: format!(
309                    "fill is a raw rgba({},{},{},{}) — use a token",
310                    c.r, c.g, c.b, c.a
311                ),
312            });
313        }
314        if let Some(c) = n.stroke
315            && c.token.is_none()
316            && c.a > 0
317        {
318            r.findings.push(Finding {
319                kind: FindingKind::RawColor,
320                node_id: n.computed_id.clone(),
321                source: n.source,
322                message: format!(
323                    "stroke is a raw rgba({},{},{},{}) — use a token",
324                    c.r, c.g, c.b, c.a
325                ),
326            });
327        }
328        if let Some(c) = n.text_color
329            && c.token.is_none()
330            && c.a > 0
331        {
332            r.findings.push(Finding {
333                kind: FindingKind::RawColor,
334                node_id: n.computed_id.clone(),
335                source: n.source,
336                message: format!(
337                    "text_color is a raw rgba({},{},{},{}) — use a token",
338                    c.r, c.g, c.b, c.a
339                ),
340            });
341        }
342        // `.tooltip()` on an unkeyed node — silently dead, because
343        // hit-test only returns keyed nodes, so hover never lands on
344        // this leaf and `synthesize_tooltip` never reads its text.
345        // Same "modifier requires unrelated state to take effect"
346        // shape as the dead-`.ellipsis()` finding below.
347        if n.tooltip.is_some() && n.key.is_none() {
348            r.findings.push(Finding {
349                kind: FindingKind::DeadTooltip,
350                node_id: n.computed_id.clone(),
351                source: n.source,
352                message: ".tooltip() on a node without .key() never fires — hit-test only \
353                     returns keyed nodes, so hover skips past this leaf to the nearest \
354                     keyed ancestor. Add .key(\"…\") on the same node that carries the \
355                     tooltip; for info-only chrome inside list rows, a synthetic key \
356                     like \"row:{idx}.<part>\" is enough."
357                    .to_string(),
358            });
359        }
360
361        // SurfaceRole::Panel only paints stroke + shadow on top of the
362        // node's existing fill. Without a fill, the surface reads as a
363        // thin border over BACKGROUND — the classic "invisible panel"
364        // mistake. Suggest the right widget. (Raised is also
365        // decorative but `button(...).ghost()` legitimately leaves a
366        // Raised node with no fill, so the lint stays narrow.)
367        if n.fill.is_none() && matches!(n.surface_role, SurfaceRole::Panel) {
368            r.findings.push(Finding {
369                kind: FindingKind::MissingSurfaceFill,
370                node_id: n.computed_id.clone(),
371                source: n.source,
372                message:
373                    "surface_role(Panel) without a fill paints only stroke + shadow — \
374                     wrap in card() / sidebar() / dialog() for the canonical recipe, or set .fill(tokens::CARD)"
375                        .to_string(),
376            });
377        }
378
379        if matches!(n.surface_role, SurfaceRole::Panel) {
380            check_unpadded_surface_panel(n, computed, ui_state, r, n.source);
381        }
382
383        // Reinvented widgets: a plain Group whose visual recipe matches
384        // a stock widget. The signatures stay narrow on purpose — both
385        // require the canonical token pair (fill = CARD, stroke =
386        // BORDER) and a structural marker (a non-zero radius for card,
387        // an exact SIDEBAR_WIDTH for sidebar). The real widgets escape
388        // these checks: `card()` returns Kind::Card, and `sidebar()`
389        // sets surface_role(Panel) — so neither stock widget trips its
390        // own lint when the user calls them directly.
391        //
392        // Skip empty Groups — a `column(Vec::<El>::new())` styled with
393        // CARD/BORDER is a pure visual swatch (color sample, divider
394        // stub) that's not pretending to be a card. Card-mimics
395        // always wrap content.
396        if matches!(n.kind, Kind::Group) && !n.children.is_empty() {
397            let card_fill = n
398                .fill
399                .as_ref()
400                .and_then(|c| c.token)
401                .is_some_and(|t| t == "card");
402            let border_stroke = n
403                .stroke
404                .as_ref()
405                .and_then(|c| c.token)
406                .is_some_and(|t| t == "border");
407            if card_fill && border_stroke {
408                let is_panel_surface = matches!(n.surface_role, SurfaceRole::Panel);
409                let sidebar_width = matches!(n.width, Size::Fixed(w) if (w - crate::tokens::SIDEBAR_WIDTH).abs() < 0.5);
410                if !is_panel_surface {
411                    if sidebar_width {
412                        r.findings.push(Finding {
413                            kind: FindingKind::ReinventedWidget,
414                            node_id: n.computed_id.clone(),
415                            source: n.source,
416                            message:
417                                "Group with fill=CARD, stroke=BORDER, width=SIDEBAR_WIDTH reinvents sidebar() — \
418                                 use sidebar([sidebar_header(...), sidebar_group([sidebar_menu([sidebar_menu_button(label, current)])])]) \
419                                 for the panel surface and the canonical row recipe"
420                                    .to_string(),
421                        });
422                    } else {
423                        // Any other Group with the canonical card-tone
424                        // pair is a hand-rolled card-or-aside surface.
425                        // Both the "boxed" case (non-zero radius, fits
426                        // inside another container) and the "side panel"
427                        // case (full-height inspector pane) collapse
428                        // into the same recipe — `card([...])` bundles
429                        // it. Mention sidebar() too, since for full-bleed
430                        // panels with custom widths (e.g. inspector
431                        // rails) the right answer might be sidebar().
432                        r.findings.push(Finding {
433                            kind: FindingKind::ReinventedWidget,
434                            node_id: n.computed_id.clone(),
435                            source: n.source,
436                            message:
437                                "Group with fill=CARD, stroke=BORDER reinvents the panel-surface recipe — \
438                                 use card([card_header([card_title(\"...\")]), card_content([...])]) / titled_card(\"Title\", [...]) for boxed content, \
439                                 or sidebar([...]) for a full-height nav/inspector pane (sidebar() also handles the custom-width case via .width(Size::Fixed(...)))"
440                                    .to_string(),
441                        });
442                    }
443                }
444            }
445        }
446    }
447
448    // Row alignment: mirror CSS flex's default `align-items: stretch`,
449    // but catch the common UI-row mistake where a fixed-size visual
450    // child (icon/badge/control) is pinned to the row top beside a
451    // text sibling. The fix is the familiar `items-center` move:
452    // `.align(Align::Center)`.
453    if let Some(blame) = self_blame {
454        lint_row_alignment(n, computed, ui_state, r, blame);
455        lint_overlay_alignment(n, computed, ui_state, r, blame);
456        lint_row_visual_text_spacing(n, ui_state, r, blame);
457    }
458
459    // Text overflow: detect at the node itself (with the node's own
460    // padding-aware content region — text_w includes padding so the
461    // check fires when the text exceeds the padded content area, not
462    // just the bare rect). Attribute to the nearest user-source
463    // ancestor so closure-built widget leaves still blame user code.
464    if n.text.is_some()
465        && !inside_inlines
466        && let Some(blame) = self_blame
467    {
468        let available_width = match n.text_wrap {
469            TextWrap::NoWrap => None,
470            TextWrap::Wrap => Some(computed.w),
471        };
472        if let Some(text_layout) = layout::text_layout(n, available_width) {
473            let text_w = text_layout.width + n.padding.left + n.padding.right;
474            let text_h = text_layout.height + n.padding.top + n.padding.bottom;
475            let raw_overflow_x = (text_w - computed.w).max(0.0);
476            let overflow_x = if matches!(
477                (n.text_wrap, n.text_overflow),
478                (TextWrap::NoWrap, TextOverflow::Ellipsis)
479            ) {
480                0.0
481            } else {
482                raw_overflow_x
483            };
484            let overflow_y = (text_h - computed.h).max(0.0);
485            if overflow_x > 0.5 || overflow_y > 0.5 {
486                let is_clipped_nowrap = overflow_x > 0.5
487                    && matches!(
488                        (n.text_wrap, n.text_overflow),
489                        (TextWrap::NoWrap, TextOverflow::Clip)
490                    );
491                let kind = if is_clipped_nowrap {
492                    FindingKind::TextOverflow
493                } else {
494                    FindingKind::Overflow
495                };
496                // Shape-specific advice. A Y-only overflow on a
497                // fixed-height box where the text alone would have fit
498                // is caused by padding eating the height; "use
499                // paragraph() / wrap_text() / a wider box" is the
500                // wrong fix. The trap that produces it most often is
501                // `.padding(scalar)` going through `From<f32> for
502                // Sides` as `Sides::all(scalar)` on a control-height
503                // box where the author meant `Sides::xy(scalar, 0)`.
504                let pad_y = n.padding.top + n.padding.bottom;
505                let height_is_fixed = matches!(n.height, Size::Fixed(_));
506                let text_alone_fits_height = text_layout.height <= computed.h + 0.5;
507                let padding_eats_fixed_height = overflow_y > 0.5
508                    && overflow_x <= 0.5
509                    && pad_y > 0.0
510                    && text_alone_fits_height
511                    && height_is_fixed;
512                let cell_h = text_layout.height;
513                let box_h = computed.h;
514                let message = if kind == FindingKind::TextOverflow {
515                    format!(
516                        "nowrap text exceeds its box by X={overflow_x:.0}; use .ellipsis(), wrap_text(), or a wider box"
517                    )
518                } else if padding_eats_fixed_height {
519                    let inner_h = (box_h - pad_y).max(0.0);
520                    let pad_x_token = if (n.padding.left - n.padding.right).abs() < 0.5 {
521                        format!("{:.0}", n.padding.left)
522                    } else {
523                        "...".to_string()
524                    };
525                    let control_h = crate::tokens::CONTROL_HEIGHT;
526                    format!(
527                        "vertical padding ({pad_y:.0}px) makes the inner content rect ({inner_h:.0}px) shorter than the text cell ({cell_h:.0}px) on a fixed-height box ({box_h:.0}px) — \
528                         the label can't vertically center and paints into the padding band, off-center by Y={overflow_y:.0}. \
529                         Reduce vertical padding (e.g. `Sides::xy({pad_x_token}, 0.0)` — `.padding(scalar)` is `Sides::all(scalar)`, which usually isn't what you want on a control-height box) or increase height (tokens::CONTROL_HEIGHT = {control_h:.0}px)"
530                    )
531                } else if overflow_y > 0.5 && overflow_x <= 0.5 {
532                    format!(
533                        "text cell ({cell_h:.0}px) exceeds box height ({box_h:.0}px) by Y={overflow_y:.0}; \
534                         increase height, reduce text size, or use paragraph()/wrap_text() with fewer lines"
535                    )
536                } else {
537                    format!(
538                        "text content exceeds its box by X={overflow_x:.0} Y={overflow_y:.0}; use paragraph()/wrap_text(), a wider box, or explicit clipping"
539                    )
540                };
541                r.findings.push(Finding {
542                    kind,
543                    node_id: n.computed_id.clone(),
544                    source: blame,
545                    message,
546                });
547            }
548        }
549    }
550
551    // Overflow: child rect extends past parent. Scrollable parents
552    // overflow their content on the main axis by design — that's the
553    // whole point — so don't flag children of a scroll viewport.
554    // `clip=true` is the general "this container handles overflow by
555    // visually truncating" signal — text_input clips its inner group,
556    // diff split halves clip at the half boundary, code blocks clip
557    // long lines, etc. Author intent here is explicit, so suppress.
558    // Inlines parents intentionally zero-size their children (the
559    // paragraph paints them as one AttributedText), so per-child rect
560    // checks would always fire — suppress. The runtime-synthesized
561    // toast_stack uses a custom layout that pins cards to the
562    // viewport regardless of its own (parent-allocated) rect, so its
563    // children naturally extend past the layer's bounds — also
564    // suppress.
565    let suppress_overflow = n.scrollable
566        || n.clip
567        || matches!(n.kind, Kind::Inlines)
568        || matches!(n.kind, Kind::Custom("toast_stack"));
569
570    // Dead-ellipsis detection: when this parent's flex layout overran
571    // on its main axis, any `Size::Hug` child with `NoWrap + Ellipsis`
572    // has a dead truncation chain. `layout::main_size_of` returns
573    // `MainSize::Resolved(intrinsic)` for `Size::Hug`, so the child's
574    // rect width on the main axis always equals its natural content
575    // width — and that's the exact value `draw_ops` passes as the
576    // budget to `ellipsize_text_with_family`. Without a constrained
577    // rect the truncation branch never trims a glyph. We compute
578    // overrun once per parent and flag matching children below.
579    let parent_main_overran =
580        !suppress_overflow && flex_main_axis_overflowed(n, computed, ui_state);
581
582    // Update the nearest-clipping-ancestor rect for descendants. The
583    // scissor in `draw_ops` uses `inner_painted_rect` (the layout
584    // rect, no padding inset, no overflow outset), so this rect is
585    // the right bound to compare descendant ring bands against.
586    // Scrollable clips suppress clipping findings on the scroll axis
587    // (auto-scroll-on-focus reveals partially-clipped rows there).
588    let child_clip = if n.clip {
589        if n.scrollable {
590            ClipCtx::Scrolling {
591                rect: computed,
592                scroll_axis: n.axis,
593                node_id: n.computed_id.clone(),
594            }
595        } else {
596            ClipCtx::Static(computed)
597        }
598    } else {
599        nearest_clip.clone()
600    };
601
602    for (child_idx, c) in n.children.iter().enumerate() {
603        let from_user_child = is_from_user(c.source);
604        let child_blame = if from_user_child {
605            Some(c.source)
606        } else {
607            self_blame
608        };
609
610        let c_rect = ui_state.rect(&c.computed_id);
611        if !suppress_overflow
612            && !rect_contains(computed, c_rect, 0.5)
613            && let Some(blame) = child_blame
614        {
615            let dx_left = (computed.x - c_rect.x).max(0.0);
616            let dx_right = (c_rect.right() - computed.right()).max(0.0);
617            let dy_top = (computed.y - c_rect.y).max(0.0);
618            let dy_bottom = (c_rect.bottom() - computed.bottom()).max(0.0);
619            r.findings.push(Finding {
620                kind: FindingKind::Overflow,
621                node_id: c.computed_id.clone(),
622                source: blame,
623                message: format!(
624                    "child overflows parent {parent_id} by L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}",
625                    parent_id = n.computed_id,
626                ),
627            });
628        }
629
630        // Dead `.ellipsis()` chain on a Hug child of an overran flex
631        // parent (see comment on `parent_main_overran` above). Point
632        // at the text directly so the user knows which fix to make:
633        // the existing per-child Overflow finding fires on the
634        // *displaced* sibling, not on the offending Hug text.
635        let main_axis_is_hug = match n.axis {
636            Axis::Row => matches!(c.width, Size::Hug),
637            Axis::Column => matches!(c.height, Size::Hug),
638            Axis::Overlay => false,
639        };
640        if parent_main_overran
641            && main_axis_is_hug
642            && c.text.is_some()
643            && c.text_wrap == TextWrap::NoWrap
644            && c.text_overflow == TextOverflow::Ellipsis
645            && let Some(blame) = child_blame
646        {
647            r.findings.push(Finding {
648                kind: FindingKind::TextOverflow,
649                node_id: c.computed_id.clone(),
650                source: blame,
651                message:
652                    ".ellipsis() has no effect on Size::Hug text — Hug forces the rect to the intrinsic content width, so the truncation budget equals the content and no glyph is ever trimmed. Set Size::Fill(_) or Size::Fixed(_) on the text or on a wrapping container so the layout can constrain the rect."
653                        .to_string(),
654            });
655        }
656
657        // Corner stackup: a filled child paints into a rounded
658        // parent's corner-curve area, obscuring the parent's stroke
659        // and curve with a flat corner. The canonical card_header /
660        // card_footer recipe is auto-fixed by `metrics`; this check
661        // catches the same pattern in hand-rolled containers. Gated
662        // on the child being from user code so library widgets that
663        // legitimately paint in corner regions don't trip it.
664        if from_user_child
665            && c.fill.is_some()
666            && n.radius.any_nonzero()
667            && let Some(blame) = child_blame
668        {
669            check_corner_stackup(n, computed, c, c_rect, r, blame);
670        }
671
672        if from_user_child
673            && c.focusable
674            && let Some(blame) = child_blame
675        {
676            // Focus-ring obscurement: only meaningful for focusable
677            // nodes that reserve a paint_overflow band (the band is
678            // where the ring renders in the stock-shader path).
679            if has_paint_overflow(c.paint_overflow) {
680                check_focus_ring_obscured(
681                    c,
682                    c_rect,
683                    &child_clip,
684                    &n.children[child_idx + 1..],
685                    ui_state,
686                    r,
687                    blame,
688                );
689            }
690            // Independent of paint_overflow: the focusable's own rect
691            // overlaps an ancestor scroll's thumb track (the thumb
692            // paints on top of the control whenever it's visible).
693            check_scrollbar_overlap(c, c_rect, &child_clip, ui_state, r, blame);
694        }
695
696        walk(
697            c,
698            Some(&n.kind),
699            child_blame,
700            &child_clip,
701            ui_state,
702            r,
703            seen,
704        );
705    }
706}
707
708fn has_paint_overflow(s: Sides) -> bool {
709    s.left > 0.0 || s.right > 0.0 || s.top > 0.0 || s.bottom > 0.0
710}
711
712/// Detect the corner-stackup pattern: a filled child whose rect
713/// overlaps one of a rounded parent's corner-curve boxes without
714/// matching that corner's radius. Mirrors the geometric test the
715/// painter actually performs — the parent's rounded-rect SDF leaves
716/// the `r×r` square at each rounded corner partially transparent, and
717/// a child fill that overlaps that square paints sharp corners over
718/// the parent's curve and stroke.
719fn check_corner_stackup(
720    parent: &El,
721    parent_rect: Rect,
722    child: &El,
723    child_rect: Rect,
724    r: &mut LintReport,
725    blame: Source,
726) {
727    let pr = parent.radius;
728    let cr = child.radius;
729    // (parent_radius, child_radius, corner-curve box in parent space)
730    let tl = (
731        pr.tl,
732        cr.tl,
733        Rect::new(parent_rect.x, parent_rect.y, pr.tl, pr.tl),
734    );
735    let tr = (
736        pr.tr,
737        cr.tr,
738        Rect::new(
739            parent_rect.x + parent_rect.w - pr.tr,
740            parent_rect.y,
741            pr.tr,
742            pr.tr,
743        ),
744    );
745    let br = (
746        pr.br,
747        cr.br,
748        Rect::new(
749            parent_rect.x + parent_rect.w - pr.br,
750            parent_rect.y + parent_rect.h - pr.br,
751            pr.br,
752            pr.br,
753        ),
754    );
755    let bl = (
756        pr.bl,
757        cr.bl,
758        Rect::new(
759            parent_rect.x,
760            parent_rect.y + parent_rect.h - pr.bl,
761            pr.bl,
762            pr.bl,
763        ),
764    );
765    let leaks_at = |(p_r, c_r, corner_box): (f32, f32, Rect)| -> bool {
766        if p_r <= 0.5 || c_r + 0.5 >= p_r {
767            return false;
768        }
769        match child_rect.intersect(corner_box) {
770            Some(overlap) => overlap.w >= 0.5 && overlap.h >= 0.5,
771            None => false,
772        }
773    };
774    let (leak_tl, leak_tr, leak_br, leak_bl) =
775        (leaks_at(tl), leaks_at(tr), leaks_at(br), leaks_at(bl));
776    if !(leak_tl || leak_tr || leak_br || leak_bl) {
777        return;
778    }
779    let (descriptor, helper) = match (leak_tl, leak_tr, leak_br, leak_bl) {
780        (true, true, false, false) => ("the parent's top corners", "Corners::top(...)"),
781        (false, false, true, true) => ("the parent's bottom corners", "Corners::bottom(...)"),
782        (true, false, false, true) => ("the parent's left corners", "Corners::left(...)"),
783        (false, true, true, false) => ("the parent's right corners", "Corners::right(...)"),
784        (true, true, true, true) => ("the parent's corners", "Corners::all(...)"),
785        // Single corner or any L-shape: author picks the matching field set.
786        _ => (
787            "a parent corner",
788            "Corners { tl, tr, br, bl } with the matching corner set",
789        ),
790    };
791    r.findings.push(Finding {
792        kind: FindingKind::CornerStackup,
793        node_id: child.computed_id.clone(),
794        source: blame,
795        message: format!(
796            "filled child paints into {descriptor} (rounded parent, max radius={pr_max:.0}) — \
797             the flat corners obscure the parent's curve and stroke. \
798             Set `.radius({helper})` on the child so its corners follow the parent's curve, \
799             or add padding to the parent so the child is inset from the curve.",
800            pr_max = pr.max(),
801        ),
802    });
803}
804
805/// Detects [`FindingKind::UnpaddedSurfacePanel`]: a Panel surface
806/// whose direct children sit flush against one or more outer edges
807/// with no padding to inset them. Per-side rule: a side is "safe"
808/// when either the panel itself pads on that side, or some child
809/// whose rect touches that side carries inward padding on that side.
810/// That keeps the canonical `card([card_header, card_content,
811/// card_footer])` anatomy quiet (header pads top/left/right at
812/// `SPACE_6`; footer pads bottom/left/right at `SPACE_6`) while
813/// flagging `card([row(...).width(Fill(1.0)), button_row])` and
814/// other bare-panel + Fill-children shapes.
815fn check_unpadded_surface_panel(
816    panel: &El,
817    panel_rect: Rect,
818    ui_state: &UiState,
819    r: &mut LintReport,
820    blame: Source,
821) {
822    // Match the issue spec: a child rect within `RING_WIDTH` of an
823    // outer edge counts as flush against it.
824    let touch_eps = crate::tokens::RING_WIDTH;
825    // Half a pixel of inward padding is enough to clear `touch_eps`
826    // and inset content from the edge.
827    const PAD_EPS: f32 = 0.5;
828
829    // Per-side state: (any child touches, any touching child pads inward).
830    let mut top = (false, false);
831    let mut right = (false, false);
832    let mut bottom = (false, false);
833    let mut left = (false, false);
834
835    for c in &panel.children {
836        let cr = ui_state.rect(&c.computed_id);
837        if cr.w <= PAD_EPS || cr.h <= PAD_EPS {
838            // Zero-area children can't be flush against anything.
839            continue;
840        }
841        if (cr.y - panel_rect.y).abs() <= touch_eps {
842            top.0 = true;
843            if c.padding.top > PAD_EPS {
844                top.1 = true;
845            }
846        }
847        if (panel_rect.right() - cr.right()).abs() <= touch_eps {
848            right.0 = true;
849            if c.padding.right > PAD_EPS {
850                right.1 = true;
851            }
852        }
853        if (panel_rect.bottom() - cr.bottom()).abs() <= touch_eps {
854            bottom.0 = true;
855            if c.padding.bottom > PAD_EPS {
856                bottom.1 = true;
857            }
858        }
859        if (cr.x - panel_rect.x).abs() <= touch_eps {
860            left.0 = true;
861            if c.padding.left > PAD_EPS {
862                left.1 = true;
863            }
864        }
865    }
866
867    let pad = panel.padding;
868    let mut sides: Vec<&'static str> = Vec::new();
869    if pad.top <= PAD_EPS && top.0 && !top.1 {
870        sides.push("top");
871    }
872    if pad.right <= PAD_EPS && right.0 && !right.1 {
873        sides.push("right");
874    }
875    if pad.bottom <= PAD_EPS && bottom.0 && !bottom.1 {
876        sides.push("bottom");
877    }
878    if pad.left <= PAD_EPS && left.0 && !left.1 {
879        sides.push("left");
880    }
881    if sides.is_empty() {
882        return;
883    }
884    let joined = sides.join("/");
885    r.findings.push(Finding {
886        kind: FindingKind::UnpaddedSurfacePanel,
887        node_id: panel.computed_id.clone(),
888        source: blame,
889        message: format!(
890            "Panel-surface children sit flush against the {joined} edge — \
891             wrap content in the slot anatomy (`card_header(...)` / `card_content(...)` / `card_footer(...)` \
892             each bake `SPACE_6` padding), or pad the panel itself \
893             (e.g. `.padding(Sides::all(tokens::SPACE_4))` for dense list-row cards).",
894        ),
895    });
896}
897
898fn check_focus_ring_obscured(
899    n: &El,
900    n_rect: Rect,
901    nearest_clip: &ClipCtx,
902    later_siblings: &[El],
903    ui_state: &UiState,
904    r: &mut LintReport,
905    blame: Source,
906) {
907    let band = n_rect.outset(n.paint_overflow);
908
909    // 1. Clipped by ancestor scissor. For scrollable clips, only the
910    // cross axis is checked — the scroll axis can bring partially
911    // clipped rows into view on focus.
912    let (clip_rect, check_horiz, check_vert) = match nearest_clip {
913        ClipCtx::None => (None, false, false),
914        ClipCtx::Static(rect) => (Some(*rect), true, true),
915        ClipCtx::Scrolling {
916            rect, scroll_axis, ..
917        } => match scroll_axis {
918            Axis::Column => (Some(*rect), true, false),
919            Axis::Row => (Some(*rect), false, true),
920            Axis::Overlay => (Some(*rect), true, true),
921        },
922    };
923    if let Some(clip) = clip_rect {
924        let dx_left = if check_horiz {
925            (clip.x - band.x).max(0.0)
926        } else {
927            0.0
928        };
929        let dx_right = if check_horiz {
930            (band.right() - clip.right()).max(0.0)
931        } else {
932            0.0
933        };
934        let dy_top = if check_vert {
935            (clip.y - band.y).max(0.0)
936        } else {
937            0.0
938        };
939        let dy_bottom = if check_vert {
940            (band.bottom() - clip.bottom()).max(0.0)
941        } else {
942            0.0
943        };
944        if dx_left + dx_right + dy_top + dy_bottom > 0.5 {
945            r.findings.push(Finding {
946                kind: FindingKind::FocusRingObscured,
947                node_id: n.computed_id.clone(),
948                source: blame,
949                message: format!(
950                    "focus ring band clipped by ancestor scissor (L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}) — give a clipping ancestor padding ≥ tokens::RING_WIDTH on the clipped side",
951                ),
952            });
953        }
954    }
955
956    // 2. Occluded by a later-painted sibling whose rect overlaps the
957    // bleed band on a side where the focusable reserves overflow.
958    // Skip overlay parents (siblings are intentionally stacked).
959    for sib in later_siblings {
960        let sib_rect = ui_state.rect(&sib.computed_id);
961        if let Some(side) = bleed_occlusion(n_rect, n.paint_overflow, sib_rect)
962            && paints_pixels(sib)
963        {
964            r.findings.push(Finding {
965                kind: FindingKind::FocusRingObscured,
966                node_id: n.computed_id.clone(),
967                source: blame,
968                message: format!(
969                    "focus ring band occluded on the {side} edge by later-painted sibling {sib_id} — increase gap to ≥ tokens::RING_WIDTH or restructure so the neighbor doesn't sit on the edge",
970                    sib_id = sib.computed_id,
971                ),
972            });
973            // First occluder is enough — don't double-report.
974            break;
975        }
976    }
977}
978
979/// Detects `ScrollbarObscuresFocusable`: a focusable descendant of a
980/// scrolling ancestor whose x-extent overlaps the visible scrollbar
981/// thumb's column. The check uses the thumb's *active* width
982/// (`SCROLLBAR_THUMB_WIDTH_ACTIVE`) — the wider rendering shown when
983/// the user interacts with the scrollbar — so the fix that clears
984/// the active thumb (a `SCROLLBAR_THUMB_WIDTH_ACTIVE +
985/// SCROLLBAR_TRACK_INSET`-wide right-edge gutter on content) is also
986/// what silences the lint.
987///
988/// The thumb's vertical position changes with scroll offset, but its
989/// x-column is fixed; checking x-axis overlap (independent of the
990/// thumb's current y) catches focusables that would be covered at
991/// any scroll position.
992///
993/// Only fires when content actually overflows enough for the runtime
994/// to write a `thumb_tracks` entry — non-overflowing scrolls don't
995/// render a thumb, so the bug isn't user-visible.
996fn check_scrollbar_overlap(
997    n: &El,
998    n_rect: Rect,
999    nearest_clip: &ClipCtx,
1000    ui_state: &UiState,
1001    r: &mut LintReport,
1002    blame: Source,
1003) {
1004    let ClipCtx::Scrolling { node_id, .. } = nearest_clip else {
1005        return;
1006    };
1007    let Some(track) = ui_state.scroll.thumb_tracks.get(node_id).copied() else {
1008        return;
1009    };
1010    // Active thumb sits flush-right inside the hitbox gutter, so its
1011    // right edge equals the track's right edge and its width is
1012    // SCROLLBAR_THUMB_WIDTH_ACTIVE. Checking against this (rather
1013    // than the wider hitbox) matches the conventional fix gutter of
1014    // SCROLLBAR_THUMB_WIDTH_ACTIVE + SCROLLBAR_TRACK_INSET.
1015    let active_w = crate::tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE;
1016    let thumb_left = track.right() - active_w;
1017    let thumb_right = track.right();
1018    let overlap_x = n_rect.right().min(thumb_right) - n_rect.x.max(thumb_left);
1019    if overlap_x <= 0.5 {
1020        return;
1021    }
1022    r.findings.push(Finding {
1023        kind: FindingKind::ScrollbarObscuresFocusable,
1024        node_id: n.computed_id.clone(),
1025        source: blame,
1026        message: format!(
1027            "scrollbar thumb overlaps this focusable on the right edge by {overlap_x:.0}px (thumb x={thumb_left:.0}..{thumb_right:.0}; control x={ctrl_x:.0}..{ctrl_right:.0}) — move horizontal padding *inside* the scroll, onto a wrapper that constrains children to a narrower content rect, so the thumb sits in a reserved gutter to the right of content",
1028            ctrl_x = n_rect.x,
1029            ctrl_right = n_rect.right(),
1030        ),
1031    });
1032}
1033
1034/// True if `n` paints visible pixels (so it can occlude a sibling's
1035/// focus ring band). Pure structural columns/rows with no fill/
1036/// stroke/text/image/shadow don't occlude.
1037fn paints_pixels(n: &El) -> bool {
1038    n.fill.is_some()
1039        || n.stroke.is_some()
1040        || n.image.is_some()
1041        || n.icon.is_some()
1042        || n.shadow > 0.0
1043        || n.text.is_some()
1044        || !matches!(n.surface_role, SurfaceRole::None)
1045}
1046
1047/// Whichever side of `n_rect`'s `paint_overflow` band `sib_rect`
1048/// intersects (above the EPS adjacency threshold). `EPS` keeps a
1049/// sibling whose edge merely touches the focusable's edge (gap = 0)
1050/// from triggering — touching is adjacency, not yet occlusion.
1051fn bleed_occlusion(n_rect: Rect, overflow: Sides, sib_rect: Rect) -> Option<&'static str> {
1052    const EPS: f32 = 0.5;
1053    let bands: [(&'static str, Rect); 4] = [
1054        (
1055            "top",
1056            Rect::new(n_rect.x, n_rect.y - overflow.top, n_rect.w, overflow.top),
1057        ),
1058        (
1059            "bottom",
1060            Rect::new(n_rect.x, n_rect.bottom(), n_rect.w, overflow.bottom),
1061        ),
1062        (
1063            "left",
1064            Rect::new(n_rect.x - overflow.left, n_rect.y, overflow.left, n_rect.h),
1065        ),
1066        (
1067            "right",
1068            Rect::new(n_rect.right(), n_rect.y, overflow.right, n_rect.h),
1069        ),
1070    ];
1071    for (side, band) in bands {
1072        if band.w <= 0.0 || band.h <= 0.0 {
1073            continue;
1074        }
1075        let iw = band.right().min(sib_rect.right()) - band.x.max(sib_rect.x);
1076        let ih = band.bottom().min(sib_rect.bottom()) - band.y.max(sib_rect.y);
1077        if iw > EPS && ih > EPS {
1078            return Some(side);
1079        }
1080    }
1081    None
1082}
1083
1084fn lint_row_alignment(
1085    n: &El,
1086    computed: Rect,
1087    ui_state: &UiState,
1088    r: &mut LintReport,
1089    blame: Source,
1090) {
1091    if !matches!(n.axis, Axis::Row) || !matches!(n.align, Align::Stretch) || n.children.len() < 2 {
1092        return;
1093    }
1094    if !n.children.iter().any(is_text_like_child) {
1095        return;
1096    }
1097
1098    let inner = computed.inset(n.padding);
1099    if inner.h <= 0.0 {
1100        return;
1101    }
1102
1103    for child in &n.children {
1104        if !is_fixed_visual_child(child) {
1105            continue;
1106        }
1107        let child_rect = ui_state.rect(&child.computed_id);
1108        let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1109        let visibly_short = child_rect.h + 2.0 < inner.h;
1110        if top_pinned && visibly_short {
1111            r.findings.push(Finding {
1112                kind: FindingKind::Alignment,
1113                node_id: n.computed_id.clone(),
1114                source: blame,
1115                message: "row has a fixed-size visual child pinned to the top beside text; add .align(Align::Center) to vertically center row content"
1116                    .to_string(),
1117            });
1118            return;
1119        }
1120    }
1121}
1122
1123fn lint_overlay_alignment(
1124    n: &El,
1125    computed: Rect,
1126    ui_state: &UiState,
1127    r: &mut LintReport,
1128    blame: Source,
1129) {
1130    if !matches!(n.axis, Axis::Overlay)
1131        || n.children.is_empty()
1132        || !matches!(n.align, Align::Start | Align::Stretch)
1133        || !matches!(n.justify, Justify::Start | Justify::SpaceBetween)
1134        || !has_visible_surface(n)
1135    {
1136        return;
1137    }
1138
1139    let inner = computed.inset(n.padding);
1140    if inner.w <= 0.0 || inner.h <= 0.0 {
1141        return;
1142    }
1143
1144    for child in &n.children {
1145        if !is_fixed_visual_child(child) {
1146            continue;
1147        }
1148        let child_rect = ui_state.rect(&child.computed_id);
1149        let left_pinned = (child_rect.x - inner.x).abs() <= 0.5;
1150        let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1151        let visibly_narrow = child_rect.w + 2.0 < inner.w;
1152        let visibly_short = child_rect.h + 2.0 < inner.h;
1153        if left_pinned && top_pinned && visibly_narrow && visibly_short {
1154            r.findings.push(Finding {
1155                kind: FindingKind::Alignment,
1156                node_id: n.computed_id.clone(),
1157                source: blame,
1158                message: "overlay has a smaller fixed-size visual child pinned to the top-left; add .align(Align::Center).justify(Justify::Center) to center overlay content"
1159                    .to_string(),
1160            });
1161            return;
1162        }
1163    }
1164}
1165
1166fn lint_row_visual_text_spacing(n: &El, ui_state: &UiState, r: &mut LintReport, blame: Source) {
1167    if !matches!(n.axis, Axis::Row) || n.children.len() < 2 {
1168        return;
1169    }
1170
1171    for pair in n.children.windows(2) {
1172        let [visual, text] = pair else {
1173            continue;
1174        };
1175        if !is_visual_cluster_child(visual) || !is_text_like_child(text) {
1176            continue;
1177        }
1178
1179        let visual_rect = ui_state.rect(&visual.computed_id);
1180        let text_rect = ui_state.rect(&text.computed_id);
1181        let gap = text_rect.x - visual_rect.right();
1182        if gap < 4.0 {
1183            r.findings.push(Finding {
1184                kind: FindingKind::Spacing,
1185                node_id: n.computed_id.clone(),
1186                source: blame,
1187                message: format!(
1188                    "row places text {:.0}px after an icon/control slot; add .gap(tokens::SPACE_2) or use a stock menu/list row",
1189                    gap.max(0.0)
1190                ),
1191            });
1192            return;
1193        }
1194    }
1195}
1196
1197fn is_text_like_child(c: &El) -> bool {
1198    c.text.is_some()
1199        || c.children
1200            .iter()
1201            .any(|child| child.text.is_some() || matches!(child.kind, Kind::Text | Kind::Heading))
1202}
1203
1204fn has_visible_surface(n: &El) -> bool {
1205    n.fill.is_some() || n.stroke.is_some()
1206}
1207
1208fn is_fixed_visual_child(c: &El) -> bool {
1209    let fixed_height = matches!(c.height, Size::Fixed(_));
1210    fixed_height
1211        && (c.icon.is_some()
1212            || matches!(c.kind, Kind::Badge)
1213            || matches!(
1214                c.metrics_role,
1215                Some(
1216                    MetricsRole::Button
1217                        | MetricsRole::IconButton
1218                        | MetricsRole::Input
1219                        | MetricsRole::Badge
1220                        | MetricsRole::TabTrigger
1221                        | MetricsRole::ChoiceControl
1222                        | MetricsRole::Slider
1223                        | MetricsRole::Progress
1224                )
1225            ))
1226}
1227
1228fn is_visual_cluster_child(c: &El) -> bool {
1229    let fixed_box = matches!(c.width, Size::Fixed(_)) && matches!(c.height, Size::Fixed(_));
1230    fixed_box
1231        && (c.icon.is_some()
1232            || matches!(c.kind, Kind::Badge)
1233            || matches!(
1234                c.metrics_role,
1235                Some(MetricsRole::IconButton | MetricsRole::Badge | MetricsRole::ChoiceControl)
1236            )
1237            || (has_visible_surface(c) && c.children.iter().any(is_fixed_visual_child)))
1238}
1239
1240fn rect_contains(parent: Rect, child: Rect, tol: f32) -> bool {
1241    child.x >= parent.x - tol
1242        && child.y >= parent.y - tol
1243        && child.right() <= parent.right() + tol
1244        && child.bottom() <= parent.bottom() + tol
1245}
1246
1247/// True when a Row/Column parent's children, summed along the parent's
1248/// main axis (plus gaps), exceed the parent's padded inner extent —
1249/// i.e. the layout pass overran. Mirrors the `consumed > main_extent`
1250/// shape from `layout::layout_axis`. Overlay parents have no main-axis
1251/// packing, so overrun is meaningless there.
1252fn flex_main_axis_overflowed(parent: &El, parent_rect: Rect, ui_state: &UiState) -> bool {
1253    let n = parent.children.len();
1254    if n == 0 {
1255        return false;
1256    }
1257    let inner = parent_rect.inset(parent.padding);
1258    let inner_main = match parent.axis {
1259        Axis::Row => inner.w,
1260        Axis::Column => inner.h,
1261        Axis::Overlay => return false,
1262    };
1263    let total_gap = parent.gap * n.saturating_sub(1) as f32;
1264    let consumed: f32 = parent
1265        .children
1266        .iter()
1267        .map(|c| {
1268            let r = ui_state.rect(&c.computed_id);
1269            match parent.axis {
1270                Axis::Row => r.w,
1271                Axis::Column => r.h,
1272                Axis::Overlay => 0.0,
1273            }
1274        })
1275        .sum();
1276    consumed + total_gap > inner_main + 0.5
1277}
1278
1279fn short_path(p: &str) -> String {
1280    let parts: Vec<&str> = p.split(['/', '\\']).collect();
1281    if parts.len() >= 2 {
1282        format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
1283    } else {
1284        p.to_string()
1285    }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290    use super::*;
1291
1292    fn lint_one(mut root: El) -> LintReport {
1293        let mut ui_state = UiState::new();
1294        layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1295        lint(&root, &ui_state)
1296    }
1297
1298    #[test]
1299    fn clipped_nowrap_text_reports_text_overflow() {
1300        let root = crate::text("A very long dashboard label")
1301            .width(Size::Fixed(42.0))
1302            .height(Size::Fixed(20.0));
1303
1304        let report = lint_one(root);
1305
1306        assert!(
1307            report
1308                .findings
1309                .iter()
1310                .any(|finding| finding.kind == FindingKind::TextOverflow),
1311            "{}",
1312            report.text()
1313        );
1314    }
1315
1316    #[test]
1317    fn ellipsis_nowrap_text_satisfies_horizontal_overflow_policy() {
1318        let root = crate::text("A very long dashboard label")
1319            .ellipsis()
1320            .width(Size::Fixed(42.0))
1321            .height(Size::Fixed(20.0));
1322
1323        let report = lint_one(root);
1324
1325        assert!(
1326            !report
1327                .findings
1328                .iter()
1329                .any(|finding| finding.kind == FindingKind::TextOverflow),
1330            "{}",
1331            report.text()
1332        );
1333    }
1334
1335    #[test]
1336    fn hug_ellipsis_in_overflowing_row_reports_dead_chain_issue_19() {
1337        // Repro for #19: a `text(...).ellipsis()` (default Hug width)
1338        // inside a flex row whose children's intrinsics sum past the
1339        // row's allocated width. `Size::Hug` makes the layout pass
1340        // resolve `main_size = intrinsic`, so the rect's width equals
1341        // the natural text width — and that's the budget passed to
1342        // `ellipsize_text_with_family`. The truncation branch never
1343        // trims a glyph and the chain is silent dead code. The lint
1344        // must point at the offending text node directly.
1345        let row = crate::row([
1346            crate::text("short_label"),
1347            crate::text("a long descriptive body that should truncate but cannot").ellipsis(),
1348            crate::text("right_side_metadata"),
1349        ])
1350        .width(Size::Fixed(160.0))
1351        .height(Size::Fixed(20.0));
1352
1353        let report = lint_one(row);
1354
1355        assert!(
1356            report
1357                .findings
1358                .iter()
1359                .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1360            "expected dead-ellipsis finding pointing at Hug text\n{}",
1361            report.text()
1362        );
1363    }
1364
1365    #[test]
1366    fn hug_ellipsis_in_non_overflowing_row_is_quiet() {
1367        // The lint targets the failure mode (parent overran + dead
1368        // chain), not the chain itself. When the row has room for all
1369        // children, `text(...).ellipsis()` with default Hug is just
1370        // harmless extra metadata — don't lint it.
1371        let row = crate::row([crate::text("ok").ellipsis()])
1372            .width(Size::Fixed(160.0))
1373            .height(Size::Fixed(20.0));
1374
1375        let report = lint_one(row);
1376
1377        assert!(
1378            !report
1379                .findings
1380                .iter()
1381                .any(|f| f.kind == FindingKind::TextOverflow),
1382            "{}",
1383            report.text()
1384        );
1385    }
1386
1387    #[test]
1388    fn fill_ellipsis_in_overflowing_row_is_quiet() {
1389        // Counter-test: when the user has chosen `Size::Fill(_)` on
1390        // the ellipsis text, the chain is live (layout actually
1391        // constrains the rect), so even if other children push the
1392        // row over, the dead-chain lint must not fire on this node.
1393        let row = crate::row([
1394            crate::text("short_label"),
1395            crate::text("a long descriptive body that should truncate but cannot")
1396                .width(Size::Fill(1.0))
1397                .ellipsis(),
1398            crate::text("right_side_metadata"),
1399        ])
1400        .width(Size::Fixed(160.0))
1401        .height(Size::Fixed(20.0));
1402
1403        let report = lint_one(row);
1404
1405        assert!(
1406            !report
1407                .findings
1408                .iter()
1409                .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1410            "{}",
1411            report.text()
1412        );
1413    }
1414
1415    #[test]
1416    fn padding_eats_fixed_height_button_reports_padding_advice() {
1417        // `.padding(scalar)` goes through `From<f32> for Sides` as
1418        // `Sides::all(scalar)` — so on a 30px-tall button with
1419        // `.padding(SPACE_2)` the vertical padding totals 16, leaving
1420        // only 14px of inner height for a 20px Label cell. The
1421        // v-center step clamps the negative slack to 0 and the text
1422        // paints into the padding band (visibly bottom-leaning, in
1423        // this case 8px above + 2px below). Message must blame the
1424        // padding (or the height override), not recommend
1425        // `paragraph()` / `wrap_text()` / a wider box.
1426        let root = crate::row([crate::button("Resume")
1427            .height(Size::Fixed(30.0))
1428            .padding(crate::tokens::SPACE_2)]);
1429
1430        let report = lint_one(root);
1431
1432        let finding = report
1433            .findings
1434            .iter()
1435            .find(|f| f.kind == FindingKind::Overflow)
1436            .unwrap_or_else(|| {
1437                panic!(
1438                    "expected an Overflow finding for the padding-eats-height shape\n{}",
1439                    report.text()
1440                )
1441            });
1442        assert!(
1443            finding.message.contains("vertical padding") && finding.message.contains("Sides::xy"),
1444            "expected padding-y advice, got:\n{}\n{}",
1445            finding.message,
1446            report.text(),
1447        );
1448        assert!(
1449            !finding.message.contains("paragraph()") && !finding.message.contains("wrap_text()"),
1450            "padding-eats-height case should not recommend paragraph/wrap_text:\n{}",
1451            finding.message,
1452        );
1453    }
1454
1455    #[test]
1456    fn padding_eats_fixed_height_y_only_does_not_fire_when_height_is_hug() {
1457        // Counter-case: with `Size::Hug` the box grows to fit; padding
1458        // can't "eat" a hugged height so there's no off-center symptom.
1459        // Don't pin the user to a non-issue.
1460        let root = crate::row([crate::text("Resume").padding(crate::tokens::SPACE_2)]);
1461
1462        let report = lint_one(root);
1463
1464        assert!(
1465            !report
1466                .findings
1467                .iter()
1468                .any(|f| f.kind == FindingKind::Overflow || f.kind == FindingKind::TextOverflow),
1469            "{}",
1470            report.text()
1471        );
1472    }
1473
1474    #[test]
1475    fn text_taller_than_fixed_height_without_padding_reports_height_advice() {
1476        // Different shape: no padding-y, but the text cell itself is
1477        // taller than the box (e.g. body text size in a too-short
1478        // chip). The fix is the height (or text size), not the
1479        // padding. Make sure the lint message reflects that.
1480        let root = crate::row([crate::text("body")
1481            .width(Size::Fixed(80.0))
1482            .height(Size::Fixed(12.0))]);
1483
1484        let report = lint_one(root);
1485
1486        let finding = report
1487            .findings
1488            .iter()
1489            .find(|f| f.kind == FindingKind::Overflow)
1490            .unwrap_or_else(|| {
1491                panic!(
1492                    "expected an Overflow finding for text-taller-than-box\n{}",
1493                    report.text()
1494                )
1495            });
1496        assert!(
1497            finding.message.contains("exceeds box height") && finding.message.contains("height"),
1498            "expected height-advice message, got:\n{}",
1499            finding.message,
1500        );
1501        assert!(
1502            !finding.message.contains("vertical padding"),
1503            "no-padding case should not blame padding:\n{}",
1504            finding.message,
1505        );
1506    }
1507
1508    #[test]
1509    fn padding_aware_text_overflow_fires_when_text_spills_past_padded_region() {
1510        // Box is wide enough for the bare text (66 ≤ 80) but padding
1511        // eats so much that the text spills past the padded content
1512        // area (66 > 80 - 40). Centered text in this state visually
1513        // reads as off-center — the lint must flag it even though the
1514        // text would technically fit inside the outer rect.
1515        //
1516        // Wrap in a row so the inner Fixed(80) is honored; the layout
1517        // pass forces the root rect to the viewport regardless of its
1518        // own size, so a single-node test would mis-measure.
1519        let leaf = crate::text("dashboard")
1520            .width(Size::Fixed(80.0))
1521            .height(Size::Fixed(28.0))
1522            .padding(Sides::xy(20.0, 0.0));
1523        let root = crate::row([leaf]);
1524
1525        let report = lint_one(root);
1526
1527        assert!(
1528            report
1529                .findings
1530                .iter()
1531                .any(|finding| finding.kind == FindingKind::TextOverflow),
1532            "{}",
1533            report.text()
1534        );
1535    }
1536
1537    #[test]
1538    fn stretch_row_with_top_pinned_icon_and_text_suggests_center_alignment() {
1539        let root = crate::row([
1540            crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1541            crate::text("Settings").width(Size::Fill(1.0)),
1542        ])
1543        .height(Size::Fixed(36.0));
1544
1545        let report = lint_one(root);
1546
1547        assert!(
1548            report
1549                .findings
1550                .iter()
1551                .any(|finding| finding.kind == FindingKind::Alignment
1552                    && finding.message.contains(".align(Align::Center)")),
1553            "{}",
1554            report.text()
1555        );
1556    }
1557
1558    #[test]
1559    fn centered_row_with_icon_and_text_satisfies_alignment_policy() {
1560        let root = crate::row([
1561            crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1562            crate::text("Settings").width(Size::Fill(1.0)),
1563        ])
1564        .height(Size::Fixed(36.0))
1565        .align(Align::Center);
1566
1567        let report = lint_one(root);
1568
1569        assert!(
1570            !report
1571                .findings
1572                .iter()
1573                .any(|finding| finding.kind == FindingKind::Alignment),
1574            "{}",
1575            report.text()
1576        );
1577    }
1578
1579    #[test]
1580    fn row_with_icon_slot_touching_text_reports_spacing() {
1581        let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1582            .align(Align::Center)
1583            .justify(Justify::Center)
1584            .fill(crate::tokens::MUTED)
1585            .width(Size::Fixed(26.0))
1586            .height(Size::Fixed(26.0));
1587        let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1588            .height(Size::Fixed(32.0))
1589            .align(Align::Center);
1590
1591        let report = lint_one(root);
1592
1593        assert!(
1594            report
1595                .findings
1596                .iter()
1597                .any(|finding| finding.kind == FindingKind::Spacing
1598                    && finding.message.contains(".gap(tokens::SPACE_2)")),
1599            "{}",
1600            report.text()
1601        );
1602    }
1603
1604    #[test]
1605    fn row_with_icon_slot_and_text_gap_satisfies_spacing_policy() {
1606        let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1607            .align(Align::Center)
1608            .justify(Justify::Center)
1609            .fill(crate::tokens::MUTED)
1610            .width(Size::Fixed(26.0))
1611            .height(Size::Fixed(26.0));
1612        let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1613            .height(Size::Fixed(32.0))
1614            .align(Align::Center)
1615            .gap(crate::tokens::SPACE_2);
1616
1617        let report = lint_one(root);
1618
1619        assert!(
1620            !report
1621                .findings
1622                .iter()
1623                .any(|finding| finding.kind == FindingKind::Spacing),
1624            "{}",
1625            report.text()
1626        );
1627    }
1628
1629    #[test]
1630    fn overlay_with_top_left_pinned_icon_suggests_center_alignment() {
1631        let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1632            .fill(crate::tokens::MUTED)
1633            .width(Size::Fixed(26.0))
1634            .height(Size::Fixed(26.0));
1635        let root = crate::column([icon_slot]);
1636
1637        let report = lint_one(root);
1638
1639        assert!(
1640            report
1641                .findings
1642                .iter()
1643                .any(|finding| finding.kind == FindingKind::Alignment
1644                    && finding.message.contains(".justify(Justify::Center)")),
1645            "{}",
1646            report.text()
1647        );
1648    }
1649
1650    #[test]
1651    fn centered_overlay_icon_satisfies_alignment_policy() {
1652        let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1653            .align(Align::Center)
1654            .justify(Justify::Center)
1655            .fill(crate::tokens::MUTED)
1656            .width(Size::Fixed(26.0))
1657            .height(Size::Fixed(26.0));
1658        let root = crate::column([icon_slot]);
1659
1660        let report = lint_one(root);
1661
1662        assert!(
1663            !report
1664                .findings
1665                .iter()
1666                .any(|finding| finding.kind == FindingKind::Alignment),
1667            "{}",
1668            report.text()
1669        );
1670    }
1671
1672    #[test]
1673    fn overflow_findings_attribute_to_nearest_user_source_ancestor() {
1674        // Closure-built-widget shape: an Element constructed inside an
1675        // aetna widget closure carries `from_library: true`. Its
1676        // overflow finding should attribute to the nearest non-library
1677        // ancestor's source.
1678        let user_source = Source {
1679            file: "src/screen.rs",
1680            line: 42,
1681            from_library: false,
1682        };
1683        let widget_source = Source {
1684            file: "src/widgets/tabs.rs",
1685            line: 200,
1686            from_library: true,
1687        };
1688
1689        let mut leaf = crate::text("A very long dashboard label")
1690            .width(Size::Fixed(40.0))
1691            .height(Size::Fixed(20.0));
1692        leaf.source = widget_source;
1693
1694        let mut root = crate::row([leaf])
1695            .width(Size::Fixed(160.0))
1696            .height(Size::Fixed(48.0));
1697        root.source = user_source;
1698
1699        let mut ui_state = UiState::new();
1700        layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1701        let report = lint(&root, &ui_state);
1702
1703        let text_overflow = report
1704            .findings
1705            .iter()
1706            .find(|f| f.kind == FindingKind::TextOverflow)
1707            .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1708        assert_eq!(text_overflow.source.file, user_source.file);
1709        assert_eq!(text_overflow.source.line, user_source.line);
1710    }
1711
1712    #[test]
1713    fn overflow_finding_self_attributes_when_node_is_already_user_source() {
1714        let mut node = crate::text("A very long dashboard label")
1715            .width(Size::Fixed(40.0))
1716            .height(Size::Fixed(20.0));
1717        let user_source = Source {
1718            file: "src/screen.rs",
1719            line: 99,
1720            from_library: false,
1721        };
1722        node.source = user_source;
1723
1724        let mut ui_state = UiState::new();
1725        layout::layout(&mut node, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1726        let report = lint(&node, &ui_state);
1727
1728        let text_overflow = report
1729            .findings
1730            .iter()
1731            .find(|f| f.kind == FindingKind::TextOverflow)
1732            .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1733        assert_eq!(text_overflow.source.line, user_source.line);
1734    }
1735
1736    #[test]
1737    fn overflow_lint_fires_for_external_app_paths_issue_13() {
1738        // Regression for #13: an external app's `Location::caller()`
1739        // file paths look like `src/sidebar.rs` (relative to its own
1740        // manifest), not `crates/<name>/src/...`. The old marker-
1741        // substring filter silently dropped every overflow finding for
1742        // these. With `from_library: false` (the user-code default),
1743        // the overflow must fire.
1744        let user_source = Source {
1745            file: "src/sidebar.rs",
1746            line: 17,
1747            from_library: false,
1748        };
1749        let mut child = crate::column(Vec::<El>::new())
1750            .width(Size::Fixed(32.0))
1751            .height(Size::Fixed(32.0));
1752        child.source = user_source;
1753
1754        let mut row = crate::row([child])
1755            .width(Size::Fixed(256.0))
1756            .height(Size::Fixed(28.0));
1757        row.source = user_source;
1758
1759        let mut ui_state = UiState::new();
1760        layout::layout(&mut row, &mut ui_state, Rect::new(0.0, 0.0, 256.0, 28.0));
1761        let report = lint(&row, &ui_state);
1762
1763        assert!(
1764            report
1765                .findings
1766                .iter()
1767                .any(|f| f.kind == FindingKind::Overflow),
1768            "expected an Overflow finding for the 32px child in a 28px row\n{}",
1769            report.text()
1770        );
1771    }
1772
1773    #[test]
1774    fn overflow_finding_suppressed_when_no_user_ancestor_exists() {
1775        // Pure-library tree: every node carries `from_library: true`,
1776        // so there's no user code to blame and the finding is dropped.
1777        let widget_source = Source {
1778            file: "src/widgets/tabs.rs",
1779            line: 200,
1780            from_library: true,
1781        };
1782        let mut leaf = crate::text("A very long dashboard label")
1783            .width(Size::Fixed(40.0))
1784            .height(Size::Fixed(20.0));
1785        leaf.source = widget_source;
1786
1787        let mut wrapper = crate::row([leaf])
1788            .width(Size::Fixed(160.0))
1789            .height(Size::Fixed(48.0));
1790        wrapper.source = widget_source;
1791
1792        let mut ui_state = UiState::new();
1793        layout::layout(
1794            &mut wrapper,
1795            &mut ui_state,
1796            Rect::new(0.0, 0.0, 160.0, 48.0),
1797        );
1798        let report = lint(&wrapper, &ui_state);
1799
1800        assert!(
1801            !report
1802                .findings
1803                .iter()
1804                .any(|f| f.kind == FindingKind::TextOverflow || f.kind == FindingKind::Overflow),
1805            "{}",
1806            report.text()
1807        );
1808    }
1809
1810    #[test]
1811    fn panel_role_without_fill_reports_missing_surface_fill() {
1812        let root = crate::column([crate::text("body")])
1813            .surface_role(SurfaceRole::Panel)
1814            .width(Size::Fixed(120.0))
1815            .height(Size::Fixed(40.0));
1816
1817        let report = lint_one(root);
1818
1819        assert!(
1820            report
1821                .findings
1822                .iter()
1823                .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1824            "{}",
1825            report.text()
1826        );
1827    }
1828
1829    #[test]
1830    fn panel_role_with_fill_satisfies_surface_policy() {
1831        let root = crate::column([crate::text("body")])
1832            .surface_role(SurfaceRole::Panel)
1833            .fill(crate::tokens::CARD)
1834            .width(Size::Fixed(120.0))
1835            .height(Size::Fixed(40.0));
1836
1837        let report = lint_one(root);
1838
1839        assert!(
1840            !report
1841                .findings
1842                .iter()
1843                .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1844            "{}",
1845            report.text()
1846        );
1847    }
1848
1849    #[test]
1850    fn card_widget_satisfies_surface_policy() {
1851        let root = crate::widgets::card::card([crate::text("body")])
1852            .width(Size::Fixed(120.0))
1853            .height(Size::Fixed(40.0));
1854
1855        let report = lint_one(root);
1856
1857        assert!(
1858            !report
1859                .findings
1860                .iter()
1861                .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1862            "{}",
1863            report.text()
1864        );
1865    }
1866
1867    #[test]
1868    fn handrolled_card_recipe_reports_reinvented_widget() {
1869        // column().fill(CARD).stroke(BORDER).radius(>0) is the canonical
1870        // hand-rolled card silhouette.
1871        let root = crate::column([crate::text("body")])
1872            .fill(crate::tokens::CARD)
1873            .stroke(crate::tokens::BORDER)
1874            .radius(crate::tokens::RADIUS_LG)
1875            .width(Size::Fixed(160.0))
1876            .height(Size::Fixed(48.0));
1877
1878        let report = lint_one(root);
1879
1880        assert!(
1881            report
1882                .findings
1883                .iter()
1884                .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("card(")),
1885            "{}",
1886            report.text()
1887        );
1888    }
1889
1890    #[test]
1891    fn real_card_widget_does_not_report_reinvented_widget() {
1892        // card() returns Kind::Card, so the smell signature (which
1893        // requires Kind::Group) excludes it by construction.
1894        let root = crate::widgets::card::card([crate::text("body")])
1895            .width(Size::Fixed(160.0))
1896            .height(Size::Fixed(48.0));
1897
1898        let report = lint_one(root);
1899
1900        assert!(
1901            !report
1902                .findings
1903                .iter()
1904                .any(|f| f.kind == FindingKind::ReinventedWidget),
1905            "{}",
1906            report.text()
1907        );
1908    }
1909
1910    #[test]
1911    fn handrolled_sidebar_recipe_reports_reinvented_widget() {
1912        // column().fill(CARD).stroke(BORDER).width(SIDEBAR_WIDTH) without
1913        // surface_role(Panel) is the volumetric_ui_v2 sidebar pattern.
1914        let root = crate::column([crate::text("nav")])
1915            .fill(crate::tokens::CARD)
1916            .stroke(crate::tokens::BORDER)
1917            .width(Size::Fixed(crate::tokens::SIDEBAR_WIDTH))
1918            .height(Size::Fill(1.0));
1919
1920        let report = lint_one(root);
1921
1922        assert!(
1923            report
1924                .findings
1925                .iter()
1926                .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("sidebar(")),
1927            "{}",
1928            report.text()
1929        );
1930    }
1931
1932    #[test]
1933    fn real_sidebar_widget_does_not_report_reinvented_widget() {
1934        // sidebar() sets surface_role(Panel), which excludes it from the
1935        // smell signature even though its fill+stroke+width match.
1936        let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
1937
1938        let report = lint_one(root);
1939
1940        assert!(
1941            !report
1942                .findings
1943                .iter()
1944                .any(|f| f.kind == FindingKind::ReinventedWidget),
1945            "{}",
1946            report.text()
1947        );
1948    }
1949
1950    #[test]
1951    fn empty_visual_swatch_does_not_report_reinvented_widget() {
1952        // A childless Group styled with CARD/BORDER is a color sample,
1953        // not a card-mimic. Card-mimics always wrap content; pure
1954        // decorative boxes shouldn't trip the lint.
1955        let root = crate::column(Vec::<El>::new())
1956            .fill(crate::tokens::CARD)
1957            .stroke(crate::tokens::BORDER)
1958            .radius(crate::tokens::RADIUS_SM)
1959            .width(Size::Fixed(42.0))
1960            .height(Size::Fixed(34.0));
1961
1962        let report = lint_one(root);
1963
1964        assert!(
1965            !report
1966                .findings
1967                .iter()
1968                .any(|f| f.kind == FindingKind::ReinventedWidget),
1969            "{}",
1970            report.text()
1971        );
1972    }
1973
1974    #[test]
1975    fn plain_column_does_not_report_reinvented_widget() {
1976        // A normal column with no surface decoration is fine.
1977        let root = crate::column([crate::text("a"), crate::text("b")])
1978            .gap(crate::tokens::SPACE_2)
1979            .width(Size::Fixed(120.0))
1980            .height(Size::Fixed(40.0));
1981
1982        let report = lint_one(root);
1983
1984        assert!(
1985            !report
1986                .findings
1987                .iter()
1988                .any(|f| f.kind == FindingKind::ReinventedWidget),
1989            "{}",
1990            report.text()
1991        );
1992    }
1993
1994    #[test]
1995    fn fill_providing_roles_do_not_require_explicit_fill() {
1996        // Sunken paints palette MUTED.darken(0.08) by default — no
1997        // explicit fill needed. Same shape applies to Selected /
1998        // Current / Input / Danger; covering Sunken here as a
1999        // representative.
2000        let root = crate::column([crate::text("body")])
2001            .surface_role(SurfaceRole::Sunken)
2002            .width(Size::Fixed(120.0))
2003            .height(Size::Fixed(40.0));
2004
2005        let report = lint_one(root);
2006
2007        assert!(
2008            !report
2009                .findings
2010                .iter()
2011                .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2012            "{}",
2013            report.text()
2014        );
2015    }
2016
2017    #[test]
2018    fn focus_ring_lint_fires_when_input_clipped_on_scroll_cross_axis() {
2019        // The original bug: a focusable text input flush at the left
2020        // edge of a vertical-scroll viewport gets its ring scissored.
2021        let selection = crate::selection::Selection::default();
2022        let mut root = crate::tree::scroll([crate::tree::column([
2023            crate::widgets::text_input::text_input("", &selection, "field"),
2024        ])])
2025        .width(Size::Fixed(300.0))
2026        .height(Size::Fixed(120.0));
2027        let mut state = UiState::new();
2028        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2029        let report = lint(&root, &state);
2030
2031        assert!(
2032            report.findings.iter().any(|f| {
2033                f.kind == FindingKind::FocusRingObscured
2034                    && f.message.contains("clipped")
2035                    && (f.message.contains("L=2") || f.message.contains("R=2"))
2036            }),
2037            "expected a FocusRingObscured clipping finding (L=2 or R=2)\n{}",
2038            report.text()
2039        );
2040    }
2041
2042    #[test]
2043    fn focus_ring_lint_silenced_when_scroll_supplies_horizontal_slack() {
2044        // Same shape, but the scroll's content is wrapped so the input
2045        // sits inset by RING_WIDTH on each horizontal edge. No finding.
2046        let selection = crate::selection::Selection::default();
2047        let mut root =
2048            crate::tree::scroll(
2049                [crate::tree::column([crate::widgets::text_input::text_input(
2050                    "", &selection, "field",
2051                )])
2052                .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))],
2053            )
2054            .width(Size::Fixed(300.0))
2055            .height(Size::Fixed(120.0));
2056        let mut state = UiState::new();
2057        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2058        let report = lint(&root, &state);
2059
2060        assert!(
2061            !report
2062                .findings
2063                .iter()
2064                .any(|f| f.kind == FindingKind::FocusRingObscured),
2065            "{}",
2066            report.text()
2067        );
2068    }
2069
2070    #[test]
2071    fn focus_ring_lint_skips_clipping_on_scroll_axis() {
2072        // Tall content that runs past a vertical scroll's bottom edge
2073        // is fine — auto-scroll-on-focus brings the focused row into
2074        // view. The lint must not fire on the scroll axis.
2075        let selection = crate::selection::Selection::default();
2076        let mut root = crate::tree::scroll([crate::tree::column([
2077            // Big top filler so the input lands well below the viewport.
2078            crate::tree::column(Vec::<El>::new())
2079                .width(Size::Fill(1.0))
2080                .height(Size::Fixed(200.0)),
2081            crate::widgets::text_input::text_input("", &selection, "field"),
2082        ])
2083        .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))])
2084        .width(Size::Fixed(300.0))
2085        .height(Size::Fixed(120.0));
2086        let mut state = UiState::new();
2087        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2088        let report = lint(&root, &state);
2089
2090        assert!(
2091            !report
2092                .findings
2093                .iter()
2094                .any(|f| f.kind == FindingKind::FocusRingObscured),
2095            "expected no FocusRingObscured finding for a row clipped on the scroll axis\n{}",
2096            report.text()
2097        );
2098    }
2099
2100    #[test]
2101    fn focus_ring_lint_fires_on_static_clip_in_any_direction() {
2102        // A non-scrolling clipping container (an ordinary clipped card)
2103        // doesn't auto-reveal anything, so all four sides count.
2104        let selection = crate::selection::Selection::default();
2105        let mut root = crate::tree::column([crate::widgets::text_input::text_input(
2106            "", &selection, "field",
2107        )])
2108        .clip()
2109        .width(Size::Fixed(300.0))
2110        .height(Size::Fixed(120.0));
2111        let mut state = UiState::new();
2112        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2113        let report = lint(&root, &state);
2114
2115        assert!(
2116            report.findings.iter().any(|f| {
2117                f.kind == FindingKind::FocusRingObscured && f.message.contains("clipped")
2118            }),
2119            "expected a static-clip FocusRingObscured finding\n{}",
2120            report.text()
2121        );
2122    }
2123
2124    #[test]
2125    fn focus_ring_lint_fires_on_painted_later_sibling_overlap() {
2126        // Focusable on the left, a card-like sibling immediately to
2127        // the right at gap=0. The card paints fill+stroke, so the
2128        // focusable's right ring band gets occluded.
2129        let selection = crate::selection::Selection::default();
2130        let mut root = crate::tree::row([
2131            crate::widgets::text_input::text_input("", &selection, "field"),
2132            crate::tree::column([crate::text("neighbor")])
2133                .fill(crate::tokens::CARD)
2134                .stroke(crate::tokens::BORDER)
2135                .width(Size::Fixed(80.0))
2136                .height(Size::Fixed(32.0)),
2137        ])
2138        .gap(0.0)
2139        .width(Size::Fixed(400.0))
2140        .height(Size::Fixed(32.0));
2141        let mut state = UiState::new();
2142        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2143        let report = lint(&root, &state);
2144
2145        assert!(
2146            report.findings.iter().any(|f| {
2147                f.kind == FindingKind::FocusRingObscured
2148                    && f.message.contains("occluded")
2149                    && f.message.contains("right")
2150            }),
2151            "expected an occlusion finding on the right edge\n{}",
2152            report.text()
2153        );
2154    }
2155
2156    #[test]
2157    fn focus_ring_lint_ignores_unpainted_structural_sibling() {
2158        // A structural column with no fill/stroke/text shouldn't be
2159        // counted as an occluder — it draws no pixels.
2160        let selection = crate::selection::Selection::default();
2161        let mut root = crate::tree::row([
2162            crate::widgets::text_input::text_input("", &selection, "field"),
2163            crate::tree::column(Vec::<El>::new())
2164                .width(Size::Fixed(80.0))
2165                .height(Size::Fixed(32.0)),
2166        ])
2167        .gap(0.0)
2168        .width(Size::Fixed(400.0))
2169        .height(Size::Fixed(32.0));
2170        let mut state = UiState::new();
2171        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2172        let report = lint(&root, &state);
2173
2174        assert!(
2175            !report
2176                .findings
2177                .iter()
2178                .any(|f| f.kind == FindingKind::FocusRingObscured),
2179            "{}",
2180            report.text()
2181        );
2182    }
2183
2184    #[test]
2185    fn scrollbar_overlap_lint_fires_when_thumb_covers_fill_child() {
2186        // Repro from #21: padding *on* the scroll silences
2187        // FocusRingObscured but leaves the scrollbar thumb painting
2188        // on top of right-flush focusables.
2189        let body = crate::tree::column(
2190            (0..30)
2191                .map(|i| {
2192                    crate::tree::row([
2193                        crate::text(format!("Row {i}")),
2194                        crate::tree::spacer(),
2195                        crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2196                    ])
2197                    .gap(crate::tokens::SPACE_2)
2198                    .width(Size::Fill(1.0))
2199                })
2200                .collect::<Vec<_>>(),
2201        )
2202        .gap(crate::tokens::SPACE_2)
2203        .width(Size::Fill(1.0));
2204
2205        let mut root = crate::tree::scroll([body])
2206            .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2207            .width(Size::Fixed(480.0))
2208            .height(Size::Fixed(320.0));
2209        let mut state = UiState::new();
2210        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2211        let report = lint(&root, &state);
2212
2213        assert!(
2214            report
2215                .findings
2216                .iter()
2217                .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2218            "expected ScrollbarObscuresFocusable for a switch that reaches the scroll's inner.right()\n{}",
2219            report.text()
2220        );
2221    }
2222
2223    #[test]
2224    fn scrollbar_overlap_lint_silenced_when_padding_is_inside_scroll() {
2225        // The recommended fix: move horizontal padding onto a wrapper
2226        // *inside* the scroll. The scroll's own padding stays on the
2227        // y axis only; the wrapper inset clears the thumb gutter.
2228        let body = crate::tree::column(
2229            (0..30)
2230                .map(|i| {
2231                    crate::tree::row([
2232                        crate::text(format!("Row {i}")),
2233                        crate::tree::spacer(),
2234                        crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2235                    ])
2236                    .gap(crate::tokens::SPACE_2)
2237                    .width(Size::Fill(1.0))
2238                })
2239                .collect::<Vec<_>>(),
2240        )
2241        .gap(crate::tokens::SPACE_2)
2242        .width(Size::Fill(1.0));
2243
2244        let mut root = crate::tree::scroll([crate::tree::column([body])
2245            .padding(Sides::xy(crate::tokens::SPACE_3, 0.0))
2246            .width(Size::Fill(1.0))])
2247        .padding(Sides::xy(0.0, crate::tokens::SPACE_2))
2248        .width(Size::Fixed(480.0))
2249        .height(Size::Fixed(320.0));
2250        let mut state = UiState::new();
2251        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2252        let report = lint(&root, &state);
2253
2254        assert!(
2255            !report
2256                .findings
2257                .iter()
2258                .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2259            "expected no ScrollbarObscuresFocusable when padding is inside the scroll\n{}",
2260            report.text()
2261        );
2262    }
2263
2264    #[test]
2265    fn scrollbar_overlap_lint_quiet_when_content_does_not_overflow() {
2266        // A `scroll` with content shorter than its viewport doesn't
2267        // render a thumb, so the bug isn't user-visible. The lint
2268        // should match — thumb_tracks has no entry for the scroll, so
2269        // there's nothing to collide against.
2270        let body = crate::tree::column([crate::tree::row([
2271            crate::text("only row"),
2272            crate::tree::spacer(),
2273            crate::widgets::switch::switch(false).key("only-toggle"),
2274        ])
2275        .gap(crate::tokens::SPACE_2)
2276        .width(Size::Fill(1.0))])
2277        .gap(crate::tokens::SPACE_2)
2278        .width(Size::Fill(1.0));
2279
2280        let mut root = crate::tree::scroll([body])
2281            .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2282            .width(Size::Fixed(480.0))
2283            .height(Size::Fixed(320.0));
2284        let mut state = UiState::new();
2285        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2286        let report = lint(&root, &state);
2287
2288        assert!(
2289            !report
2290                .findings
2291                .iter()
2292                .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2293            "expected no ScrollbarObscuresFocusable when content fits in the viewport (no thumb rendered)\n{}",
2294            report.text()
2295        );
2296    }
2297
2298    #[test]
2299    fn unkeyed_tooltip_reports_dead_tooltip() {
2300        // Repro: a `.tooltip()` on a text leaf with no `.key()`.
2301        // Hit-test only returns keyed nodes, so hover never lands on
2302        // this leaf and the tooltip is silently dead. The classic
2303        // mistake on commit-graph row chrome (sha cells, timestamps,
2304        // chips, identicon avatars).
2305        let root = crate::text("abc1234").tooltip("commit sha");
2306
2307        let report = lint_one(root);
2308
2309        assert!(
2310            report
2311                .findings
2312                .iter()
2313                .any(|f| f.kind == FindingKind::DeadTooltip),
2314            "expected DeadTooltip on unkeyed tooltipped text\n{}",
2315            report.text()
2316        );
2317    }
2318
2319    #[test]
2320    fn keyed_tooltip_satisfies_dead_tooltip_policy() {
2321        // Counter-test: same shape, but the leaf has a key — so
2322        // hit-test does land here and the tooltip fires.
2323        let root = crate::text("abc1234").key("sha").tooltip("commit sha");
2324
2325        let report = lint_one(root);
2326
2327        assert!(
2328            !report
2329                .findings
2330                .iter()
2331                .any(|f| f.kind == FindingKind::DeadTooltip),
2332            "{}",
2333            report.text()
2334        );
2335    }
2336
2337    #[test]
2338    fn unkeyed_tooltip_inside_keyed_ancestor_still_reports_dead_tooltip() {
2339        // Even when an ancestor is keyed (so hover lands on the
2340        // ancestor), the leaf's tooltip text is on the leaf — and
2341        // tooltip lookup is by the hit target's `computed_id`, not
2342        // by walking ancestors. So the leaf's tooltip still never
2343        // fires. Flag it.
2344        let root =
2345            crate::row([crate::text("inner detail").tooltip("never shown")]).key("outer-row");
2346
2347        let report = lint_one(root);
2348
2349        assert!(
2350            report
2351                .findings
2352                .iter()
2353                .any(|f| f.kind == FindingKind::DeadTooltip),
2354            "expected DeadTooltip on unkeyed leaf even with keyed ancestor\n{}",
2355            report.text()
2356        );
2357    }
2358
2359    #[test]
2360    fn focus_ring_lint_is_quiet_inside_form_after_padding_fix() {
2361        // Regression: with form()'s default RING_WIDTH horizontal
2362        // padding, a text input flush inside a scroll/form chain
2363        // doesn't trip the clipping lint.
2364        let selection = crate::selection::Selection::default();
2365        let mut root = crate::tree::scroll([crate::widgets::form::form([
2366            crate::widgets::form::form_item([crate::widgets::form::form_control(
2367                crate::widgets::text_input::text_input("", &selection, "field"),
2368            )]),
2369        ])])
2370        .width(Size::Fixed(300.0))
2371        .height(Size::Fixed(120.0));
2372        let mut state = UiState::new();
2373        layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2374        let report = lint(&root, &state);
2375
2376        assert!(
2377            !report
2378                .findings
2379                .iter()
2380                .any(|f| f.kind == FindingKind::FocusRingObscured),
2381            "{}",
2382            report.text()
2383        );
2384    }
2385
2386    /// Like [`lint_one`] but runs the metrics pass first, so canonical
2387    /// recipes that depend on auto-defaults (card_header corner
2388    /// inheritance, control heights, etc.) reach lint in their settled
2389    /// shape.
2390    fn lint_one_with_metrics(mut root: El) -> LintReport {
2391        crate::metrics::ThemeMetrics::default().apply_to_tree(&mut root);
2392        let mut ui_state = UiState::new();
2393        layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 120.0));
2394        lint(&root, &ui_state)
2395    }
2396
2397    #[test]
2398    fn handrolled_rounded_container_with_flat_filled_header_reports_corner_stackup() {
2399        // The hand-rolled equivalent of `card([card_header(...).fill(MUTED), ...])`.
2400        // Metrics-pass corner inheritance doesn't apply here (no
2401        // MetricsRole::Card on the parent), so the lint must fire.
2402        let parent = crate::column([
2403            crate::row([crate::text("Header")])
2404                .fill(crate::tokens::MUTED)
2405                .width(Size::Fill(1.0))
2406                .height(Size::Fixed(24.0)),
2407            crate::row([crate::text("Body")])
2408                .width(Size::Fill(1.0))
2409                .height(Size::Fixed(60.0)),
2410        ])
2411        .fill(crate::tokens::CARD)
2412        .stroke(crate::tokens::BORDER)
2413        .radius(crate::tokens::RADIUS_LG)
2414        .width(Size::Fixed(160.0))
2415        .height(Size::Fixed(96.0));
2416
2417        let report = lint_one(parent);
2418
2419        let found = report
2420            .findings
2421            .iter()
2422            .find(|f| f.kind == FindingKind::CornerStackup);
2423        let found =
2424            found.unwrap_or_else(|| panic!("expected CornerStackup, got:\n{}", report.text()));
2425        assert!(
2426            found.message.contains("Corners::top"),
2427            "top-strip leak should suggest Corners::top, got: {}",
2428            found.message
2429        );
2430    }
2431
2432    #[test]
2433    fn handrolled_rounded_container_with_inset_child_does_not_report_corner_stackup() {
2434        // Parent has padding; the child is inset from the curve area.
2435        let parent = crate::column([crate::row([crate::text("Header")])
2436            .fill(crate::tokens::MUTED)
2437            .width(Size::Fill(1.0))
2438            .height(Size::Fixed(24.0))])
2439        .fill(crate::tokens::CARD)
2440        .stroke(crate::tokens::BORDER)
2441        .radius(crate::tokens::RADIUS_LG)
2442        .padding(Sides::all(crate::tokens::RADIUS_LG))
2443        .width(Size::Fixed(160.0))
2444        .height(Size::Fixed(96.0));
2445
2446        let report = lint_one(parent);
2447        assert!(
2448            !report
2449                .findings
2450                .iter()
2451                .any(|f| f.kind == FindingKind::CornerStackup),
2452            "inset child should not trip the lint, got:\n{}",
2453            report.text()
2454        );
2455    }
2456
2457    #[test]
2458    fn handrolled_rounded_container_with_matching_corners_does_not_report_corner_stackup() {
2459        let parent = crate::column([crate::row([crate::text("Header")])
2460            .fill(crate::tokens::MUTED)
2461            .radius(Corners::top(crate::tokens::RADIUS_LG))
2462            .width(Size::Fill(1.0))
2463            .height(Size::Fixed(24.0))])
2464        .fill(crate::tokens::CARD)
2465        .stroke(crate::tokens::BORDER)
2466        .radius(crate::tokens::RADIUS_LG)
2467        .width(Size::Fixed(160.0))
2468        .height(Size::Fixed(96.0));
2469
2470        let report = lint_one(parent);
2471        assert!(
2472            !report
2473                .findings
2474                .iter()
2475                .any(|f| f.kind == FindingKind::CornerStackup),
2476            "matching corners should not trip the lint, got:\n{}",
2477            report.text()
2478        );
2479    }
2480
2481    #[test]
2482    fn canonical_card_recipe_does_not_report_corner_stackup_after_metrics() {
2483        // A + B together: the canonical recipe lands in lint with
2484        // corners already stamped, so the lint stays quiet.
2485        let root = crate::widgets::card::card([
2486            crate::widgets::card::card_header([crate::text("Header")]).fill(crate::tokens::MUTED),
2487            crate::widgets::card::card_content([crate::text("Body")]),
2488        ])
2489        .width(Size::Fixed(180.0))
2490        .height(Size::Fixed(110.0));
2491
2492        let report = lint_one_with_metrics(root);
2493        assert!(
2494            !report
2495                .findings
2496                .iter()
2497                .any(|f| f.kind == FindingKind::CornerStackup),
2498            "canonical card_header(...).fill(...) recipe should be quiet after metrics pass, got:\n{}",
2499            report.text()
2500        );
2501    }
2502
2503    #[test]
2504    fn bare_card_with_flush_content_reports_unpadded_surface_panel_issue_24() {
2505        // Repro for #24: `card([...])` with children that carry their
2506        // own width/gap config and no slot wrappers and no
2507        // `.padding(...)` on the card. The row's rect is flush against
2508        // the card's top stroke (and L/R via Size::Fill(1.0)).
2509        let root = crate::widgets::card::card([crate::row([
2510            crate::text("some title").bold(),
2511            crate::text("description line").muted(),
2512        ])
2513        .gap(crate::tokens::SPACE_2)
2514        .width(Size::Fill(1.0))])
2515        .width(Size::Fixed(200.0))
2516        .height(Size::Fixed(80.0));
2517
2518        let report = lint_one(root);
2519        let f = report
2520            .findings
2521            .iter()
2522            .find(|f| f.kind == FindingKind::UnpaddedSurfacePanel)
2523            .unwrap_or_else(|| {
2524                panic!(
2525                    "expected UnpaddedSurfacePanel finding, got:\n{}",
2526                    report.text()
2527                )
2528            });
2529        assert!(
2530            f.message.contains("top"),
2531            "expected the flushing-side list to call out `top`, got: {}",
2532            f.message
2533        );
2534    }
2535
2536    #[test]
2537    fn card_with_explicit_padding_does_not_report_unpadded_surface_panel() {
2538        // The "dense list-row card" fix from the issue: pad the card
2539        // itself (the bare slot recipe's SPACE_6 feels too generous).
2540        let root = crate::widgets::card::card([
2541            crate::row([crate::text("title").bold()]).width(Size::Fill(1.0))
2542        ])
2543        .padding(Sides::all(crate::tokens::SPACE_4))
2544        .width(Size::Fixed(200.0))
2545        .height(Size::Fixed(60.0));
2546
2547        let report = lint_one(root);
2548        assert!(
2549            !report
2550                .findings
2551                .iter()
2552                .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2553            "{}",
2554            report.text()
2555        );
2556    }
2557
2558    #[test]
2559    fn canonical_card_anatomy_does_not_report_unpadded_surface_panel() {
2560        // header pads top/left/right at SPACE_6; footer pads
2561        // bottom/left/right at SPACE_6. Every panel edge is covered
2562        // by a touching slot child with inward padding on that side.
2563        let root = crate::widgets::card::card([
2564            crate::widgets::card::card_header([crate::widgets::card::card_title("Header")]),
2565            crate::widgets::card::card_content([crate::text("Body")]),
2566            crate::widgets::card::card_footer([crate::text("footer")]),
2567        ])
2568        .width(Size::Fixed(220.0))
2569        .height(Size::Fixed(160.0));
2570
2571        let report = lint_one(root);
2572        assert!(
2573            !report
2574                .findings
2575                .iter()
2576                .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2577            "canonical slot anatomy should be quiet, got:\n{}",
2578            report.text()
2579        );
2580    }
2581
2582    #[test]
2583    fn sidebar_widget_does_not_report_unpadded_surface_panel() {
2584        // sidebar() carries default_padding(SPACE_4), so the panel
2585        // itself insets content from every edge.
2586        let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
2587
2588        let report = lint_one(root);
2589        assert!(
2590            !report
2591                .findings
2592                .iter()
2593                .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2594            "{}",
2595            report.text()
2596        );
2597    }
2598}