1use std::fmt::Write as _;
37
38use crate::layout;
39use crate::metrics::MetricsRole;
40use crate::state::UiState;
41use crate::tree::*;
42
43#[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 MissingSurfaceFill,
72 ReinventedWidget,
81 FocusRingObscured,
95 ScrollbarObscuresFocusable,
113 DeadTooltip,
128 CornerStackup,
145 UnpaddedSurfacePanel,
172}
173
174#[derive(Clone, Debug, Default)]
175#[non_exhaustive]
176pub struct LintReport {
177 pub findings: Vec<Finding>,
178}
179
180impl LintReport {
181 pub fn text(&self) -> String {
182 if self.findings.is_empty() {
183 return "no findings\n".to_string();
184 }
185 let mut s = String::new();
186 for f in &self.findings {
187 let _ = writeln!(
188 s,
189 "{kind:?} node={id} {source} :: {msg}",
190 kind = f.kind,
191 id = f.node_id,
192 source = if f.source.line == 0 {
193 "<no-source>".to_string()
194 } else {
195 format!("{}:{}", short_path(f.source.file), f.source.line)
196 },
197 msg = f.message,
198 );
199 }
200 s
201 }
202}
203
204pub fn lint(root: &El, ui_state: &UiState) -> LintReport {
214 let mut r = LintReport::default();
215 let mut seen_ids: std::collections::BTreeMap<String, usize> = Default::default();
216 walk(
217 root,
218 None,
219 None,
220 &ClipCtx::None,
221 ui_state,
222 &mut r,
223 &mut seen_ids,
224 );
225 for (id, n) in seen_ids {
226 if n > 1 {
227 r.findings.push(Finding {
228 kind: FindingKind::DuplicateId,
229 node_id: id.clone(),
230 source: Source::default(),
231 message: format!("{n} nodes share id {id}"),
232 });
233 }
234 }
235 r
236}
237
238fn is_from_user(source: Source) -> bool {
239 !source.from_library
240}
241
242#[derive(Clone)]
251enum ClipCtx {
252 None,
253 Static(Rect),
255 Scrolling {
259 rect: Rect,
260 scroll_axis: Axis,
261 node_id: String,
262 },
263}
264
265fn walk(
266 n: &El,
267 parent_kind: Option<&Kind>,
268 parent_blame: Option<Source>,
269 nearest_clip: &ClipCtx,
270 ui_state: &UiState,
271 r: &mut LintReport,
272 seen: &mut std::collections::BTreeMap<String, usize>,
273) {
274 *seen.entry(n.computed_id.clone()).or_default() += 1;
275 let computed = ui_state.rect(&n.computed_id);
276
277 let from_user_self = is_from_user(n.source);
278 let self_blame = if from_user_self {
285 Some(n.source)
286 } else {
287 parent_blame
288 };
289
290 let inside_inlines = matches!(parent_kind, Some(Kind::Inlines));
296
297 if from_user_self {
300 if let Some(c) = n.fill
301 && c.token.is_none()
302 && c.a > 0
303 {
304 r.findings.push(Finding {
305 kind: FindingKind::RawColor,
306 node_id: n.computed_id.clone(),
307 source: n.source,
308 message: format!(
309 "fill is a raw rgba({},{},{},{}) — use a token",
310 c.r, c.g, c.b, c.a
311 ),
312 });
313 }
314 if let Some(c) = n.stroke
315 && c.token.is_none()
316 && c.a > 0
317 {
318 r.findings.push(Finding {
319 kind: FindingKind::RawColor,
320 node_id: n.computed_id.clone(),
321 source: n.source,
322 message: format!(
323 "stroke is a raw rgba({},{},{},{}) — use a token",
324 c.r, c.g, c.b, c.a
325 ),
326 });
327 }
328 if let Some(c) = n.text_color
329 && c.token.is_none()
330 && c.a > 0
331 {
332 r.findings.push(Finding {
333 kind: FindingKind::RawColor,
334 node_id: n.computed_id.clone(),
335 source: n.source,
336 message: format!(
337 "text_color is a raw rgba({},{},{},{}) — use a token",
338 c.r, c.g, c.b, c.a
339 ),
340 });
341 }
342 if n.tooltip.is_some() && n.key.is_none() {
348 r.findings.push(Finding {
349 kind: FindingKind::DeadTooltip,
350 node_id: n.computed_id.clone(),
351 source: n.source,
352 message: ".tooltip() on a node without .key() never fires — hit-test only \
353 returns keyed nodes, so hover skips past this leaf to the nearest \
354 keyed ancestor. Add .key(\"…\") on the same node that carries the \
355 tooltip; for info-only chrome inside list rows, a synthetic key \
356 like \"row:{idx}.<part>\" is enough."
357 .to_string(),
358 });
359 }
360
361 if n.fill.is_none() && matches!(n.surface_role, SurfaceRole::Panel) {
368 r.findings.push(Finding {
369 kind: FindingKind::MissingSurfaceFill,
370 node_id: n.computed_id.clone(),
371 source: n.source,
372 message:
373 "surface_role(Panel) without a fill paints only stroke + shadow — \
374 wrap in card() / sidebar() / dialog() for the canonical recipe, or set .fill(tokens::CARD)"
375 .to_string(),
376 });
377 }
378
379 if matches!(n.surface_role, SurfaceRole::Panel) {
380 check_unpadded_surface_panel(n, computed, ui_state, r, n.source);
381 }
382
383 if matches!(n.kind, Kind::Group) && !n.children.is_empty() {
397 let card_fill = n
398 .fill
399 .as_ref()
400 .and_then(|c| c.token)
401 .is_some_and(|t| t == "card");
402 let border_stroke = n
403 .stroke
404 .as_ref()
405 .and_then(|c| c.token)
406 .is_some_and(|t| t == "border");
407 if card_fill && border_stroke {
408 let is_panel_surface = matches!(n.surface_role, SurfaceRole::Panel);
409 let sidebar_width = matches!(n.width, Size::Fixed(w) if (w - crate::tokens::SIDEBAR_WIDTH).abs() < 0.5);
410 if !is_panel_surface {
411 if sidebar_width {
412 r.findings.push(Finding {
413 kind: FindingKind::ReinventedWidget,
414 node_id: n.computed_id.clone(),
415 source: n.source,
416 message:
417 "Group with fill=CARD, stroke=BORDER, width=SIDEBAR_WIDTH reinvents sidebar() — \
418 use sidebar([sidebar_header(...), sidebar_group([sidebar_menu([sidebar_menu_button(label, current)])])]) \
419 for the panel surface and the canonical row recipe"
420 .to_string(),
421 });
422 } else {
423 r.findings.push(Finding {
433 kind: FindingKind::ReinventedWidget,
434 node_id: n.computed_id.clone(),
435 source: n.source,
436 message:
437 "Group with fill=CARD, stroke=BORDER reinvents the panel-surface recipe — \
438 use card([card_header([card_title(\"...\")]), card_content([...])]) / titled_card(\"Title\", [...]) for boxed content, \
439 or sidebar([...]) for a full-height nav/inspector pane (sidebar() also handles the custom-width case via .width(Size::Fixed(...)))"
440 .to_string(),
441 });
442 }
443 }
444 }
445 }
446 }
447
448 if let Some(blame) = self_blame {
454 lint_row_alignment(n, computed, ui_state, r, blame);
455 lint_overlay_alignment(n, computed, ui_state, r, blame);
456 lint_row_visual_text_spacing(n, ui_state, r, blame);
457 }
458
459 if n.text.is_some()
465 && !inside_inlines
466 && let Some(blame) = self_blame
467 {
468 let available_width = match n.text_wrap {
469 TextWrap::NoWrap => None,
470 TextWrap::Wrap => Some(computed.w),
471 };
472 if let Some(text_layout) = layout::text_layout(n, available_width) {
473 let text_w = text_layout.width + n.padding.left + n.padding.right;
474 let text_h = text_layout.height + n.padding.top + n.padding.bottom;
475 let raw_overflow_x = (text_w - computed.w).max(0.0);
476 let overflow_x = if matches!(
477 (n.text_wrap, n.text_overflow),
478 (TextWrap::NoWrap, TextOverflow::Ellipsis)
479 ) {
480 0.0
481 } else {
482 raw_overflow_x
483 };
484 let overflow_y = (text_h - computed.h).max(0.0);
485 if overflow_x > 0.5 || overflow_y > 0.5 {
486 let is_clipped_nowrap = overflow_x > 0.5
487 && matches!(
488 (n.text_wrap, n.text_overflow),
489 (TextWrap::NoWrap, TextOverflow::Clip)
490 );
491 let kind = if is_clipped_nowrap {
492 FindingKind::TextOverflow
493 } else {
494 FindingKind::Overflow
495 };
496 let pad_y = n.padding.top + n.padding.bottom;
505 let height_is_fixed = matches!(n.height, Size::Fixed(_));
506 let text_alone_fits_height = text_layout.height <= computed.h + 0.5;
507 let padding_eats_fixed_height = overflow_y > 0.5
508 && overflow_x <= 0.5
509 && pad_y > 0.0
510 && text_alone_fits_height
511 && height_is_fixed;
512 let cell_h = text_layout.height;
513 let box_h = computed.h;
514 let message = if kind == FindingKind::TextOverflow {
515 format!(
516 "nowrap text exceeds its box by X={overflow_x:.0}; use .ellipsis(), wrap_text(), or a wider box"
517 )
518 } else if padding_eats_fixed_height {
519 let inner_h = (box_h - pad_y).max(0.0);
520 let pad_x_token = if (n.padding.left - n.padding.right).abs() < 0.5 {
521 format!("{:.0}", n.padding.left)
522 } else {
523 "...".to_string()
524 };
525 let control_h = crate::tokens::CONTROL_HEIGHT;
526 format!(
527 "vertical padding ({pad_y:.0}px) makes the inner content rect ({inner_h:.0}px) shorter than the text cell ({cell_h:.0}px) on a fixed-height box ({box_h:.0}px) — \
528 the label can't vertically center and paints into the padding band, off-center by Y={overflow_y:.0}. \
529 Reduce vertical padding (e.g. `Sides::xy({pad_x_token}, 0.0)` — `.padding(scalar)` is `Sides::all(scalar)`, which usually isn't what you want on a control-height box) or increase height (tokens::CONTROL_HEIGHT = {control_h:.0}px)"
530 )
531 } else if overflow_y > 0.5 && overflow_x <= 0.5 {
532 format!(
533 "text cell ({cell_h:.0}px) exceeds box height ({box_h:.0}px) by Y={overflow_y:.0}; \
534 increase height, reduce text size, or use paragraph()/wrap_text() with fewer lines"
535 )
536 } else {
537 format!(
538 "text content exceeds its box by X={overflow_x:.0} Y={overflow_y:.0}; use paragraph()/wrap_text(), a wider box, or explicit clipping"
539 )
540 };
541 r.findings.push(Finding {
542 kind,
543 node_id: n.computed_id.clone(),
544 source: blame,
545 message,
546 });
547 }
548 }
549 }
550
551 let suppress_overflow = n.scrollable
566 || n.clip
567 || matches!(n.kind, Kind::Inlines)
568 || matches!(n.kind, Kind::Custom("toast_stack"));
569
570 let parent_main_overran =
580 !suppress_overflow && flex_main_axis_overflowed(n, computed, ui_state);
581
582 let child_clip = if n.clip {
589 if n.scrollable {
590 ClipCtx::Scrolling {
591 rect: computed,
592 scroll_axis: n.axis,
593 node_id: n.computed_id.clone(),
594 }
595 } else {
596 ClipCtx::Static(computed)
597 }
598 } else {
599 nearest_clip.clone()
600 };
601
602 for (child_idx, c) in n.children.iter().enumerate() {
603 let from_user_child = is_from_user(c.source);
604 let child_blame = if from_user_child {
605 Some(c.source)
606 } else {
607 self_blame
608 };
609
610 let c_rect = ui_state.rect(&c.computed_id);
611 if !suppress_overflow
612 && !rect_contains(computed, c_rect, 0.5)
613 && let Some(blame) = child_blame
614 {
615 let dx_left = (computed.x - c_rect.x).max(0.0);
616 let dx_right = (c_rect.right() - computed.right()).max(0.0);
617 let dy_top = (computed.y - c_rect.y).max(0.0);
618 let dy_bottom = (c_rect.bottom() - computed.bottom()).max(0.0);
619 r.findings.push(Finding {
620 kind: FindingKind::Overflow,
621 node_id: c.computed_id.clone(),
622 source: blame,
623 message: format!(
624 "child overflows parent {parent_id} by L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}",
625 parent_id = n.computed_id,
626 ),
627 });
628 }
629
630 let main_axis_is_hug = match n.axis {
636 Axis::Row => matches!(c.width, Size::Hug),
637 Axis::Column => matches!(c.height, Size::Hug),
638 Axis::Overlay => false,
639 };
640 if parent_main_overran
641 && main_axis_is_hug
642 && c.text.is_some()
643 && c.text_wrap == TextWrap::NoWrap
644 && c.text_overflow == TextOverflow::Ellipsis
645 && let Some(blame) = child_blame
646 {
647 r.findings.push(Finding {
648 kind: FindingKind::TextOverflow,
649 node_id: c.computed_id.clone(),
650 source: blame,
651 message:
652 ".ellipsis() has no effect on Size::Hug text — Hug forces the rect to the intrinsic content width, so the truncation budget equals the content and no glyph is ever trimmed. Set Size::Fill(_) or Size::Fixed(_) on the text or on a wrapping container so the layout can constrain the rect."
653 .to_string(),
654 });
655 }
656
657 if from_user_child
665 && c.fill.is_some()
666 && n.radius.any_nonzero()
667 && let Some(blame) = child_blame
668 {
669 check_corner_stackup(n, computed, c, c_rect, r, blame);
670 }
671
672 if from_user_child
673 && c.focusable
674 && let Some(blame) = child_blame
675 {
676 if has_paint_overflow(c.paint_overflow) {
680 check_focus_ring_obscured(
681 c,
682 c_rect,
683 &child_clip,
684 &n.children[child_idx + 1..],
685 ui_state,
686 r,
687 blame,
688 );
689 }
690 check_scrollbar_overlap(c, c_rect, &child_clip, ui_state, r, blame);
694 }
695
696 walk(
697 c,
698 Some(&n.kind),
699 child_blame,
700 &child_clip,
701 ui_state,
702 r,
703 seen,
704 );
705 }
706}
707
708fn has_paint_overflow(s: Sides) -> bool {
709 s.left > 0.0 || s.right > 0.0 || s.top > 0.0 || s.bottom > 0.0
710}
711
712fn check_corner_stackup(
720 parent: &El,
721 parent_rect: Rect,
722 child: &El,
723 child_rect: Rect,
724 r: &mut LintReport,
725 blame: Source,
726) {
727 let pr = parent.radius;
728 let cr = child.radius;
729 let tl = (
731 pr.tl,
732 cr.tl,
733 Rect::new(parent_rect.x, parent_rect.y, pr.tl, pr.tl),
734 );
735 let tr = (
736 pr.tr,
737 cr.tr,
738 Rect::new(
739 parent_rect.x + parent_rect.w - pr.tr,
740 parent_rect.y,
741 pr.tr,
742 pr.tr,
743 ),
744 );
745 let br = (
746 pr.br,
747 cr.br,
748 Rect::new(
749 parent_rect.x + parent_rect.w - pr.br,
750 parent_rect.y + parent_rect.h - pr.br,
751 pr.br,
752 pr.br,
753 ),
754 );
755 let bl = (
756 pr.bl,
757 cr.bl,
758 Rect::new(
759 parent_rect.x,
760 parent_rect.y + parent_rect.h - pr.bl,
761 pr.bl,
762 pr.bl,
763 ),
764 );
765 let leaks_at = |(p_r, c_r, corner_box): (f32, f32, Rect)| -> bool {
766 if p_r <= 0.5 || c_r + 0.5 >= p_r {
767 return false;
768 }
769 match child_rect.intersect(corner_box) {
770 Some(overlap) => overlap.w >= 0.5 && overlap.h >= 0.5,
771 None => false,
772 }
773 };
774 let (leak_tl, leak_tr, leak_br, leak_bl) =
775 (leaks_at(tl), leaks_at(tr), leaks_at(br), leaks_at(bl));
776 if !(leak_tl || leak_tr || leak_br || leak_bl) {
777 return;
778 }
779 let (descriptor, helper) = match (leak_tl, leak_tr, leak_br, leak_bl) {
780 (true, true, false, false) => ("the parent's top corners", "Corners::top(...)"),
781 (false, false, true, true) => ("the parent's bottom corners", "Corners::bottom(...)"),
782 (true, false, false, true) => ("the parent's left corners", "Corners::left(...)"),
783 (false, true, true, false) => ("the parent's right corners", "Corners::right(...)"),
784 (true, true, true, true) => ("the parent's corners", "Corners::all(...)"),
785 _ => (
787 "a parent corner",
788 "Corners { tl, tr, br, bl } with the matching corner set",
789 ),
790 };
791 r.findings.push(Finding {
792 kind: FindingKind::CornerStackup,
793 node_id: child.computed_id.clone(),
794 source: blame,
795 message: format!(
796 "filled child paints into {descriptor} (rounded parent, max radius={pr_max:.0}) — \
797 the flat corners obscure the parent's curve and stroke. \
798 Set `.radius({helper})` on the child so its corners follow the parent's curve, \
799 or add padding to the parent so the child is inset from the curve.",
800 pr_max = pr.max(),
801 ),
802 });
803}
804
805fn check_unpadded_surface_panel(
816 panel: &El,
817 panel_rect: Rect,
818 ui_state: &UiState,
819 r: &mut LintReport,
820 blame: Source,
821) {
822 let touch_eps = crate::tokens::RING_WIDTH;
825 const PAD_EPS: f32 = 0.5;
828
829 let mut top = (false, false);
831 let mut right = (false, false);
832 let mut bottom = (false, false);
833 let mut left = (false, false);
834
835 for c in &panel.children {
836 let cr = ui_state.rect(&c.computed_id);
837 if cr.w <= PAD_EPS || cr.h <= PAD_EPS {
838 continue;
840 }
841 if (cr.y - panel_rect.y).abs() <= touch_eps {
842 top.0 = true;
843 if c.padding.top > PAD_EPS {
844 top.1 = true;
845 }
846 }
847 if (panel_rect.right() - cr.right()).abs() <= touch_eps {
848 right.0 = true;
849 if c.padding.right > PAD_EPS {
850 right.1 = true;
851 }
852 }
853 if (panel_rect.bottom() - cr.bottom()).abs() <= touch_eps {
854 bottom.0 = true;
855 if c.padding.bottom > PAD_EPS {
856 bottom.1 = true;
857 }
858 }
859 if (cr.x - panel_rect.x).abs() <= touch_eps {
860 left.0 = true;
861 if c.padding.left > PAD_EPS {
862 left.1 = true;
863 }
864 }
865 }
866
867 let pad = panel.padding;
868 let mut sides: Vec<&'static str> = Vec::new();
869 if pad.top <= PAD_EPS && top.0 && !top.1 {
870 sides.push("top");
871 }
872 if pad.right <= PAD_EPS && right.0 && !right.1 {
873 sides.push("right");
874 }
875 if pad.bottom <= PAD_EPS && bottom.0 && !bottom.1 {
876 sides.push("bottom");
877 }
878 if pad.left <= PAD_EPS && left.0 && !left.1 {
879 sides.push("left");
880 }
881 if sides.is_empty() {
882 return;
883 }
884 let joined = sides.join("/");
885 r.findings.push(Finding {
886 kind: FindingKind::UnpaddedSurfacePanel,
887 node_id: panel.computed_id.clone(),
888 source: blame,
889 message: format!(
890 "Panel-surface children sit flush against the {joined} edge — \
891 wrap content in the slot anatomy (`card_header(...)` / `card_content(...)` / `card_footer(...)` \
892 each bake `SPACE_6` padding), or pad the panel itself \
893 (e.g. `.padding(Sides::all(tokens::SPACE_4))` for dense list-row cards).",
894 ),
895 });
896}
897
898fn check_focus_ring_obscured(
899 n: &El,
900 n_rect: Rect,
901 nearest_clip: &ClipCtx,
902 later_siblings: &[El],
903 ui_state: &UiState,
904 r: &mut LintReport,
905 blame: Source,
906) {
907 let band = n_rect.outset(n.paint_overflow);
908
909 let (clip_rect, check_horiz, check_vert) = match nearest_clip {
913 ClipCtx::None => (None, false, false),
914 ClipCtx::Static(rect) => (Some(*rect), true, true),
915 ClipCtx::Scrolling {
916 rect, scroll_axis, ..
917 } => match scroll_axis {
918 Axis::Column => (Some(*rect), true, false),
919 Axis::Row => (Some(*rect), false, true),
920 Axis::Overlay => (Some(*rect), true, true),
921 },
922 };
923 if let Some(clip) = clip_rect {
924 let dx_left = if check_horiz {
925 (clip.x - band.x).max(0.0)
926 } else {
927 0.0
928 };
929 let dx_right = if check_horiz {
930 (band.right() - clip.right()).max(0.0)
931 } else {
932 0.0
933 };
934 let dy_top = if check_vert {
935 (clip.y - band.y).max(0.0)
936 } else {
937 0.0
938 };
939 let dy_bottom = if check_vert {
940 (band.bottom() - clip.bottom()).max(0.0)
941 } else {
942 0.0
943 };
944 if dx_left + dx_right + dy_top + dy_bottom > 0.5 {
945 r.findings.push(Finding {
946 kind: FindingKind::FocusRingObscured,
947 node_id: n.computed_id.clone(),
948 source: blame,
949 message: format!(
950 "focus ring band clipped by ancestor scissor (L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}) — give a clipping ancestor padding ≥ tokens::RING_WIDTH on the clipped side",
951 ),
952 });
953 }
954 }
955
956 for sib in later_siblings {
960 let sib_rect = ui_state.rect(&sib.computed_id);
961 if let Some(side) = bleed_occlusion(n_rect, n.paint_overflow, sib_rect)
962 && paints_pixels(sib)
963 {
964 r.findings.push(Finding {
965 kind: FindingKind::FocusRingObscured,
966 node_id: n.computed_id.clone(),
967 source: blame,
968 message: format!(
969 "focus ring band occluded on the {side} edge by later-painted sibling {sib_id} — increase gap to ≥ tokens::RING_WIDTH or restructure so the neighbor doesn't sit on the edge",
970 sib_id = sib.computed_id,
971 ),
972 });
973 break;
975 }
976 }
977}
978
979fn check_scrollbar_overlap(
997 n: &El,
998 n_rect: Rect,
999 nearest_clip: &ClipCtx,
1000 ui_state: &UiState,
1001 r: &mut LintReport,
1002 blame: Source,
1003) {
1004 let ClipCtx::Scrolling { node_id, .. } = nearest_clip else {
1005 return;
1006 };
1007 let Some(track) = ui_state.scroll.thumb_tracks.get(node_id).copied() else {
1008 return;
1009 };
1010 let active_w = crate::tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE;
1016 let thumb_left = track.right() - active_w;
1017 let thumb_right = track.right();
1018 let overlap_x = n_rect.right().min(thumb_right) - n_rect.x.max(thumb_left);
1019 if overlap_x <= 0.5 {
1020 return;
1021 }
1022 r.findings.push(Finding {
1023 kind: FindingKind::ScrollbarObscuresFocusable,
1024 node_id: n.computed_id.clone(),
1025 source: blame,
1026 message: format!(
1027 "scrollbar thumb overlaps this focusable on the right edge by {overlap_x:.0}px (thumb x={thumb_left:.0}..{thumb_right:.0}; control x={ctrl_x:.0}..{ctrl_right:.0}) — move horizontal padding *inside* the scroll, onto a wrapper that constrains children to a narrower content rect, so the thumb sits in a reserved gutter to the right of content",
1028 ctrl_x = n_rect.x,
1029 ctrl_right = n_rect.right(),
1030 ),
1031 });
1032}
1033
1034fn paints_pixels(n: &El) -> bool {
1038 n.fill.is_some()
1039 || n.stroke.is_some()
1040 || n.image.is_some()
1041 || n.icon.is_some()
1042 || n.shadow > 0.0
1043 || n.text.is_some()
1044 || !matches!(n.surface_role, SurfaceRole::None)
1045}
1046
1047fn bleed_occlusion(n_rect: Rect, overflow: Sides, sib_rect: Rect) -> Option<&'static str> {
1052 const EPS: f32 = 0.5;
1053 let bands: [(&'static str, Rect); 4] = [
1054 (
1055 "top",
1056 Rect::new(n_rect.x, n_rect.y - overflow.top, n_rect.w, overflow.top),
1057 ),
1058 (
1059 "bottom",
1060 Rect::new(n_rect.x, n_rect.bottom(), n_rect.w, overflow.bottom),
1061 ),
1062 (
1063 "left",
1064 Rect::new(n_rect.x - overflow.left, n_rect.y, overflow.left, n_rect.h),
1065 ),
1066 (
1067 "right",
1068 Rect::new(n_rect.right(), n_rect.y, overflow.right, n_rect.h),
1069 ),
1070 ];
1071 for (side, band) in bands {
1072 if band.w <= 0.0 || band.h <= 0.0 {
1073 continue;
1074 }
1075 let iw = band.right().min(sib_rect.right()) - band.x.max(sib_rect.x);
1076 let ih = band.bottom().min(sib_rect.bottom()) - band.y.max(sib_rect.y);
1077 if iw > EPS && ih > EPS {
1078 return Some(side);
1079 }
1080 }
1081 None
1082}
1083
1084fn lint_row_alignment(
1085 n: &El,
1086 computed: Rect,
1087 ui_state: &UiState,
1088 r: &mut LintReport,
1089 blame: Source,
1090) {
1091 if !matches!(n.axis, Axis::Row) || !matches!(n.align, Align::Stretch) || n.children.len() < 2 {
1092 return;
1093 }
1094 if !n.children.iter().any(is_text_like_child) {
1095 return;
1096 }
1097
1098 let inner = computed.inset(n.padding);
1099 if inner.h <= 0.0 {
1100 return;
1101 }
1102
1103 for child in &n.children {
1104 if !is_fixed_visual_child(child) {
1105 continue;
1106 }
1107 let child_rect = ui_state.rect(&child.computed_id);
1108 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1109 let visibly_short = child_rect.h + 2.0 < inner.h;
1110 if top_pinned && visibly_short {
1111 r.findings.push(Finding {
1112 kind: FindingKind::Alignment,
1113 node_id: n.computed_id.clone(),
1114 source: blame,
1115 message: "row has a fixed-size visual child pinned to the top beside text; add .align(Align::Center) to vertically center row content"
1116 .to_string(),
1117 });
1118 return;
1119 }
1120 }
1121}
1122
1123fn lint_overlay_alignment(
1124 n: &El,
1125 computed: Rect,
1126 ui_state: &UiState,
1127 r: &mut LintReport,
1128 blame: Source,
1129) {
1130 if !matches!(n.axis, Axis::Overlay)
1131 || n.children.is_empty()
1132 || !matches!(n.align, Align::Start | Align::Stretch)
1133 || !matches!(n.justify, Justify::Start | Justify::SpaceBetween)
1134 || !has_visible_surface(n)
1135 {
1136 return;
1137 }
1138
1139 let inner = computed.inset(n.padding);
1140 if inner.w <= 0.0 || inner.h <= 0.0 {
1141 return;
1142 }
1143
1144 for child in &n.children {
1145 if !is_fixed_visual_child(child) {
1146 continue;
1147 }
1148 let child_rect = ui_state.rect(&child.computed_id);
1149 let left_pinned = (child_rect.x - inner.x).abs() <= 0.5;
1150 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1151 let visibly_narrow = child_rect.w + 2.0 < inner.w;
1152 let visibly_short = child_rect.h + 2.0 < inner.h;
1153 if left_pinned && top_pinned && visibly_narrow && visibly_short {
1154 r.findings.push(Finding {
1155 kind: FindingKind::Alignment,
1156 node_id: n.computed_id.clone(),
1157 source: blame,
1158 message: "overlay has a smaller fixed-size visual child pinned to the top-left; add .align(Align::Center).justify(Justify::Center) to center overlay content"
1159 .to_string(),
1160 });
1161 return;
1162 }
1163 }
1164}
1165
1166fn lint_row_visual_text_spacing(n: &El, ui_state: &UiState, r: &mut LintReport, blame: Source) {
1167 if !matches!(n.axis, Axis::Row) || n.children.len() < 2 {
1168 return;
1169 }
1170
1171 for pair in n.children.windows(2) {
1172 let [visual, text] = pair else {
1173 continue;
1174 };
1175 if !is_visual_cluster_child(visual) || !is_text_like_child(text) {
1176 continue;
1177 }
1178
1179 let visual_rect = ui_state.rect(&visual.computed_id);
1180 let text_rect = ui_state.rect(&text.computed_id);
1181 let gap = text_rect.x - visual_rect.right();
1182 if gap < 4.0 {
1183 r.findings.push(Finding {
1184 kind: FindingKind::Spacing,
1185 node_id: n.computed_id.clone(),
1186 source: blame,
1187 message: format!(
1188 "row places text {:.0}px after an icon/control slot; add .gap(tokens::SPACE_2) or use a stock menu/list row",
1189 gap.max(0.0)
1190 ),
1191 });
1192 return;
1193 }
1194 }
1195}
1196
1197fn is_text_like_child(c: &El) -> bool {
1198 c.text.is_some()
1199 || c.children
1200 .iter()
1201 .any(|child| child.text.is_some() || matches!(child.kind, Kind::Text | Kind::Heading))
1202}
1203
1204fn has_visible_surface(n: &El) -> bool {
1205 n.fill.is_some() || n.stroke.is_some()
1206}
1207
1208fn is_fixed_visual_child(c: &El) -> bool {
1209 let fixed_height = matches!(c.height, Size::Fixed(_));
1210 fixed_height
1211 && (c.icon.is_some()
1212 || matches!(c.kind, Kind::Badge)
1213 || matches!(
1214 c.metrics_role,
1215 Some(
1216 MetricsRole::Button
1217 | MetricsRole::IconButton
1218 | MetricsRole::Input
1219 | MetricsRole::Badge
1220 | MetricsRole::TabTrigger
1221 | MetricsRole::ChoiceControl
1222 | MetricsRole::Slider
1223 | MetricsRole::Progress
1224 )
1225 ))
1226}
1227
1228fn is_visual_cluster_child(c: &El) -> bool {
1229 let fixed_box = matches!(c.width, Size::Fixed(_)) && matches!(c.height, Size::Fixed(_));
1230 fixed_box
1231 && (c.icon.is_some()
1232 || matches!(c.kind, Kind::Badge)
1233 || matches!(
1234 c.metrics_role,
1235 Some(MetricsRole::IconButton | MetricsRole::Badge | MetricsRole::ChoiceControl)
1236 )
1237 || (has_visible_surface(c) && c.children.iter().any(is_fixed_visual_child)))
1238}
1239
1240fn rect_contains(parent: Rect, child: Rect, tol: f32) -> bool {
1241 child.x >= parent.x - tol
1242 && child.y >= parent.y - tol
1243 && child.right() <= parent.right() + tol
1244 && child.bottom() <= parent.bottom() + tol
1245}
1246
1247fn flex_main_axis_overflowed(parent: &El, parent_rect: Rect, ui_state: &UiState) -> bool {
1253 let n = parent.children.len();
1254 if n == 0 {
1255 return false;
1256 }
1257 let inner = parent_rect.inset(parent.padding);
1258 let inner_main = match parent.axis {
1259 Axis::Row => inner.w,
1260 Axis::Column => inner.h,
1261 Axis::Overlay => return false,
1262 };
1263 let total_gap = parent.gap * n.saturating_sub(1) as f32;
1264 let consumed: f32 = parent
1265 .children
1266 .iter()
1267 .map(|c| {
1268 let r = ui_state.rect(&c.computed_id);
1269 match parent.axis {
1270 Axis::Row => r.w,
1271 Axis::Column => r.h,
1272 Axis::Overlay => 0.0,
1273 }
1274 })
1275 .sum();
1276 consumed + total_gap > inner_main + 0.5
1277}
1278
1279fn short_path(p: &str) -> String {
1280 let parts: Vec<&str> = p.split(['/', '\\']).collect();
1281 if parts.len() >= 2 {
1282 format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
1283 } else {
1284 p.to_string()
1285 }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290 use super::*;
1291
1292 fn lint_one(mut root: El) -> LintReport {
1293 let mut ui_state = UiState::new();
1294 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1295 lint(&root, &ui_state)
1296 }
1297
1298 #[test]
1299 fn clipped_nowrap_text_reports_text_overflow() {
1300 let root = crate::text("A very long dashboard label")
1301 .width(Size::Fixed(42.0))
1302 .height(Size::Fixed(20.0));
1303
1304 let report = lint_one(root);
1305
1306 assert!(
1307 report
1308 .findings
1309 .iter()
1310 .any(|finding| finding.kind == FindingKind::TextOverflow),
1311 "{}",
1312 report.text()
1313 );
1314 }
1315
1316 #[test]
1317 fn ellipsis_nowrap_text_satisfies_horizontal_overflow_policy() {
1318 let root = crate::text("A very long dashboard label")
1319 .ellipsis()
1320 .width(Size::Fixed(42.0))
1321 .height(Size::Fixed(20.0));
1322
1323 let report = lint_one(root);
1324
1325 assert!(
1326 !report
1327 .findings
1328 .iter()
1329 .any(|finding| finding.kind == FindingKind::TextOverflow),
1330 "{}",
1331 report.text()
1332 );
1333 }
1334
1335 #[test]
1336 fn hug_ellipsis_in_overflowing_row_reports_dead_chain_issue_19() {
1337 let row = crate::row([
1346 crate::text("short_label"),
1347 crate::text("a long descriptive body that should truncate but cannot").ellipsis(),
1348 crate::text("right_side_metadata"),
1349 ])
1350 .width(Size::Fixed(160.0))
1351 .height(Size::Fixed(20.0));
1352
1353 let report = lint_one(row);
1354
1355 assert!(
1356 report
1357 .findings
1358 .iter()
1359 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1360 "expected dead-ellipsis finding pointing at Hug text\n{}",
1361 report.text()
1362 );
1363 }
1364
1365 #[test]
1366 fn hug_ellipsis_in_non_overflowing_row_is_quiet() {
1367 let row = crate::row([crate::text("ok").ellipsis()])
1372 .width(Size::Fixed(160.0))
1373 .height(Size::Fixed(20.0));
1374
1375 let report = lint_one(row);
1376
1377 assert!(
1378 !report
1379 .findings
1380 .iter()
1381 .any(|f| f.kind == FindingKind::TextOverflow),
1382 "{}",
1383 report.text()
1384 );
1385 }
1386
1387 #[test]
1388 fn fill_ellipsis_in_overflowing_row_is_quiet() {
1389 let row = crate::row([
1394 crate::text("short_label"),
1395 crate::text("a long descriptive body that should truncate but cannot")
1396 .width(Size::Fill(1.0))
1397 .ellipsis(),
1398 crate::text("right_side_metadata"),
1399 ])
1400 .width(Size::Fixed(160.0))
1401 .height(Size::Fixed(20.0));
1402
1403 let report = lint_one(row);
1404
1405 assert!(
1406 !report
1407 .findings
1408 .iter()
1409 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1410 "{}",
1411 report.text()
1412 );
1413 }
1414
1415 #[test]
1416 fn padding_eats_fixed_height_button_reports_padding_advice() {
1417 let root = crate::row([crate::button("Resume")
1427 .height(Size::Fixed(30.0))
1428 .padding(crate::tokens::SPACE_2)]);
1429
1430 let report = lint_one(root);
1431
1432 let finding = report
1433 .findings
1434 .iter()
1435 .find(|f| f.kind == FindingKind::Overflow)
1436 .unwrap_or_else(|| {
1437 panic!(
1438 "expected an Overflow finding for the padding-eats-height shape\n{}",
1439 report.text()
1440 )
1441 });
1442 assert!(
1443 finding.message.contains("vertical padding") && finding.message.contains("Sides::xy"),
1444 "expected padding-y advice, got:\n{}\n{}",
1445 finding.message,
1446 report.text(),
1447 );
1448 assert!(
1449 !finding.message.contains("paragraph()") && !finding.message.contains("wrap_text()"),
1450 "padding-eats-height case should not recommend paragraph/wrap_text:\n{}",
1451 finding.message,
1452 );
1453 }
1454
1455 #[test]
1456 fn padding_eats_fixed_height_y_only_does_not_fire_when_height_is_hug() {
1457 let root = crate::row([crate::text("Resume").padding(crate::tokens::SPACE_2)]);
1461
1462 let report = lint_one(root);
1463
1464 assert!(
1465 !report
1466 .findings
1467 .iter()
1468 .any(|f| f.kind == FindingKind::Overflow || f.kind == FindingKind::TextOverflow),
1469 "{}",
1470 report.text()
1471 );
1472 }
1473
1474 #[test]
1475 fn text_taller_than_fixed_height_without_padding_reports_height_advice() {
1476 let root = crate::row([crate::text("body")
1481 .width(Size::Fixed(80.0))
1482 .height(Size::Fixed(12.0))]);
1483
1484 let report = lint_one(root);
1485
1486 let finding = report
1487 .findings
1488 .iter()
1489 .find(|f| f.kind == FindingKind::Overflow)
1490 .unwrap_or_else(|| {
1491 panic!(
1492 "expected an Overflow finding for text-taller-than-box\n{}",
1493 report.text()
1494 )
1495 });
1496 assert!(
1497 finding.message.contains("exceeds box height") && finding.message.contains("height"),
1498 "expected height-advice message, got:\n{}",
1499 finding.message,
1500 );
1501 assert!(
1502 !finding.message.contains("vertical padding"),
1503 "no-padding case should not blame padding:\n{}",
1504 finding.message,
1505 );
1506 }
1507
1508 #[test]
1509 fn padding_aware_text_overflow_fires_when_text_spills_past_padded_region() {
1510 let leaf = crate::text("dashboard")
1520 .width(Size::Fixed(80.0))
1521 .height(Size::Fixed(28.0))
1522 .padding(Sides::xy(20.0, 0.0));
1523 let root = crate::row([leaf]);
1524
1525 let report = lint_one(root);
1526
1527 assert!(
1528 report
1529 .findings
1530 .iter()
1531 .any(|finding| finding.kind == FindingKind::TextOverflow),
1532 "{}",
1533 report.text()
1534 );
1535 }
1536
1537 #[test]
1538 fn stretch_row_with_top_pinned_icon_and_text_suggests_center_alignment() {
1539 let root = crate::row([
1540 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1541 crate::text("Settings").width(Size::Fill(1.0)),
1542 ])
1543 .height(Size::Fixed(36.0));
1544
1545 let report = lint_one(root);
1546
1547 assert!(
1548 report
1549 .findings
1550 .iter()
1551 .any(|finding| finding.kind == FindingKind::Alignment
1552 && finding.message.contains(".align(Align::Center)")),
1553 "{}",
1554 report.text()
1555 );
1556 }
1557
1558 #[test]
1559 fn centered_row_with_icon_and_text_satisfies_alignment_policy() {
1560 let root = crate::row([
1561 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1562 crate::text("Settings").width(Size::Fill(1.0)),
1563 ])
1564 .height(Size::Fixed(36.0))
1565 .align(Align::Center);
1566
1567 let report = lint_one(root);
1568
1569 assert!(
1570 !report
1571 .findings
1572 .iter()
1573 .any(|finding| finding.kind == FindingKind::Alignment),
1574 "{}",
1575 report.text()
1576 );
1577 }
1578
1579 #[test]
1580 fn row_with_icon_slot_touching_text_reports_spacing() {
1581 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1582 .align(Align::Center)
1583 .justify(Justify::Center)
1584 .fill(crate::tokens::MUTED)
1585 .width(Size::Fixed(26.0))
1586 .height(Size::Fixed(26.0));
1587 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1588 .height(Size::Fixed(32.0))
1589 .align(Align::Center);
1590
1591 let report = lint_one(root);
1592
1593 assert!(
1594 report
1595 .findings
1596 .iter()
1597 .any(|finding| finding.kind == FindingKind::Spacing
1598 && finding.message.contains(".gap(tokens::SPACE_2)")),
1599 "{}",
1600 report.text()
1601 );
1602 }
1603
1604 #[test]
1605 fn row_with_icon_slot_and_text_gap_satisfies_spacing_policy() {
1606 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1607 .align(Align::Center)
1608 .justify(Justify::Center)
1609 .fill(crate::tokens::MUTED)
1610 .width(Size::Fixed(26.0))
1611 .height(Size::Fixed(26.0));
1612 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1613 .height(Size::Fixed(32.0))
1614 .align(Align::Center)
1615 .gap(crate::tokens::SPACE_2);
1616
1617 let report = lint_one(root);
1618
1619 assert!(
1620 !report
1621 .findings
1622 .iter()
1623 .any(|finding| finding.kind == FindingKind::Spacing),
1624 "{}",
1625 report.text()
1626 );
1627 }
1628
1629 #[test]
1630 fn overlay_with_top_left_pinned_icon_suggests_center_alignment() {
1631 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1632 .fill(crate::tokens::MUTED)
1633 .width(Size::Fixed(26.0))
1634 .height(Size::Fixed(26.0));
1635 let root = crate::column([icon_slot]);
1636
1637 let report = lint_one(root);
1638
1639 assert!(
1640 report
1641 .findings
1642 .iter()
1643 .any(|finding| finding.kind == FindingKind::Alignment
1644 && finding.message.contains(".justify(Justify::Center)")),
1645 "{}",
1646 report.text()
1647 );
1648 }
1649
1650 #[test]
1651 fn centered_overlay_icon_satisfies_alignment_policy() {
1652 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1653 .align(Align::Center)
1654 .justify(Justify::Center)
1655 .fill(crate::tokens::MUTED)
1656 .width(Size::Fixed(26.0))
1657 .height(Size::Fixed(26.0));
1658 let root = crate::column([icon_slot]);
1659
1660 let report = lint_one(root);
1661
1662 assert!(
1663 !report
1664 .findings
1665 .iter()
1666 .any(|finding| finding.kind == FindingKind::Alignment),
1667 "{}",
1668 report.text()
1669 );
1670 }
1671
1672 #[test]
1673 fn overflow_findings_attribute_to_nearest_user_source_ancestor() {
1674 let user_source = Source {
1679 file: "src/screen.rs",
1680 line: 42,
1681 from_library: false,
1682 };
1683 let widget_source = Source {
1684 file: "src/widgets/tabs.rs",
1685 line: 200,
1686 from_library: true,
1687 };
1688
1689 let mut leaf = crate::text("A very long dashboard label")
1690 .width(Size::Fixed(40.0))
1691 .height(Size::Fixed(20.0));
1692 leaf.source = widget_source;
1693
1694 let mut root = crate::row([leaf])
1695 .width(Size::Fixed(160.0))
1696 .height(Size::Fixed(48.0));
1697 root.source = user_source;
1698
1699 let mut ui_state = UiState::new();
1700 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1701 let report = lint(&root, &ui_state);
1702
1703 let text_overflow = report
1704 .findings
1705 .iter()
1706 .find(|f| f.kind == FindingKind::TextOverflow)
1707 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1708 assert_eq!(text_overflow.source.file, user_source.file);
1709 assert_eq!(text_overflow.source.line, user_source.line);
1710 }
1711
1712 #[test]
1713 fn overflow_finding_self_attributes_when_node_is_already_user_source() {
1714 let mut node = crate::text("A very long dashboard label")
1715 .width(Size::Fixed(40.0))
1716 .height(Size::Fixed(20.0));
1717 let user_source = Source {
1718 file: "src/screen.rs",
1719 line: 99,
1720 from_library: false,
1721 };
1722 node.source = user_source;
1723
1724 let mut ui_state = UiState::new();
1725 layout::layout(&mut node, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1726 let report = lint(&node, &ui_state);
1727
1728 let text_overflow = report
1729 .findings
1730 .iter()
1731 .find(|f| f.kind == FindingKind::TextOverflow)
1732 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1733 assert_eq!(text_overflow.source.line, user_source.line);
1734 }
1735
1736 #[test]
1737 fn overflow_lint_fires_for_external_app_paths_issue_13() {
1738 let user_source = Source {
1745 file: "src/sidebar.rs",
1746 line: 17,
1747 from_library: false,
1748 };
1749 let mut child = crate::column(Vec::<El>::new())
1750 .width(Size::Fixed(32.0))
1751 .height(Size::Fixed(32.0));
1752 child.source = user_source;
1753
1754 let mut row = crate::row([child])
1755 .width(Size::Fixed(256.0))
1756 .height(Size::Fixed(28.0));
1757 row.source = user_source;
1758
1759 let mut ui_state = UiState::new();
1760 layout::layout(&mut row, &mut ui_state, Rect::new(0.0, 0.0, 256.0, 28.0));
1761 let report = lint(&row, &ui_state);
1762
1763 assert!(
1764 report
1765 .findings
1766 .iter()
1767 .any(|f| f.kind == FindingKind::Overflow),
1768 "expected an Overflow finding for the 32px child in a 28px row\n{}",
1769 report.text()
1770 );
1771 }
1772
1773 #[test]
1774 fn overflow_finding_suppressed_when_no_user_ancestor_exists() {
1775 let widget_source = Source {
1778 file: "src/widgets/tabs.rs",
1779 line: 200,
1780 from_library: true,
1781 };
1782 let mut leaf = crate::text("A very long dashboard label")
1783 .width(Size::Fixed(40.0))
1784 .height(Size::Fixed(20.0));
1785 leaf.source = widget_source;
1786
1787 let mut wrapper = crate::row([leaf])
1788 .width(Size::Fixed(160.0))
1789 .height(Size::Fixed(48.0));
1790 wrapper.source = widget_source;
1791
1792 let mut ui_state = UiState::new();
1793 layout::layout(
1794 &mut wrapper,
1795 &mut ui_state,
1796 Rect::new(0.0, 0.0, 160.0, 48.0),
1797 );
1798 let report = lint(&wrapper, &ui_state);
1799
1800 assert!(
1801 !report
1802 .findings
1803 .iter()
1804 .any(|f| f.kind == FindingKind::TextOverflow || f.kind == FindingKind::Overflow),
1805 "{}",
1806 report.text()
1807 );
1808 }
1809
1810 #[test]
1811 fn panel_role_without_fill_reports_missing_surface_fill() {
1812 let root = crate::column([crate::text("body")])
1813 .surface_role(SurfaceRole::Panel)
1814 .width(Size::Fixed(120.0))
1815 .height(Size::Fixed(40.0));
1816
1817 let report = lint_one(root);
1818
1819 assert!(
1820 report
1821 .findings
1822 .iter()
1823 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1824 "{}",
1825 report.text()
1826 );
1827 }
1828
1829 #[test]
1830 fn panel_role_with_fill_satisfies_surface_policy() {
1831 let root = crate::column([crate::text("body")])
1832 .surface_role(SurfaceRole::Panel)
1833 .fill(crate::tokens::CARD)
1834 .width(Size::Fixed(120.0))
1835 .height(Size::Fixed(40.0));
1836
1837 let report = lint_one(root);
1838
1839 assert!(
1840 !report
1841 .findings
1842 .iter()
1843 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1844 "{}",
1845 report.text()
1846 );
1847 }
1848
1849 #[test]
1850 fn card_widget_satisfies_surface_policy() {
1851 let root = crate::widgets::card::card([crate::text("body")])
1852 .width(Size::Fixed(120.0))
1853 .height(Size::Fixed(40.0));
1854
1855 let report = lint_one(root);
1856
1857 assert!(
1858 !report
1859 .findings
1860 .iter()
1861 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1862 "{}",
1863 report.text()
1864 );
1865 }
1866
1867 #[test]
1868 fn handrolled_card_recipe_reports_reinvented_widget() {
1869 let root = crate::column([crate::text("body")])
1872 .fill(crate::tokens::CARD)
1873 .stroke(crate::tokens::BORDER)
1874 .radius(crate::tokens::RADIUS_LG)
1875 .width(Size::Fixed(160.0))
1876 .height(Size::Fixed(48.0));
1877
1878 let report = lint_one(root);
1879
1880 assert!(
1881 report
1882 .findings
1883 .iter()
1884 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("card(")),
1885 "{}",
1886 report.text()
1887 );
1888 }
1889
1890 #[test]
1891 fn real_card_widget_does_not_report_reinvented_widget() {
1892 let root = crate::widgets::card::card([crate::text("body")])
1895 .width(Size::Fixed(160.0))
1896 .height(Size::Fixed(48.0));
1897
1898 let report = lint_one(root);
1899
1900 assert!(
1901 !report
1902 .findings
1903 .iter()
1904 .any(|f| f.kind == FindingKind::ReinventedWidget),
1905 "{}",
1906 report.text()
1907 );
1908 }
1909
1910 #[test]
1911 fn handrolled_sidebar_recipe_reports_reinvented_widget() {
1912 let root = crate::column([crate::text("nav")])
1915 .fill(crate::tokens::CARD)
1916 .stroke(crate::tokens::BORDER)
1917 .width(Size::Fixed(crate::tokens::SIDEBAR_WIDTH))
1918 .height(Size::Fill(1.0));
1919
1920 let report = lint_one(root);
1921
1922 assert!(
1923 report
1924 .findings
1925 .iter()
1926 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("sidebar(")),
1927 "{}",
1928 report.text()
1929 );
1930 }
1931
1932 #[test]
1933 fn real_sidebar_widget_does_not_report_reinvented_widget() {
1934 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
1937
1938 let report = lint_one(root);
1939
1940 assert!(
1941 !report
1942 .findings
1943 .iter()
1944 .any(|f| f.kind == FindingKind::ReinventedWidget),
1945 "{}",
1946 report.text()
1947 );
1948 }
1949
1950 #[test]
1951 fn empty_visual_swatch_does_not_report_reinvented_widget() {
1952 let root = crate::column(Vec::<El>::new())
1956 .fill(crate::tokens::CARD)
1957 .stroke(crate::tokens::BORDER)
1958 .radius(crate::tokens::RADIUS_SM)
1959 .width(Size::Fixed(42.0))
1960 .height(Size::Fixed(34.0));
1961
1962 let report = lint_one(root);
1963
1964 assert!(
1965 !report
1966 .findings
1967 .iter()
1968 .any(|f| f.kind == FindingKind::ReinventedWidget),
1969 "{}",
1970 report.text()
1971 );
1972 }
1973
1974 #[test]
1975 fn plain_column_does_not_report_reinvented_widget() {
1976 let root = crate::column([crate::text("a"), crate::text("b")])
1978 .gap(crate::tokens::SPACE_2)
1979 .width(Size::Fixed(120.0))
1980 .height(Size::Fixed(40.0));
1981
1982 let report = lint_one(root);
1983
1984 assert!(
1985 !report
1986 .findings
1987 .iter()
1988 .any(|f| f.kind == FindingKind::ReinventedWidget),
1989 "{}",
1990 report.text()
1991 );
1992 }
1993
1994 #[test]
1995 fn fill_providing_roles_do_not_require_explicit_fill() {
1996 let root = crate::column([crate::text("body")])
2001 .surface_role(SurfaceRole::Sunken)
2002 .width(Size::Fixed(120.0))
2003 .height(Size::Fixed(40.0));
2004
2005 let report = lint_one(root);
2006
2007 assert!(
2008 !report
2009 .findings
2010 .iter()
2011 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2012 "{}",
2013 report.text()
2014 );
2015 }
2016
2017 #[test]
2018 fn focus_ring_lint_fires_when_input_clipped_on_scroll_cross_axis() {
2019 let selection = crate::selection::Selection::default();
2022 let mut root = crate::tree::scroll([crate::tree::column([
2023 crate::widgets::text_input::text_input("", &selection, "field"),
2024 ])])
2025 .width(Size::Fixed(300.0))
2026 .height(Size::Fixed(120.0));
2027 let mut state = UiState::new();
2028 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2029 let report = lint(&root, &state);
2030
2031 assert!(
2032 report.findings.iter().any(|f| {
2033 f.kind == FindingKind::FocusRingObscured
2034 && f.message.contains("clipped")
2035 && (f.message.contains("L=2") || f.message.contains("R=2"))
2036 }),
2037 "expected a FocusRingObscured clipping finding (L=2 or R=2)\n{}",
2038 report.text()
2039 );
2040 }
2041
2042 #[test]
2043 fn focus_ring_lint_silenced_when_scroll_supplies_horizontal_slack() {
2044 let selection = crate::selection::Selection::default();
2047 let mut root =
2048 crate::tree::scroll(
2049 [crate::tree::column([crate::widgets::text_input::text_input(
2050 "", &selection, "field",
2051 )])
2052 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))],
2053 )
2054 .width(Size::Fixed(300.0))
2055 .height(Size::Fixed(120.0));
2056 let mut state = UiState::new();
2057 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2058 let report = lint(&root, &state);
2059
2060 assert!(
2061 !report
2062 .findings
2063 .iter()
2064 .any(|f| f.kind == FindingKind::FocusRingObscured),
2065 "{}",
2066 report.text()
2067 );
2068 }
2069
2070 #[test]
2071 fn focus_ring_lint_skips_clipping_on_scroll_axis() {
2072 let selection = crate::selection::Selection::default();
2076 let mut root = crate::tree::scroll([crate::tree::column([
2077 crate::tree::column(Vec::<El>::new())
2079 .width(Size::Fill(1.0))
2080 .height(Size::Fixed(200.0)),
2081 crate::widgets::text_input::text_input("", &selection, "field"),
2082 ])
2083 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))])
2084 .width(Size::Fixed(300.0))
2085 .height(Size::Fixed(120.0));
2086 let mut state = UiState::new();
2087 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2088 let report = lint(&root, &state);
2089
2090 assert!(
2091 !report
2092 .findings
2093 .iter()
2094 .any(|f| f.kind == FindingKind::FocusRingObscured),
2095 "expected no FocusRingObscured finding for a row clipped on the scroll axis\n{}",
2096 report.text()
2097 );
2098 }
2099
2100 #[test]
2101 fn focus_ring_lint_fires_on_static_clip_in_any_direction() {
2102 let selection = crate::selection::Selection::default();
2105 let mut root = crate::tree::column([crate::widgets::text_input::text_input(
2106 "", &selection, "field",
2107 )])
2108 .clip()
2109 .width(Size::Fixed(300.0))
2110 .height(Size::Fixed(120.0));
2111 let mut state = UiState::new();
2112 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2113 let report = lint(&root, &state);
2114
2115 assert!(
2116 report.findings.iter().any(|f| {
2117 f.kind == FindingKind::FocusRingObscured && f.message.contains("clipped")
2118 }),
2119 "expected a static-clip FocusRingObscured finding\n{}",
2120 report.text()
2121 );
2122 }
2123
2124 #[test]
2125 fn focus_ring_lint_fires_on_painted_later_sibling_overlap() {
2126 let selection = crate::selection::Selection::default();
2130 let mut root = crate::tree::row([
2131 crate::widgets::text_input::text_input("", &selection, "field"),
2132 crate::tree::column([crate::text("neighbor")])
2133 .fill(crate::tokens::CARD)
2134 .stroke(crate::tokens::BORDER)
2135 .width(Size::Fixed(80.0))
2136 .height(Size::Fixed(32.0)),
2137 ])
2138 .gap(0.0)
2139 .width(Size::Fixed(400.0))
2140 .height(Size::Fixed(32.0));
2141 let mut state = UiState::new();
2142 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2143 let report = lint(&root, &state);
2144
2145 assert!(
2146 report.findings.iter().any(|f| {
2147 f.kind == FindingKind::FocusRingObscured
2148 && f.message.contains("occluded")
2149 && f.message.contains("right")
2150 }),
2151 "expected an occlusion finding on the right edge\n{}",
2152 report.text()
2153 );
2154 }
2155
2156 #[test]
2157 fn focus_ring_lint_ignores_unpainted_structural_sibling() {
2158 let selection = crate::selection::Selection::default();
2161 let mut root = crate::tree::row([
2162 crate::widgets::text_input::text_input("", &selection, "field"),
2163 crate::tree::column(Vec::<El>::new())
2164 .width(Size::Fixed(80.0))
2165 .height(Size::Fixed(32.0)),
2166 ])
2167 .gap(0.0)
2168 .width(Size::Fixed(400.0))
2169 .height(Size::Fixed(32.0));
2170 let mut state = UiState::new();
2171 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2172 let report = lint(&root, &state);
2173
2174 assert!(
2175 !report
2176 .findings
2177 .iter()
2178 .any(|f| f.kind == FindingKind::FocusRingObscured),
2179 "{}",
2180 report.text()
2181 );
2182 }
2183
2184 #[test]
2185 fn scrollbar_overlap_lint_fires_when_thumb_covers_fill_child() {
2186 let body = crate::tree::column(
2190 (0..30)
2191 .map(|i| {
2192 crate::tree::row([
2193 crate::text(format!("Row {i}")),
2194 crate::tree::spacer(),
2195 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2196 ])
2197 .gap(crate::tokens::SPACE_2)
2198 .width(Size::Fill(1.0))
2199 })
2200 .collect::<Vec<_>>(),
2201 )
2202 .gap(crate::tokens::SPACE_2)
2203 .width(Size::Fill(1.0));
2204
2205 let mut root = crate::tree::scroll([body])
2206 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2207 .width(Size::Fixed(480.0))
2208 .height(Size::Fixed(320.0));
2209 let mut state = UiState::new();
2210 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2211 let report = lint(&root, &state);
2212
2213 assert!(
2214 report
2215 .findings
2216 .iter()
2217 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2218 "expected ScrollbarObscuresFocusable for a switch that reaches the scroll's inner.right()\n{}",
2219 report.text()
2220 );
2221 }
2222
2223 #[test]
2224 fn scrollbar_overlap_lint_silenced_when_padding_is_inside_scroll() {
2225 let body = crate::tree::column(
2229 (0..30)
2230 .map(|i| {
2231 crate::tree::row([
2232 crate::text(format!("Row {i}")),
2233 crate::tree::spacer(),
2234 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2235 ])
2236 .gap(crate::tokens::SPACE_2)
2237 .width(Size::Fill(1.0))
2238 })
2239 .collect::<Vec<_>>(),
2240 )
2241 .gap(crate::tokens::SPACE_2)
2242 .width(Size::Fill(1.0));
2243
2244 let mut root = crate::tree::scroll([crate::tree::column([body])
2245 .padding(Sides::xy(crate::tokens::SPACE_3, 0.0))
2246 .width(Size::Fill(1.0))])
2247 .padding(Sides::xy(0.0, crate::tokens::SPACE_2))
2248 .width(Size::Fixed(480.0))
2249 .height(Size::Fixed(320.0));
2250 let mut state = UiState::new();
2251 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2252 let report = lint(&root, &state);
2253
2254 assert!(
2255 !report
2256 .findings
2257 .iter()
2258 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2259 "expected no ScrollbarObscuresFocusable when padding is inside the scroll\n{}",
2260 report.text()
2261 );
2262 }
2263
2264 #[test]
2265 fn scrollbar_overlap_lint_quiet_when_content_does_not_overflow() {
2266 let body = crate::tree::column([crate::tree::row([
2271 crate::text("only row"),
2272 crate::tree::spacer(),
2273 crate::widgets::switch::switch(false).key("only-toggle"),
2274 ])
2275 .gap(crate::tokens::SPACE_2)
2276 .width(Size::Fill(1.0))])
2277 .gap(crate::tokens::SPACE_2)
2278 .width(Size::Fill(1.0));
2279
2280 let mut root = crate::tree::scroll([body])
2281 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2282 .width(Size::Fixed(480.0))
2283 .height(Size::Fixed(320.0));
2284 let mut state = UiState::new();
2285 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2286 let report = lint(&root, &state);
2287
2288 assert!(
2289 !report
2290 .findings
2291 .iter()
2292 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2293 "expected no ScrollbarObscuresFocusable when content fits in the viewport (no thumb rendered)\n{}",
2294 report.text()
2295 );
2296 }
2297
2298 #[test]
2299 fn unkeyed_tooltip_reports_dead_tooltip() {
2300 let root = crate::text("abc1234").tooltip("commit sha");
2306
2307 let report = lint_one(root);
2308
2309 assert!(
2310 report
2311 .findings
2312 .iter()
2313 .any(|f| f.kind == FindingKind::DeadTooltip),
2314 "expected DeadTooltip on unkeyed tooltipped text\n{}",
2315 report.text()
2316 );
2317 }
2318
2319 #[test]
2320 fn keyed_tooltip_satisfies_dead_tooltip_policy() {
2321 let root = crate::text("abc1234").key("sha").tooltip("commit sha");
2324
2325 let report = lint_one(root);
2326
2327 assert!(
2328 !report
2329 .findings
2330 .iter()
2331 .any(|f| f.kind == FindingKind::DeadTooltip),
2332 "{}",
2333 report.text()
2334 );
2335 }
2336
2337 #[test]
2338 fn unkeyed_tooltip_inside_keyed_ancestor_still_reports_dead_tooltip() {
2339 let root =
2345 crate::row([crate::text("inner detail").tooltip("never shown")]).key("outer-row");
2346
2347 let report = lint_one(root);
2348
2349 assert!(
2350 report
2351 .findings
2352 .iter()
2353 .any(|f| f.kind == FindingKind::DeadTooltip),
2354 "expected DeadTooltip on unkeyed leaf even with keyed ancestor\n{}",
2355 report.text()
2356 );
2357 }
2358
2359 #[test]
2360 fn focus_ring_lint_is_quiet_inside_form_after_padding_fix() {
2361 let selection = crate::selection::Selection::default();
2365 let mut root = crate::tree::scroll([crate::widgets::form::form([
2366 crate::widgets::form::form_item([crate::widgets::form::form_control(
2367 crate::widgets::text_input::text_input("", &selection, "field"),
2368 )]),
2369 ])])
2370 .width(Size::Fixed(300.0))
2371 .height(Size::Fixed(120.0));
2372 let mut state = UiState::new();
2373 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2374 let report = lint(&root, &state);
2375
2376 assert!(
2377 !report
2378 .findings
2379 .iter()
2380 .any(|f| f.kind == FindingKind::FocusRingObscured),
2381 "{}",
2382 report.text()
2383 );
2384 }
2385
2386 fn lint_one_with_metrics(mut root: El) -> LintReport {
2391 crate::metrics::ThemeMetrics::default().apply_to_tree(&mut root);
2392 let mut ui_state = UiState::new();
2393 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 120.0));
2394 lint(&root, &ui_state)
2395 }
2396
2397 #[test]
2398 fn handrolled_rounded_container_with_flat_filled_header_reports_corner_stackup() {
2399 let parent = crate::column([
2403 crate::row([crate::text("Header")])
2404 .fill(crate::tokens::MUTED)
2405 .width(Size::Fill(1.0))
2406 .height(Size::Fixed(24.0)),
2407 crate::row([crate::text("Body")])
2408 .width(Size::Fill(1.0))
2409 .height(Size::Fixed(60.0)),
2410 ])
2411 .fill(crate::tokens::CARD)
2412 .stroke(crate::tokens::BORDER)
2413 .radius(crate::tokens::RADIUS_LG)
2414 .width(Size::Fixed(160.0))
2415 .height(Size::Fixed(96.0));
2416
2417 let report = lint_one(parent);
2418
2419 let found = report
2420 .findings
2421 .iter()
2422 .find(|f| f.kind == FindingKind::CornerStackup);
2423 let found =
2424 found.unwrap_or_else(|| panic!("expected CornerStackup, got:\n{}", report.text()));
2425 assert!(
2426 found.message.contains("Corners::top"),
2427 "top-strip leak should suggest Corners::top, got: {}",
2428 found.message
2429 );
2430 }
2431
2432 #[test]
2433 fn handrolled_rounded_container_with_inset_child_does_not_report_corner_stackup() {
2434 let parent = crate::column([crate::row([crate::text("Header")])
2436 .fill(crate::tokens::MUTED)
2437 .width(Size::Fill(1.0))
2438 .height(Size::Fixed(24.0))])
2439 .fill(crate::tokens::CARD)
2440 .stroke(crate::tokens::BORDER)
2441 .radius(crate::tokens::RADIUS_LG)
2442 .padding(Sides::all(crate::tokens::RADIUS_LG))
2443 .width(Size::Fixed(160.0))
2444 .height(Size::Fixed(96.0));
2445
2446 let report = lint_one(parent);
2447 assert!(
2448 !report
2449 .findings
2450 .iter()
2451 .any(|f| f.kind == FindingKind::CornerStackup),
2452 "inset child should not trip the lint, got:\n{}",
2453 report.text()
2454 );
2455 }
2456
2457 #[test]
2458 fn handrolled_rounded_container_with_matching_corners_does_not_report_corner_stackup() {
2459 let parent = crate::column([crate::row([crate::text("Header")])
2460 .fill(crate::tokens::MUTED)
2461 .radius(Corners::top(crate::tokens::RADIUS_LG))
2462 .width(Size::Fill(1.0))
2463 .height(Size::Fixed(24.0))])
2464 .fill(crate::tokens::CARD)
2465 .stroke(crate::tokens::BORDER)
2466 .radius(crate::tokens::RADIUS_LG)
2467 .width(Size::Fixed(160.0))
2468 .height(Size::Fixed(96.0));
2469
2470 let report = lint_one(parent);
2471 assert!(
2472 !report
2473 .findings
2474 .iter()
2475 .any(|f| f.kind == FindingKind::CornerStackup),
2476 "matching corners should not trip the lint, got:\n{}",
2477 report.text()
2478 );
2479 }
2480
2481 #[test]
2482 fn canonical_card_recipe_does_not_report_corner_stackup_after_metrics() {
2483 let root = crate::widgets::card::card([
2486 crate::widgets::card::card_header([crate::text("Header")]).fill(crate::tokens::MUTED),
2487 crate::widgets::card::card_content([crate::text("Body")]),
2488 ])
2489 .width(Size::Fixed(180.0))
2490 .height(Size::Fixed(110.0));
2491
2492 let report = lint_one_with_metrics(root);
2493 assert!(
2494 !report
2495 .findings
2496 .iter()
2497 .any(|f| f.kind == FindingKind::CornerStackup),
2498 "canonical card_header(...).fill(...) recipe should be quiet after metrics pass, got:\n{}",
2499 report.text()
2500 );
2501 }
2502
2503 #[test]
2504 fn bare_card_with_flush_content_reports_unpadded_surface_panel_issue_24() {
2505 let root = crate::widgets::card::card([crate::row([
2510 crate::text("some title").bold(),
2511 crate::text("description line").muted(),
2512 ])
2513 .gap(crate::tokens::SPACE_2)
2514 .width(Size::Fill(1.0))])
2515 .width(Size::Fixed(200.0))
2516 .height(Size::Fixed(80.0));
2517
2518 let report = lint_one(root);
2519 let f = report
2520 .findings
2521 .iter()
2522 .find(|f| f.kind == FindingKind::UnpaddedSurfacePanel)
2523 .unwrap_or_else(|| {
2524 panic!(
2525 "expected UnpaddedSurfacePanel finding, got:\n{}",
2526 report.text()
2527 )
2528 });
2529 assert!(
2530 f.message.contains("top"),
2531 "expected the flushing-side list to call out `top`, got: {}",
2532 f.message
2533 );
2534 }
2535
2536 #[test]
2537 fn card_with_explicit_padding_does_not_report_unpadded_surface_panel() {
2538 let root = crate::widgets::card::card([
2541 crate::row([crate::text("title").bold()]).width(Size::Fill(1.0))
2542 ])
2543 .padding(Sides::all(crate::tokens::SPACE_4))
2544 .width(Size::Fixed(200.0))
2545 .height(Size::Fixed(60.0));
2546
2547 let report = lint_one(root);
2548 assert!(
2549 !report
2550 .findings
2551 .iter()
2552 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2553 "{}",
2554 report.text()
2555 );
2556 }
2557
2558 #[test]
2559 fn canonical_card_anatomy_does_not_report_unpadded_surface_panel() {
2560 let root = crate::widgets::card::card([
2564 crate::widgets::card::card_header([crate::widgets::card::card_title("Header")]),
2565 crate::widgets::card::card_content([crate::text("Body")]),
2566 crate::widgets::card::card_footer([crate::text("footer")]),
2567 ])
2568 .width(Size::Fixed(220.0))
2569 .height(Size::Fixed(160.0));
2570
2571 let report = lint_one(root);
2572 assert!(
2573 !report
2574 .findings
2575 .iter()
2576 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2577 "canonical slot anatomy should be quiet, got:\n{}",
2578 report.text()
2579 );
2580 }
2581
2582 #[test]
2583 fn sidebar_widget_does_not_report_unpadded_surface_panel() {
2584 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
2587
2588 let report = lint_one(root);
2589 assert!(
2590 !report
2591 .findings
2592 .iter()
2593 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2594 "{}",
2595 report.text()
2596 );
2597 }
2598}