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