use std::fmt::Write as _;
use crate::layout;
use crate::metrics::MetricsRole;
use crate::state::UiState;
use crate::tree::*;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Finding {
pub kind: FindingKind,
pub node_id: String,
pub source: Source,
pub message: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FindingKind {
RawColor,
Overflow,
TextOverflow,
DuplicateId,
Alignment,
Spacing,
MissingSurfaceFill,
ReinventedWidget,
FocusRingObscured,
ScrollbarObscuresFocusable,
HitOverflowCollision,
DeadTooltip,
CornerStackup,
UnpaddedSurfacePanel,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct LintReport {
pub findings: Vec<Finding>,
}
impl LintReport {
pub fn text(&self) -> String {
if self.findings.is_empty() {
return "no findings\n".to_string();
}
let mut s = String::new();
for f in &self.findings {
let _ = writeln!(
s,
"{kind:?} node={id} {source} :: {msg}",
kind = f.kind,
id = f.node_id,
source = if f.source.line == 0 {
"<no-source>".to_string()
} else {
format!("{}:{}", short_path(f.source.file), f.source.line)
},
msg = f.message,
);
}
s
}
}
pub fn lint(root: &El, ui_state: &UiState) -> LintReport {
let mut r = LintReport::default();
let mut seen_ids: std::collections::BTreeMap<String, usize> = Default::default();
walk(
root,
None,
None,
&ClipCtx::None,
ui_state,
&mut r,
&mut seen_ids,
);
for (id, n) in seen_ids {
if n > 1 {
r.findings.push(Finding {
kind: FindingKind::DuplicateId,
node_id: id.clone(),
source: Source::default(),
message: format!("{n} nodes share id {id}"),
});
}
}
r
}
fn is_from_user(source: Source) -> bool {
!source.from_library
}
#[derive(Clone)]
enum ClipCtx {
None,
Static(Rect),
Scrolling {
rect: Rect,
scroll_axis: Axis,
node_id: String,
},
}
fn walk(
n: &El,
parent_kind: Option<&Kind>,
parent_blame: Option<Source>,
nearest_clip: &ClipCtx,
ui_state: &UiState,
r: &mut LintReport,
seen: &mut std::collections::BTreeMap<String, usize>,
) {
*seen.entry(n.computed_id.clone()).or_default() += 1;
let computed = ui_state.rect(&n.computed_id);
let from_user_self = is_from_user(n.source);
let self_blame = if from_user_self {
Some(n.source)
} else {
parent_blame
};
let inside_inlines = matches!(parent_kind, Some(Kind::Inlines));
if from_user_self {
if let Some(c) = n.fill
&& c.token.is_none()
&& c.a > 0
{
r.findings.push(Finding {
kind: FindingKind::RawColor,
node_id: n.computed_id.clone(),
source: n.source,
message: format!(
"fill is a raw rgba({},{},{},{}) — use a token",
c.r, c.g, c.b, c.a
),
});
}
if let Some(c) = n.stroke
&& c.token.is_none()
&& c.a > 0
{
r.findings.push(Finding {
kind: FindingKind::RawColor,
node_id: n.computed_id.clone(),
source: n.source,
message: format!(
"stroke is a raw rgba({},{},{},{}) — use a token",
c.r, c.g, c.b, c.a
),
});
}
if let Some(c) = n.text_color
&& c.token.is_none()
&& c.a > 0
{
r.findings.push(Finding {
kind: FindingKind::RawColor,
node_id: n.computed_id.clone(),
source: n.source,
message: format!(
"text_color is a raw rgba({},{},{},{}) — use a token",
c.r, c.g, c.b, c.a
),
});
}
if n.tooltip.is_some() && n.key.is_none() {
r.findings.push(Finding {
kind: FindingKind::DeadTooltip,
node_id: n.computed_id.clone(),
source: n.source,
message: ".tooltip() on a node without .key() never fires — hit-test only \
returns keyed nodes, so hover skips past this leaf to the nearest \
keyed ancestor. Add .key(\"…\") on the same node that carries the \
tooltip; for info-only chrome inside list rows, a synthetic key \
like \"row:{idx}.<part>\" is enough."
.to_string(),
});
}
if n.fill.is_none() && matches!(n.surface_role, SurfaceRole::Panel) {
r.findings.push(Finding {
kind: FindingKind::MissingSurfaceFill,
node_id: n.computed_id.clone(),
source: n.source,
message:
"surface_role(Panel) without a fill paints only stroke + shadow — \
wrap in card() / sidebar() / dialog() for the canonical recipe, or set .fill(tokens::CARD)"
.to_string(),
});
}
if matches!(n.surface_role, SurfaceRole::Panel) {
check_unpadded_surface_panel(n, computed, ui_state, r, n.source);
}
if matches!(n.kind, Kind::Group) && !n.children.is_empty() {
let card_fill = n
.fill
.as_ref()
.and_then(|c| c.token)
.is_some_and(|t| t == "card");
let border_stroke = n
.stroke
.as_ref()
.and_then(|c| c.token)
.is_some_and(|t| t == "border");
if card_fill && border_stroke {
let is_panel_surface = matches!(n.surface_role, SurfaceRole::Panel);
let sidebar_width = matches!(n.width, Size::Fixed(w) if (w - crate::tokens::SIDEBAR_WIDTH).abs() < 0.5);
if !is_panel_surface {
if sidebar_width {
r.findings.push(Finding {
kind: FindingKind::ReinventedWidget,
node_id: n.computed_id.clone(),
source: n.source,
message:
"Group with fill=CARD, stroke=BORDER, width=SIDEBAR_WIDTH reinvents sidebar() — \
use sidebar([sidebar_header(...), sidebar_group([sidebar_menu([sidebar_menu_button(label, current)])])]) \
for the panel surface and the canonical row recipe"
.to_string(),
});
} else {
r.findings.push(Finding {
kind: FindingKind::ReinventedWidget,
node_id: n.computed_id.clone(),
source: n.source,
message:
"Group with fill=CARD, stroke=BORDER reinvents the panel-surface recipe — \
use card([card_header([card_title(\"...\")]), card_content([...])]) / titled_card(\"Title\", [...]) for boxed content, \
or sidebar([...]) for a full-height nav/inspector pane (sidebar() also handles the custom-width case via .width(Size::Fixed(...)))"
.to_string(),
});
}
}
}
}
}
if let Some(blame) = self_blame {
lint_row_alignment(n, computed, ui_state, r, blame);
lint_overlay_alignment(n, computed, ui_state, r, blame);
lint_row_visual_text_spacing(n, ui_state, r, blame);
}
if n.text.is_some()
&& !inside_inlines
&& let Some(blame) = self_blame
{
let available_width = match n.text_wrap {
TextWrap::NoWrap => None,
TextWrap::Wrap => Some(computed.w),
};
if let Some(text_layout) = layout::text_layout(n, available_width) {
let text_w = text_layout.width + n.padding.left + n.padding.right;
let text_h = text_layout.height + n.padding.top + n.padding.bottom;
let raw_overflow_x = (text_w - computed.w).max(0.0);
let overflow_x = if matches!(
(n.text_wrap, n.text_overflow),
(TextWrap::NoWrap, TextOverflow::Ellipsis)
) {
0.0
} else {
raw_overflow_x
};
let overflow_y = (text_h - computed.h).max(0.0);
if overflow_x > 0.5 || overflow_y > 0.5 {
let is_clipped_nowrap = overflow_x > 0.5
&& matches!(
(n.text_wrap, n.text_overflow),
(TextWrap::NoWrap, TextOverflow::Clip)
);
let kind = if is_clipped_nowrap {
FindingKind::TextOverflow
} else {
FindingKind::Overflow
};
let pad_y = n.padding.top + n.padding.bottom;
let height_is_fixed = matches!(n.height, Size::Fixed(_));
let text_alone_fits_height = text_layout.height <= computed.h + 0.5;
let padding_eats_fixed_height = overflow_y > 0.5
&& overflow_x <= 0.5
&& pad_y > 0.0
&& text_alone_fits_height
&& height_is_fixed;
let cell_h = text_layout.height;
let box_h = computed.h;
let message = if kind == FindingKind::TextOverflow {
format!(
"nowrap text exceeds its box by X={overflow_x:.0}; use .ellipsis(), wrap_text(), or a wider box"
)
} else if padding_eats_fixed_height {
let inner_h = (box_h - pad_y).max(0.0);
let pad_x_token = if (n.padding.left - n.padding.right).abs() < 0.5 {
format!("{:.0}", n.padding.left)
} else {
"...".to_string()
};
let control_h = crate::tokens::CONTROL_HEIGHT;
format!(
"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) — \
the label can't vertically center and paints into the padding band, off-center by Y={overflow_y:.0}. \
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)"
)
} else if overflow_y > 0.5 && overflow_x <= 0.5 {
format!(
"text cell ({cell_h:.0}px) exceeds box height ({box_h:.0}px) by Y={overflow_y:.0}; \
increase height, reduce text size, or use paragraph()/wrap_text() with fewer lines"
)
} else {
format!(
"text content exceeds its box by X={overflow_x:.0} Y={overflow_y:.0}; use paragraph()/wrap_text(), a wider box, or explicit clipping"
)
};
r.findings.push(Finding {
kind,
node_id: n.computed_id.clone(),
source: blame,
message,
});
}
}
}
let suppress_overflow = n.scrollable
|| n.clip
|| matches!(n.kind, Kind::Inlines)
|| matches!(n.kind, Kind::Custom("toast_stack"));
let parent_main_overran =
!suppress_overflow && flex_main_axis_overflowed(n, computed, ui_state);
let child_clip = if n.clip {
if n.scrollable {
ClipCtx::Scrolling {
rect: computed,
scroll_axis: n.axis,
node_id: n.computed_id.clone(),
}
} else {
ClipCtx::Static(computed)
}
} else {
nearest_clip.clone()
};
if !matches!(n.axis, Axis::Overlay)
&& let Some(blame) = self_blame
{
lint_hit_overflow_collisions(n, &child_clip, ui_state, r, blame);
}
for (child_idx, c) in n.children.iter().enumerate() {
let from_user_child = is_from_user(c.source);
let child_blame = if from_user_child {
Some(c.source)
} else {
self_blame
};
let c_rect = ui_state.rect(&c.computed_id);
if !suppress_overflow
&& !rect_contains(computed, c_rect, 0.5)
&& let Some(blame) = child_blame
{
let dx_left = (computed.x - c_rect.x).max(0.0);
let dx_right = (c_rect.right() - computed.right()).max(0.0);
let dy_top = (computed.y - c_rect.y).max(0.0);
let dy_bottom = (c_rect.bottom() - computed.bottom()).max(0.0);
r.findings.push(Finding {
kind: FindingKind::Overflow,
node_id: c.computed_id.clone(),
source: blame,
message: format!(
"child overflows parent {parent_id} by L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}",
parent_id = n.computed_id,
),
});
}
let main_axis_is_hug = match n.axis {
Axis::Row => matches!(c.width, Size::Hug),
Axis::Column => matches!(c.height, Size::Hug),
Axis::Overlay => false,
};
if parent_main_overran
&& main_axis_is_hug
&& c.text.is_some()
&& c.text_wrap == TextWrap::NoWrap
&& c.text_overflow == TextOverflow::Ellipsis
&& let Some(blame) = child_blame
{
r.findings.push(Finding {
kind: FindingKind::TextOverflow,
node_id: c.computed_id.clone(),
source: blame,
message:
".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."
.to_string(),
});
}
if from_user_child
&& c.fill.is_some()
&& n.radius.any_nonzero()
&& let Some(blame) = child_blame
{
check_corner_stackup(n, computed, c, c_rect, r, blame);
}
if from_user_child
&& c.focusable
&& let Some(blame) = child_blame
{
check_focus_ring_obscured(
c,
c_rect,
&child_clip,
&n.children[child_idx + 1..],
ui_state,
r,
blame,
);
check_scrollbar_overlap(c, c_rect, &child_clip, ui_state, r, blame);
}
walk(
c,
Some(&n.kind),
child_blame,
&child_clip,
ui_state,
r,
seen,
);
}
}
fn focus_ring_overflow(n: &El) -> Sides {
match n.focus_ring_placement {
crate::tree::FocusRingPlacement::Outside => Sides::all(crate::tokens::RING_WIDTH),
crate::tree::FocusRingPlacement::Inside => Sides::zero(),
}
}
fn has_hit_overflow(sides: Sides) -> bool {
sides.left > 0.5 || sides.right > 0.5 || sides.top > 0.5 || sides.bottom > 0.5
}
fn clip_rect(ctx: &ClipCtx) -> Option<Rect> {
match ctx {
ClipCtx::None => None,
ClipCtx::Static(rect) | ClipCtx::Scrolling { rect, .. } => Some(*rect),
}
}
fn clipped_rect(rect: Rect, ctx: &ClipCtx) -> Option<Rect> {
match clip_rect(ctx) {
Some(clip) => rect.intersect(clip),
None => Some(rect),
}
}
fn lint_hit_overflow_collisions(
parent: &El,
child_clip: &ClipCtx,
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
for (left_idx, left) in parent.children.iter().enumerate() {
if left.key.is_none() {
continue;
}
let left_rect = ui_state.rect(&left.computed_id);
let Some(left_hit) = clipped_rect(left_rect.outset(left.hit_overflow), child_clip) else {
continue;
};
for right in parent.children.iter().skip(left_idx + 1) {
if right.key.is_none() {
continue;
}
if !has_hit_overflow(left.hit_overflow) && !has_hit_overflow(right.hit_overflow) {
continue;
}
let right_rect = ui_state.rect(&right.computed_id);
let Some(right_hit) = clipped_rect(right_rect.outset(right.hit_overflow), child_clip)
else {
continue;
};
let Some(overlap) = left_hit.intersect(right_hit) else {
continue;
};
if overlap.w <= 0.5 || overlap.h <= 0.5 {
continue;
}
let left_visual_contains = left_rect.contains(overlap.center_x(), overlap.center_y());
let right_visual_contains = right_rect.contains(overlap.center_x(), overlap.center_y());
if left_visual_contains && right_visual_contains {
continue;
}
let earlier = left.key.as_deref().unwrap_or("<unkeyed>");
let later = right.key.as_deref().unwrap_or("<unkeyed>");
let owner = if has_hit_overflow(right.hit_overflow) {
right
} else {
left
};
r.findings.push(Finding {
kind: FindingKind::HitOverflowCollision,
node_id: owner.computed_id.clone(),
source: blame,
message: format!(
"expanded hit targets for sibling keys `{earlier}` and `{later}` overlap by {w:.0}x{h:.0}px — \
hit-test resolves the collision by paint order, so `{later}` owns that invisible band. \
Reduce `.hit_overflow(...)`, add real gap/padding, or make one visible row/control own the full intended target.",
w = overlap.w,
h = overlap.h,
),
});
}
}
}
fn check_corner_stackup(
parent: &El,
parent_rect: Rect,
child: &El,
child_rect: Rect,
r: &mut LintReport,
blame: Source,
) {
let pr = parent.radius;
let cr = child.radius;
let tl = (
pr.tl,
cr.tl,
Rect::new(parent_rect.x, parent_rect.y, pr.tl, pr.tl),
);
let tr = (
pr.tr,
cr.tr,
Rect::new(
parent_rect.x + parent_rect.w - pr.tr,
parent_rect.y,
pr.tr,
pr.tr,
),
);
let br = (
pr.br,
cr.br,
Rect::new(
parent_rect.x + parent_rect.w - pr.br,
parent_rect.y + parent_rect.h - pr.br,
pr.br,
pr.br,
),
);
let bl = (
pr.bl,
cr.bl,
Rect::new(
parent_rect.x,
parent_rect.y + parent_rect.h - pr.bl,
pr.bl,
pr.bl,
),
);
let leaks_at = |(p_r, c_r, corner_box): (f32, f32, Rect)| -> bool {
if p_r <= 0.5 || c_r + 0.5 >= p_r {
return false;
}
match child_rect.intersect(corner_box) {
Some(overlap) => overlap.w >= 0.5 && overlap.h >= 0.5,
None => false,
}
};
let (leak_tl, leak_tr, leak_br, leak_bl) =
(leaks_at(tl), leaks_at(tr), leaks_at(br), leaks_at(bl));
if !(leak_tl || leak_tr || leak_br || leak_bl) {
return;
}
let (descriptor, helper) = match (leak_tl, leak_tr, leak_br, leak_bl) {
(true, true, false, false) => ("the parent's top corners", "Corners::top(...)"),
(false, false, true, true) => ("the parent's bottom corners", "Corners::bottom(...)"),
(true, false, false, true) => ("the parent's left corners", "Corners::left(...)"),
(false, true, true, false) => ("the parent's right corners", "Corners::right(...)"),
(true, true, true, true) => ("the parent's corners", "Corners::all(...)"),
_ => (
"a parent corner",
"Corners { tl, tr, br, bl } with the matching corner set",
),
};
r.findings.push(Finding {
kind: FindingKind::CornerStackup,
node_id: child.computed_id.clone(),
source: blame,
message: format!(
"filled child paints into {descriptor} (rounded parent, max radius={pr_max:.0}) — \
the flat corners obscure the parent's curve and stroke. \
Set `.radius({helper})` on the child so its corners follow the parent's curve, \
or add padding to the parent so the child is inset from the curve.",
pr_max = pr.max(),
),
});
}
fn check_unpadded_surface_panel(
panel: &El,
panel_rect: Rect,
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
let touch_eps = crate::tokens::RING_WIDTH;
const PAD_EPS: f32 = 0.5;
let mut top = (false, false);
let mut right = (false, false);
let mut bottom = (false, false);
let mut left = (false, false);
for c in &panel.children {
let cr = ui_state.rect(&c.computed_id);
if cr.w <= PAD_EPS || cr.h <= PAD_EPS {
continue;
}
if (cr.y - panel_rect.y).abs() <= touch_eps {
top.0 = true;
if c.padding.top > PAD_EPS {
top.1 = true;
}
}
if (panel_rect.right() - cr.right()).abs() <= touch_eps {
right.0 = true;
if c.padding.right > PAD_EPS {
right.1 = true;
}
}
if (panel_rect.bottom() - cr.bottom()).abs() <= touch_eps {
bottom.0 = true;
if c.padding.bottom > PAD_EPS {
bottom.1 = true;
}
}
if (cr.x - panel_rect.x).abs() <= touch_eps {
left.0 = true;
if c.padding.left > PAD_EPS {
left.1 = true;
}
}
}
let pad = panel.padding;
let mut sides: Vec<&'static str> = Vec::new();
if pad.top <= PAD_EPS && top.0 && !top.1 {
sides.push("top");
}
if pad.right <= PAD_EPS && right.0 && !right.1 {
sides.push("right");
}
if pad.bottom <= PAD_EPS && bottom.0 && !bottom.1 {
sides.push("bottom");
}
if pad.left <= PAD_EPS && left.0 && !left.1 {
sides.push("left");
}
if sides.is_empty() {
return;
}
let joined = sides.join("/");
r.findings.push(Finding {
kind: FindingKind::UnpaddedSurfacePanel,
node_id: panel.computed_id.clone(),
source: blame,
message: format!(
"Panel-surface children sit flush against the {joined} edge — \
wrap content in the slot anatomy (`card_header(...)` / `card_content(...)` / `card_footer(...)` \
each bake `SPACE_6` padding), or pad the panel itself \
(e.g. `.padding(Sides::all(tokens::SPACE_4))` for dense list-row cards).",
),
});
}
fn check_focus_ring_obscured(
n: &El,
n_rect: Rect,
nearest_clip: &ClipCtx,
later_siblings: &[El],
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
let ring_overflow = focus_ring_overflow(n);
if ring_overflow.left <= 0.5
&& ring_overflow.right <= 0.5
&& ring_overflow.top <= 0.5
&& ring_overflow.bottom <= 0.5
{
return;
}
let band = n_rect.outset(ring_overflow);
let (clip_rect, check_horiz, check_vert) = match nearest_clip {
ClipCtx::None => (None, false, false),
ClipCtx::Static(rect) => (Some(*rect), true, true),
ClipCtx::Scrolling {
rect, scroll_axis, ..
} => match scroll_axis {
Axis::Column => (Some(*rect), true, false),
Axis::Row => (Some(*rect), false, true),
Axis::Overlay => (Some(*rect), true, true),
},
};
if let Some(clip) = clip_rect {
let dx_left = if check_horiz {
(clip.x - band.x).max(0.0)
} else {
0.0
};
let dx_right = if check_horiz {
(band.right() - clip.right()).max(0.0)
} else {
0.0
};
let dy_top = if check_vert {
(clip.y - band.y).max(0.0)
} else {
0.0
};
let dy_bottom = if check_vert {
(band.bottom() - clip.bottom()).max(0.0)
} else {
0.0
};
if dx_left + dx_right + dy_top + dy_bottom > 0.5 {
r.findings.push(Finding {
kind: FindingKind::FocusRingObscured,
node_id: n.computed_id.clone(),
source: blame,
message: format!(
"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",
),
});
}
}
for sib in later_siblings {
let sib_rect = ui_state.rect(&sib.computed_id);
if let Some(side) = bleed_occlusion(n_rect, ring_overflow, sib_rect)
&& paints_pixels(sib)
{
r.findings.push(Finding {
kind: FindingKind::FocusRingObscured,
node_id: n.computed_id.clone(),
source: blame,
message: format!(
"focus ring band occluded on the {side} edge by later-painted sibling {sib_id} — increase gap to ≥ tokens::RING_WIDTH or restructure so the neighbor doesn't sit on the edge",
sib_id = sib.computed_id,
),
});
break;
}
}
}
fn check_scrollbar_overlap(
n: &El,
n_rect: Rect,
nearest_clip: &ClipCtx,
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
let ClipCtx::Scrolling { node_id, .. } = nearest_clip else {
return;
};
let Some(track) = ui_state.scroll.thumb_tracks.get(node_id).copied() else {
return;
};
let active_w = crate::tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE;
let thumb_left = track.right() - active_w;
let thumb_right = track.right();
let overlap_x = n_rect.right().min(thumb_right) - n_rect.x.max(thumb_left);
if overlap_x <= 0.5 {
return;
}
r.findings.push(Finding {
kind: FindingKind::ScrollbarObscuresFocusable,
node_id: n.computed_id.clone(),
source: blame,
message: format!(
"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",
ctrl_x = n_rect.x,
ctrl_right = n_rect.right(),
),
});
}
fn paints_pixels(n: &El) -> bool {
n.fill.is_some()
|| n.stroke.is_some()
|| n.image.is_some()
|| n.icon.is_some()
|| n.shadow > 0.0
|| n.text.is_some()
|| !matches!(n.surface_role, SurfaceRole::None)
}
fn bleed_occlusion(n_rect: Rect, overflow: Sides, sib_rect: Rect) -> Option<&'static str> {
const EPS: f32 = 0.5;
let bands: [(&'static str, Rect); 4] = [
(
"top",
Rect::new(n_rect.x, n_rect.y - overflow.top, n_rect.w, overflow.top),
),
(
"bottom",
Rect::new(n_rect.x, n_rect.bottom(), n_rect.w, overflow.bottom),
),
(
"left",
Rect::new(n_rect.x - overflow.left, n_rect.y, overflow.left, n_rect.h),
),
(
"right",
Rect::new(n_rect.right(), n_rect.y, overflow.right, n_rect.h),
),
];
for (side, band) in bands {
if band.w <= 0.0 || band.h <= 0.0 {
continue;
}
let iw = band.right().min(sib_rect.right()) - band.x.max(sib_rect.x);
let ih = band.bottom().min(sib_rect.bottom()) - band.y.max(sib_rect.y);
if iw > EPS && ih > EPS {
return Some(side);
}
}
None
}
fn lint_row_alignment(
n: &El,
computed: Rect,
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
if !matches!(n.axis, Axis::Row) || !matches!(n.align, Align::Stretch) || n.children.len() < 2 {
return;
}
if !n.children.iter().any(is_text_like_child) {
return;
}
let inner = computed.inset(n.padding);
if inner.h <= 0.0 {
return;
}
for child in &n.children {
if !is_fixed_visual_child(child) {
continue;
}
let child_rect = ui_state.rect(&child.computed_id);
let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
let visibly_short = child_rect.h + 2.0 < inner.h;
if top_pinned && visibly_short {
r.findings.push(Finding {
kind: FindingKind::Alignment,
node_id: n.computed_id.clone(),
source: blame,
message: "row has a fixed-size visual child pinned to the top beside text; add .align(Align::Center) to vertically center row content"
.to_string(),
});
return;
}
}
}
fn lint_overlay_alignment(
n: &El,
computed: Rect,
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
if !matches!(n.axis, Axis::Overlay)
|| n.children.is_empty()
|| !matches!(n.align, Align::Start | Align::Stretch)
|| !matches!(n.justify, Justify::Start | Justify::SpaceBetween)
|| !has_visible_surface(n)
{
return;
}
let inner = computed.inset(n.padding);
if inner.w <= 0.0 || inner.h <= 0.0 {
return;
}
for child in &n.children {
if !is_fixed_visual_child(child) {
continue;
}
let child_rect = ui_state.rect(&child.computed_id);
let left_pinned = (child_rect.x - inner.x).abs() <= 0.5;
let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
let visibly_narrow = child_rect.w + 2.0 < inner.w;
let visibly_short = child_rect.h + 2.0 < inner.h;
if left_pinned && top_pinned && visibly_narrow && visibly_short {
r.findings.push(Finding {
kind: FindingKind::Alignment,
node_id: n.computed_id.clone(),
source: blame,
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"
.to_string(),
});
return;
}
}
}
fn lint_row_visual_text_spacing(n: &El, ui_state: &UiState, r: &mut LintReport, blame: Source) {
if !matches!(n.axis, Axis::Row) || n.children.len() < 2 {
return;
}
for pair in n.children.windows(2) {
let [visual, text] = pair else {
continue;
};
if !is_visual_cluster_child(visual) || !is_text_like_child(text) {
continue;
}
let visual_rect = ui_state.rect(&visual.computed_id);
let text_rect = ui_state.rect(&text.computed_id);
let gap = text_rect.x - visual_rect.right();
if gap < 4.0 {
r.findings.push(Finding {
kind: FindingKind::Spacing,
node_id: n.computed_id.clone(),
source: blame,
message: format!(
"row places text {:.0}px after an icon/control slot; add .gap(tokens::SPACE_2) or use a stock menu/list row",
gap.max(0.0)
),
});
return;
}
}
}
fn is_text_like_child(c: &El) -> bool {
c.text.is_some()
|| c.children
.iter()
.any(|child| child.text.is_some() || matches!(child.kind, Kind::Text | Kind::Heading))
}
fn has_visible_surface(n: &El) -> bool {
n.fill.is_some() || n.stroke.is_some()
}
fn is_fixed_visual_child(c: &El) -> bool {
let fixed_height = matches!(c.height, Size::Fixed(_));
fixed_height
&& (c.icon.is_some()
|| matches!(c.kind, Kind::Badge)
|| matches!(
c.metrics_role,
Some(
MetricsRole::Button
| MetricsRole::IconButton
| MetricsRole::Input
| MetricsRole::Badge
| MetricsRole::TabTrigger
| MetricsRole::ChoiceControl
| MetricsRole::Slider
| MetricsRole::Progress
)
))
}
fn is_visual_cluster_child(c: &El) -> bool {
let fixed_box = matches!(c.width, Size::Fixed(_)) && matches!(c.height, Size::Fixed(_));
fixed_box
&& (c.icon.is_some()
|| matches!(c.kind, Kind::Badge)
|| matches!(
c.metrics_role,
Some(MetricsRole::IconButton | MetricsRole::Badge | MetricsRole::ChoiceControl)
)
|| (has_visible_surface(c) && c.children.iter().any(is_fixed_visual_child)))
}
fn rect_contains(parent: Rect, child: Rect, tol: f32) -> bool {
child.x >= parent.x - tol
&& child.y >= parent.y - tol
&& child.right() <= parent.right() + tol
&& child.bottom() <= parent.bottom() + tol
}
fn flex_main_axis_overflowed(parent: &El, parent_rect: Rect, ui_state: &UiState) -> bool {
let n = parent.children.len();
if n == 0 {
return false;
}
let inner = parent_rect.inset(parent.padding);
let inner_main = match parent.axis {
Axis::Row => inner.w,
Axis::Column => inner.h,
Axis::Overlay => return false,
};
let total_gap = parent.gap * n.saturating_sub(1) as f32;
let consumed: f32 = parent
.children
.iter()
.map(|c| {
let r = ui_state.rect(&c.computed_id);
match parent.axis {
Axis::Row => r.w,
Axis::Column => r.h,
Axis::Overlay => 0.0,
}
})
.sum();
consumed + total_gap > inner_main + 0.5
}
fn short_path(p: &str) -> String {
let parts: Vec<&str> = p.split(['/', '\\']).collect();
if parts.len() >= 2 {
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
p.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lint_one(mut root: El) -> LintReport {
let mut ui_state = UiState::new();
layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
lint(&root, &ui_state)
}
#[test]
fn clipped_nowrap_text_reports_text_overflow() {
let root = crate::text("A very long dashboard label")
.width(Size::Fixed(42.0))
.height(Size::Fixed(20.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn ellipsis_nowrap_text_satisfies_horizontal_overflow_policy() {
let root = crate::text("A very long dashboard label")
.ellipsis()
.width(Size::Fixed(42.0))
.height(Size::Fixed(20.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn hug_ellipsis_in_overflowing_row_reports_dead_chain_issue_19() {
let row = crate::row([
crate::text("short_label"),
crate::text("a long descriptive body that should truncate but cannot").ellipsis(),
crate::text("right_side_metadata"),
])
.width(Size::Fixed(160.0))
.height(Size::Fixed(20.0));
let report = lint_one(row);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
"expected dead-ellipsis finding pointing at Hug text\n{}",
report.text()
);
}
#[test]
fn hug_ellipsis_in_non_overflowing_row_is_quiet() {
let row = crate::row([crate::text("ok").ellipsis()])
.width(Size::Fixed(160.0))
.height(Size::Fixed(20.0));
let report = lint_one(row);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn fill_ellipsis_in_overflowing_row_is_quiet() {
let row = crate::row([
crate::text("short_label"),
crate::text("a long descriptive body that should truncate but cannot")
.width(Size::Fill(1.0))
.ellipsis(),
crate::text("right_side_metadata"),
])
.width(Size::Fixed(160.0))
.height(Size::Fixed(20.0));
let report = lint_one(row);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
"{}",
report.text()
);
}
#[test]
fn padding_eats_fixed_height_button_reports_padding_advice() {
let root = crate::row([crate::button("Resume")
.height(Size::Fixed(30.0))
.padding(crate::tokens::SPACE_2)]);
let report = lint_one(root);
let finding = report
.findings
.iter()
.find(|f| f.kind == FindingKind::Overflow)
.unwrap_or_else(|| {
panic!(
"expected an Overflow finding for the padding-eats-height shape\n{}",
report.text()
)
});
assert!(
finding.message.contains("vertical padding") && finding.message.contains("Sides::xy"),
"expected padding-y advice, got:\n{}\n{}",
finding.message,
report.text(),
);
assert!(
!finding.message.contains("paragraph()") && !finding.message.contains("wrap_text()"),
"padding-eats-height case should not recommend paragraph/wrap_text:\n{}",
finding.message,
);
}
#[test]
fn padding_eats_fixed_height_y_only_does_not_fire_when_height_is_hug() {
let root = crate::row([crate::text("Resume").padding(crate::tokens::SPACE_2)]);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::Overflow || f.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn text_taller_than_fixed_height_without_padding_reports_height_advice() {
let root = crate::row([crate::text("body")
.width(Size::Fixed(80.0))
.height(Size::Fixed(12.0))]);
let report = lint_one(root);
let finding = report
.findings
.iter()
.find(|f| f.kind == FindingKind::Overflow)
.unwrap_or_else(|| {
panic!(
"expected an Overflow finding for text-taller-than-box\n{}",
report.text()
)
});
assert!(
finding.message.contains("exceeds box height") && finding.message.contains("height"),
"expected height-advice message, got:\n{}",
finding.message,
);
assert!(
!finding.message.contains("vertical padding"),
"no-padding case should not blame padding:\n{}",
finding.message,
);
}
#[test]
fn padding_aware_text_overflow_fires_when_text_spills_past_padded_region() {
let leaf = crate::text("dashboard")
.width(Size::Fixed(80.0))
.height(Size::Fixed(28.0))
.padding(Sides::xy(20.0, 0.0));
let root = crate::row([leaf]);
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn stretch_row_with_top_pinned_icon_and_text_suggests_center_alignment() {
let root = crate::row([
crate::icon("settings").icon_size(crate::tokens::ICON_SM),
crate::text("Settings").width(Size::Fill(1.0)),
])
.height(Size::Fixed(36.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment
&& finding.message.contains(".align(Align::Center)")),
"{}",
report.text()
);
}
#[test]
fn centered_row_with_icon_and_text_satisfies_alignment_policy() {
let root = crate::row([
crate::icon("settings").icon_size(crate::tokens::ICON_SM),
crate::text("Settings").width(Size::Fill(1.0)),
])
.height(Size::Fixed(36.0))
.align(Align::Center);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment),
"{}",
report.text()
);
}
#[test]
fn row_with_icon_slot_touching_text_reports_spacing() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.align(Align::Center)
.justify(Justify::Center)
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
.height(Size::Fixed(32.0))
.align(Align::Center);
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Spacing
&& finding.message.contains(".gap(tokens::SPACE_2)")),
"{}",
report.text()
);
}
#[test]
fn row_with_icon_slot_and_text_gap_satisfies_spacing_policy() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.align(Align::Center)
.justify(Justify::Center)
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
.height(Size::Fixed(32.0))
.align(Align::Center)
.gap(crate::tokens::SPACE_2);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Spacing),
"{}",
report.text()
);
}
#[test]
fn overlay_with_top_left_pinned_icon_suggests_center_alignment() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::column([icon_slot]);
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment
&& finding.message.contains(".justify(Justify::Center)")),
"{}",
report.text()
);
}
#[test]
fn centered_overlay_icon_satisfies_alignment_policy() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.align(Align::Center)
.justify(Justify::Center)
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::column([icon_slot]);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment),
"{}",
report.text()
);
}
#[test]
fn overflow_findings_attribute_to_nearest_user_source_ancestor() {
let user_source = Source {
file: "src/screen.rs",
line: 42,
from_library: false,
};
let widget_source = Source {
file: "src/widgets/tabs.rs",
line: 200,
from_library: true,
};
let mut leaf = crate::text("A very long dashboard label")
.width(Size::Fixed(40.0))
.height(Size::Fixed(20.0));
leaf.source = widget_source;
let mut root = crate::row([leaf])
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
root.source = user_source;
let mut ui_state = UiState::new();
layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
let report = lint(&root, &ui_state);
let text_overflow = report
.findings
.iter()
.find(|f| f.kind == FindingKind::TextOverflow)
.unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
assert_eq!(text_overflow.source.file, user_source.file);
assert_eq!(text_overflow.source.line, user_source.line);
}
#[test]
fn overflow_finding_self_attributes_when_node_is_already_user_source() {
let mut node = crate::text("A very long dashboard label")
.width(Size::Fixed(40.0))
.height(Size::Fixed(20.0));
let user_source = Source {
file: "src/screen.rs",
line: 99,
from_library: false,
};
node.source = user_source;
let mut ui_state = UiState::new();
layout::layout(&mut node, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
let report = lint(&node, &ui_state);
let text_overflow = report
.findings
.iter()
.find(|f| f.kind == FindingKind::TextOverflow)
.unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
assert_eq!(text_overflow.source.line, user_source.line);
}
#[test]
fn overflow_lint_fires_for_external_app_paths_issue_13() {
let user_source = Source {
file: "src/sidebar.rs",
line: 17,
from_library: false,
};
let mut child = crate::column(Vec::<El>::new())
.width(Size::Fixed(32.0))
.height(Size::Fixed(32.0));
child.source = user_source;
let mut row = crate::row([child])
.width(Size::Fixed(256.0))
.height(Size::Fixed(28.0));
row.source = user_source;
let mut ui_state = UiState::new();
layout::layout(&mut row, &mut ui_state, Rect::new(0.0, 0.0, 256.0, 28.0));
let report = lint(&row, &ui_state);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::Overflow),
"expected an Overflow finding for the 32px child in a 28px row\n{}",
report.text()
);
}
#[test]
fn overflow_finding_suppressed_when_no_user_ancestor_exists() {
let widget_source = Source {
file: "src/widgets/tabs.rs",
line: 200,
from_library: true,
};
let mut leaf = crate::text("A very long dashboard label")
.width(Size::Fixed(40.0))
.height(Size::Fixed(20.0));
leaf.source = widget_source;
let mut wrapper = crate::row([leaf])
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
wrapper.source = widget_source;
let mut ui_state = UiState::new();
layout::layout(
&mut wrapper,
&mut ui_state,
Rect::new(0.0, 0.0, 160.0, 48.0),
);
let report = lint(&wrapper, &ui_state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::TextOverflow || f.kind == FindingKind::Overflow),
"{}",
report.text()
);
}
#[test]
fn panel_role_without_fill_reports_missing_surface_fill() {
let root = crate::column([crate::text("body")])
.surface_role(SurfaceRole::Panel)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn panel_role_with_fill_satisfies_surface_policy() {
let root = crate::column([crate::text("body")])
.surface_role(SurfaceRole::Panel)
.fill(crate::tokens::CARD)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn card_widget_satisfies_surface_policy() {
let root = crate::widgets::card::card([crate::text("body")])
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn handrolled_card_recipe_reports_reinvented_widget() {
let root = crate::column([crate::text("body")])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.radius(crate::tokens::RADIUS_LG)
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("card(")),
"{}",
report.text()
);
}
#[test]
fn real_card_widget_does_not_report_reinvented_widget() {
let root = crate::widgets::card::card([crate::text("body")])
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn handrolled_sidebar_recipe_reports_reinvented_widget() {
let root = crate::column([crate::text("nav")])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.width(Size::Fixed(crate::tokens::SIDEBAR_WIDTH))
.height(Size::Fill(1.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("sidebar(")),
"{}",
report.text()
);
}
#[test]
fn real_sidebar_widget_does_not_report_reinvented_widget() {
let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn empty_visual_swatch_does_not_report_reinvented_widget() {
let root = crate::column(Vec::<El>::new())
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.radius(crate::tokens::RADIUS_SM)
.width(Size::Fixed(42.0))
.height(Size::Fixed(34.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn plain_column_does_not_report_reinvented_widget() {
let root = crate::column([crate::text("a"), crate::text("b")])
.gap(crate::tokens::SPACE_2)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn fill_providing_roles_do_not_require_explicit_fill() {
let root = crate::column([crate::text("body")])
.surface_role(SurfaceRole::Sunken)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn focus_ring_lint_fires_when_input_clipped_on_scroll_cross_axis() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::scroll([crate::tree::column([
crate::widgets::text_input::text_input("", &selection, "field"),
])])
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::FocusRingObscured
&& f.message.contains("clipped")
&& (f.message.contains("L=2") || f.message.contains("R=2"))
}),
"expected a FocusRingObscured clipping finding (L=2 or R=2)\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_assumes_every_focusable_has_a_ring_band() {
let mut root = crate::tree::scroll([crate::tree::column([El::new(Kind::Custom(
"raw_focusable",
))
.key("raw")
.focusable()
.fill(crate::tokens::CARD)
.width(Size::Fill(1.0))
.height(Size::Fixed(40.0))])])
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::FocusRingObscured
&& f.message.contains("clipped")
&& (f.message.contains("L=2") || f.message.contains("R=2"))
}),
"expected a FocusRingObscured clipping finding for implicit focus ring band\n{}",
report.text()
);
}
#[test]
fn hit_overflow_collision_lint_fires_for_sibling_target_overlap() {
let root = crate::tree::row([
crate::button("A")
.key("a")
.hit_overflow(Sides::right(8.0))
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0)),
crate::button("B")
.key("b")
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0)),
])
.gap(4.0);
let report = lint_one(root);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::HitOverflowCollision
&& f.message.contains("`a`")
&& f.message.contains("`b`")
}),
"expected HitOverflowCollision when a hit_overflow band reaches the next sibling\n{}",
report.text()
);
}
#[test]
fn hit_overflow_collision_lint_is_quiet_when_gap_clears_band() {
let root = crate::tree::row([
crate::button("A")
.key("a")
.hit_overflow(Sides::right(8.0))
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0)),
crate::button("B")
.key("b")
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0)),
])
.gap(12.0);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::HitOverflowCollision),
"{}",
report.text()
);
}
#[test]
fn hit_overflow_collision_lint_skips_overlay_stacks() {
let root = crate::tree::stack([
crate::button("A")
.key("a")
.hit_overflow(Sides::all(8.0))
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0)),
crate::button("B")
.key("b")
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0)),
]);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::HitOverflowCollision),
"{}",
report.text()
);
}
#[test]
fn focus_ring_lint_silenced_when_scroll_supplies_horizontal_slack() {
let selection = crate::selection::Selection::default();
let mut root =
crate::tree::scroll(
[crate::tree::column([crate::widgets::text_input::text_input(
"", &selection, "field",
)])
.padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))],
)
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"{}",
report.text()
);
}
#[test]
fn focus_ring_lint_skips_clipping_on_scroll_axis() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::scroll([crate::tree::column([
crate::tree::column(Vec::<El>::new())
.width(Size::Fill(1.0))
.height(Size::Fixed(200.0)),
crate::widgets::text_input::text_input("", &selection, "field"),
])
.padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))])
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"expected no FocusRingObscured finding for a row clipped on the scroll axis\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_fires_on_static_clip_in_any_direction() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::column([crate::widgets::text_input::text_input(
"", &selection, "field",
)])
.clip()
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::FocusRingObscured && f.message.contains("clipped")
}),
"expected a static-clip FocusRingObscured finding\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_fires_on_painted_later_sibling_overlap() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::row([
crate::widgets::text_input::text_input("", &selection, "field"),
crate::tree::column([crate::text("neighbor")])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.width(Size::Fixed(80.0))
.height(Size::Fixed(32.0)),
])
.gap(0.0)
.width(Size::Fixed(400.0))
.height(Size::Fixed(32.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
let report = lint(&root, &state);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::FocusRingObscured
&& f.message.contains("occluded")
&& f.message.contains("right")
}),
"expected an occlusion finding on the right edge\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_allows_flush_inside_ring_menu_items() {
let mut root = crate::tree::column([
crate::menu_item("Checkout").key("checkout"),
crate::menu_item("Merge").key("merge"),
crate::menu_item("Delete").key("delete"),
])
.gap(0.0)
.width(Size::Fixed(180.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 220.0, 140.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"{}",
report.text()
);
}
#[test]
fn focus_ring_lint_ignores_unpainted_structural_sibling() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::row([
crate::widgets::text_input::text_input("", &selection, "field"),
crate::tree::column(Vec::<El>::new())
.width(Size::Fixed(80.0))
.height(Size::Fixed(32.0)),
])
.gap(0.0)
.width(Size::Fixed(400.0))
.height(Size::Fixed(32.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"{}",
report.text()
);
}
#[test]
fn scrollbar_overlap_lint_fires_when_thumb_covers_fill_child() {
let body = crate::tree::column(
(0..30)
.map(|i| {
crate::tree::row([
crate::text(format!("Row {i}")),
crate::tree::spacer(),
crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
])
.gap(crate::tokens::SPACE_2)
.width(Size::Fill(1.0))
})
.collect::<Vec<_>>(),
)
.gap(crate::tokens::SPACE_2)
.width(Size::Fill(1.0));
let mut root = crate::tree::scroll([body])
.padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
.width(Size::Fixed(480.0))
.height(Size::Fixed(320.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
let report = lint(&root, &state);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
"expected ScrollbarObscuresFocusable for a switch that reaches the scroll's inner.right()\n{}",
report.text()
);
}
#[test]
fn scrollbar_overlap_lint_silenced_when_padding_is_inside_scroll() {
let body = crate::tree::column(
(0..30)
.map(|i| {
crate::tree::row([
crate::text(format!("Row {i}")),
crate::tree::spacer(),
crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
])
.gap(crate::tokens::SPACE_2)
.width(Size::Fill(1.0))
})
.collect::<Vec<_>>(),
)
.gap(crate::tokens::SPACE_2)
.width(Size::Fill(1.0));
let mut root = crate::tree::scroll([crate::tree::column([body])
.padding(Sides::xy(crate::tokens::SPACE_3, 0.0))
.width(Size::Fill(1.0))])
.padding(Sides::xy(0.0, crate::tokens::SPACE_2))
.width(Size::Fixed(480.0))
.height(Size::Fixed(320.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
"expected no ScrollbarObscuresFocusable when padding is inside the scroll\n{}",
report.text()
);
}
#[test]
fn scrollbar_overlap_lint_quiet_when_content_does_not_overflow() {
let body = crate::tree::column([crate::tree::row([
crate::text("only row"),
crate::tree::spacer(),
crate::widgets::switch::switch(false).key("only-toggle"),
])
.gap(crate::tokens::SPACE_2)
.width(Size::Fill(1.0))])
.gap(crate::tokens::SPACE_2)
.width(Size::Fill(1.0));
let mut root = crate::tree::scroll([body])
.padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
.width(Size::Fixed(480.0))
.height(Size::Fixed(320.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
"expected no ScrollbarObscuresFocusable when content fits in the viewport (no thumb rendered)\n{}",
report.text()
);
}
#[test]
fn unkeyed_tooltip_reports_dead_tooltip() {
let root = crate::text("abc1234").tooltip("commit sha");
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::DeadTooltip),
"expected DeadTooltip on unkeyed tooltipped text\n{}",
report.text()
);
}
#[test]
fn keyed_tooltip_satisfies_dead_tooltip_policy() {
let root = crate::text("abc1234").key("sha").tooltip("commit sha");
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::DeadTooltip),
"{}",
report.text()
);
}
#[test]
fn unkeyed_tooltip_inside_keyed_ancestor_still_reports_dead_tooltip() {
let root =
crate::row([crate::text("inner detail").tooltip("never shown")]).key("outer-row");
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::DeadTooltip),
"expected DeadTooltip on unkeyed leaf even with keyed ancestor\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_is_quiet_inside_form_after_padding_fix() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::scroll([crate::widgets::form::form([
crate::widgets::form::form_item([crate::widgets::form::form_control(
crate::widgets::text_input::text_input("", &selection, "field"),
)]),
])])
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"{}",
report.text()
);
}
fn lint_one_with_metrics(mut root: El) -> LintReport {
crate::metrics::ThemeMetrics::default().apply_to_tree(&mut root);
let mut ui_state = UiState::new();
layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 120.0));
lint(&root, &ui_state)
}
#[test]
fn handrolled_rounded_container_with_flat_filled_header_reports_corner_stackup() {
let parent = crate::column([
crate::row([crate::text("Header")])
.fill(crate::tokens::MUTED)
.width(Size::Fill(1.0))
.height(Size::Fixed(24.0)),
crate::row([crate::text("Body")])
.width(Size::Fill(1.0))
.height(Size::Fixed(60.0)),
])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.radius(crate::tokens::RADIUS_LG)
.width(Size::Fixed(160.0))
.height(Size::Fixed(96.0));
let report = lint_one(parent);
let found = report
.findings
.iter()
.find(|f| f.kind == FindingKind::CornerStackup);
let found =
found.unwrap_or_else(|| panic!("expected CornerStackup, got:\n{}", report.text()));
assert!(
found.message.contains("Corners::top"),
"top-strip leak should suggest Corners::top, got: {}",
found.message
);
}
#[test]
fn handrolled_rounded_container_with_inset_child_does_not_report_corner_stackup() {
let parent = crate::column([crate::row([crate::text("Header")])
.fill(crate::tokens::MUTED)
.width(Size::Fill(1.0))
.height(Size::Fixed(24.0))])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.radius(crate::tokens::RADIUS_LG)
.padding(Sides::all(crate::tokens::RADIUS_LG))
.width(Size::Fixed(160.0))
.height(Size::Fixed(96.0));
let report = lint_one(parent);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::CornerStackup),
"inset child should not trip the lint, got:\n{}",
report.text()
);
}
#[test]
fn handrolled_rounded_container_with_matching_corners_does_not_report_corner_stackup() {
let parent = crate::column([crate::row([crate::text("Header")])
.fill(crate::tokens::MUTED)
.radius(Corners::top(crate::tokens::RADIUS_LG))
.width(Size::Fill(1.0))
.height(Size::Fixed(24.0))])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.radius(crate::tokens::RADIUS_LG)
.width(Size::Fixed(160.0))
.height(Size::Fixed(96.0));
let report = lint_one(parent);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::CornerStackup),
"matching corners should not trip the lint, got:\n{}",
report.text()
);
}
#[test]
fn canonical_card_recipe_does_not_report_corner_stackup_after_metrics() {
let root = crate::widgets::card::card([
crate::widgets::card::card_header([crate::text("Header")]).fill(crate::tokens::MUTED),
crate::widgets::card::card_content([crate::text("Body")]),
])
.width(Size::Fixed(180.0))
.height(Size::Fixed(110.0));
let report = lint_one_with_metrics(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::CornerStackup),
"canonical card_header(...).fill(...) recipe should be quiet after metrics pass, got:\n{}",
report.text()
);
}
#[test]
fn bare_card_with_flush_content_reports_unpadded_surface_panel_issue_24() {
let root = crate::widgets::card::card([crate::row([
crate::text("some title").bold(),
crate::text("description line").muted(),
])
.gap(crate::tokens::SPACE_2)
.width(Size::Fill(1.0))])
.width(Size::Fixed(200.0))
.height(Size::Fixed(80.0));
let report = lint_one(root);
let f = report
.findings
.iter()
.find(|f| f.kind == FindingKind::UnpaddedSurfacePanel)
.unwrap_or_else(|| {
panic!(
"expected UnpaddedSurfacePanel finding, got:\n{}",
report.text()
)
});
assert!(
f.message.contains("top"),
"expected the flushing-side list to call out `top`, got: {}",
f.message
);
}
#[test]
fn card_with_explicit_padding_does_not_report_unpadded_surface_panel() {
let root = crate::widgets::card::card([
crate::row([crate::text("title").bold()]).width(Size::Fill(1.0))
])
.padding(Sides::all(crate::tokens::SPACE_4))
.width(Size::Fixed(200.0))
.height(Size::Fixed(60.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
"{}",
report.text()
);
}
#[test]
fn canonical_card_anatomy_does_not_report_unpadded_surface_panel() {
let root = crate::widgets::card::card([
crate::widgets::card::card_header([crate::widgets::card::card_title("Header")]),
crate::widgets::card::card_content([crate::text("Body")]),
crate::widgets::card::card_footer([crate::text("footer")]),
])
.width(Size::Fixed(220.0))
.height(Size::Fixed(160.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
"canonical slot anatomy should be quiet, got:\n{}",
report.text()
);
}
#[test]
fn sidebar_widget_does_not_report_unpadded_surface_panel() {
let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
"{}",
report.text()
);
}
}