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