Skip to main content

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