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,
99 ScrollbarObscuresFocusable,
117 HitOverflowCollision,
131 DeadTooltip,
146 TooltipWithoutOverlayRoot,
158 CornerStackup,
175 UnpaddedSurfacePanel,
202 UnpaddedViewportLeaf,
224}
225
226#[derive(Clone, Debug, Default)]
227#[non_exhaustive]
228pub struct LintReport {
229 pub findings: Vec<Finding>,
230}
231
232impl LintReport {
233 pub fn retain(&mut self, mut pred: impl FnMut(&Finding) -> bool) {
241 self.findings.retain(|f| pred(f));
242 }
243
244 pub fn text(&self) -> String {
245 if self.findings.is_empty() {
246 return "no findings\n".to_string();
247 }
248 let mut s = String::new();
249 for f in &self.findings {
250 let _ = writeln!(
251 s,
252 "{kind:?} node={id} {source} :: {msg}",
253 kind = f.kind,
254 id = f.node_id,
255 source = if f.source.line == 0 {
256 "<no-source>".to_string()
257 } else {
258 format!("{}:{}", short_path(f.source.file), f.source.line)
259 },
260 msg = f.message,
261 );
262 }
263 s
264 }
265}
266
267pub fn lint(root: &El, ui_state: &UiState) -> LintReport {
277 let mut r = LintReport::default();
278 let mut seen_ids: std::collections::BTreeMap<String, usize> = Default::default();
279 let mut flat = FlatTree::new();
280 walk(
281 root,
282 None,
283 None,
284 &ClipCtx::None,
285 FlatTree::ROOT_LAYER,
286 ui_state,
287 &mut r,
288 &mut seen_ids,
289 &mut flat,
290 );
291 check_hit_overflow_collisions(&flat, &mut r);
297 check_focus_ring_occluded(&flat, &mut r);
298 for (id, n) in seen_ids {
299 if n > 1 {
300 r.findings.push(Finding {
301 kind: FindingKind::DuplicateId,
302 node_id: id.clone(),
303 source: Source::default(),
304 message: format!("{n} nodes share id {id}"),
305 });
306 }
307 }
308 check_tooltip_overlay_root(root, &mut r);
309 check_unpadded_viewport_leaves(root, ui_state, &mut r);
310 r
311}
312
313fn check_unpadded_viewport_leaves<'a>(root: &'a El, ui_state: &UiState, r: &mut LintReport) {
330 const PAD_EPS: f32 = 0.5;
331 let touch_eps = crate::tokens::RING_WIDTH;
332 let vp = ui_state.rect(&root.computed_id);
333 if vp.w <= PAD_EPS || vp.h <= PAD_EPS {
334 return;
335 }
336
337 let mut found: [Option<(&'a El, Source)>; 4] = [None; 4];
339
340 fn rec<'a>(
341 n: &'a El,
342 blame: Option<Source>,
343 is_root: bool,
344 vp: Rect,
345 touch_eps: f32,
346 ui_state: &UiState,
347 found: &mut [Option<(&'a El, Source)>; 4],
348 ) {
349 const PAD_EPS: f32 = 0.5;
350 let self_blame = if is_from_user(n.source) {
351 Some(n.source)
352 } else {
353 blame
354 };
355 let is_content_leaf =
356 n.text.is_some() || n.icon.is_some() || matches!(n.kind, Kind::Inlines | Kind::Math);
357 if is_content_leaf && !is_root {
358 let rect = ui_state.rect(&n.computed_id);
359 if rect.w > PAD_EPS && rect.h > PAD_EPS {
360 let sides = [
361 ((rect.y - vp.y).abs() <= touch_eps, n.padding.top, 0usize),
362 (
363 (vp.right() - rect.right()).abs() <= touch_eps,
364 n.padding.right,
365 1,
366 ),
367 (
368 (vp.bottom() - rect.bottom()).abs() <= touch_eps,
369 n.padding.bottom,
370 2,
371 ),
372 ((rect.x - vp.x).abs() <= touch_eps, n.padding.left, 3),
373 ];
374 for (touches, own_pad, side) in sides {
375 if touches && own_pad <= PAD_EPS && found[side].is_none() {
376 found[side] = Some((n, self_blame.unwrap_or(n.source)));
377 }
378 }
379 }
380 }
381 if matches!(n.kind, Kind::Inlines) {
382 return;
385 }
386 if matches!(n.kind, Kind::Scroll | Kind::VirtualList) {
387 return;
392 }
393 for c in &n.children {
394 rec(c, self_blame, false, vp, touch_eps, ui_state, found);
395 }
396 }
397 rec(root, None, true, vp, touch_eps, ui_state, &mut found);
398
399 const SIDE_NAMES: [&str; 4] = ["top", "right", "bottom", "left"];
400 let mut emitted: Vec<*const El> = Vec::new();
401 for (side, entry) in found.iter().enumerate() {
402 let Some((leaf, blame)) = entry else { continue };
403 if emitted.contains(&std::ptr::from_ref(*leaf)) {
404 continue; }
406 emitted.push(std::ptr::from_ref(*leaf));
407 let sides: Vec<&str> = (side..4)
408 .filter(|&j| matches!(found[j], Some((l, _)) if std::ptr::eq(l, *leaf)))
409 .map(|j| SIDE_NAMES[j])
410 .collect();
411 push_for(
412 r,
413 leaf,
414 Finding {
415 kind: FindingKind::UnpaddedViewportLeaf,
416 node_id: leaf.computed_id.clone(),
417 source: *blame,
418 message: format!(
419 "text/icon content sits flush against the viewport {} edge with no \
420 padding on that side — window chrome needs window padding. Return \
421 `page([...])` from `App::build` (it bakes tokens::SPACE_4 window \
422 padding), or pad the root container.",
423 sides.join("/"),
424 ),
425 },
426 );
427 }
428}
429
430fn check_tooltip_overlay_root(root: &El, r: &mut LintReport) {
437 if root.axis == Axis::Overlay {
438 return;
439 }
440 fn first_tooltip(n: &El) -> Option<&El> {
441 if n.tooltip.is_some() {
442 return Some(n);
443 }
444 n.children.iter().find_map(first_tooltip)
445 }
446 let Some(carrier) = first_tooltip(root) else {
447 return;
448 };
449 push_for(
450 r,
451 root,
452 Finding {
453 kind: FindingKind::TooltipWithoutOverlayRoot,
454 node_id: root.computed_id.clone(),
455 source: root.source,
456 message: format!(
457 "a node carries .tooltip() (first: {carrier_id} at {file}:{line}) but the \
458 root is not an Axis::Overlay container, so the tooltip layer has nowhere \
459 to mount — at runtime this panics on first hover. Wrap your `App::build` \
460 return value in `overlays(main, [])`. Got root axis = {axis:?}",
461 carrier_id = carrier.computed_id,
462 file = short_path(carrier.source.file),
463 line = carrier.source.line,
464 axis = root.axis,
465 ),
466 },
467 );
468}
469
470fn is_from_user(source: Source) -> bool {
471 !source.from_library
472}
473
474fn push_for(r: &mut LintReport, target: &El, finding: Finding) {
481 debug_assert_eq!(
482 finding.node_id, target.computed_id,
483 "lint::push_for: target must be the finding's attribution node",
484 );
485 if target.allow_lint.contains(&finding.kind) {
486 return;
487 }
488 r.findings.push(finding);
489}
490
491#[derive(Clone)]
500enum ClipCtx {
501 None,
502 Static(Rect),
504 Scrolling {
508 rect: Rect,
509 scroll_axis: Axis,
510 node_id: String,
511 },
512}
513
514struct FlatNode<'a> {
518 el: &'a El,
519 rect: Rect,
520 subtree_end: usize,
524 layer: usize,
526 clip: ClipCtx,
529 blame: Option<Source>,
532}
533
534struct FlatTree<'a> {
539 nodes: Vec<FlatNode<'a>>,
540 layer_parents: Vec<Option<usize>>,
545}
546
547impl<'a> FlatTree<'a> {
548 const ROOT_LAYER: usize = 0;
549
550 fn new() -> Self {
551 Self {
552 nodes: Vec::new(),
553 layer_parents: vec![None],
554 }
555 }
556
557 fn push_layer(&mut self, parent: usize) -> usize {
559 self.layer_parents.push(Some(parent));
560 self.layer_parents.len() - 1
561 }
562
563 fn layers_comparable(&self, a: usize, b: usize) -> bool {
570 self.is_layer_ancestor_or_self(a, b) || self.is_layer_ancestor_or_self(b, a)
571 }
572
573 fn is_layer_ancestor_or_self(&self, anc: usize, mut layer: usize) -> bool {
574 loop {
575 if layer == anc {
576 return true;
577 }
578 match self.layer_parents[layer] {
579 Some(p) => layer = p,
580 None => return false,
581 }
582 }
583 }
584
585 fn is_descendant(&self, i: usize, j: usize) -> bool {
587 j > i && j < self.nodes[i].subtree_end
588 }
589}
590
591#[allow(clippy::too_many_arguments)]
592fn walk<'a>(
593 n: &'a El,
594 parent_kind: Option<&Kind>,
595 parent_blame: Option<Source>,
596 nearest_clip: &ClipCtx,
597 layer: usize,
598 ui_state: &UiState,
599 r: &mut LintReport,
600 seen: &mut std::collections::BTreeMap<String, usize>,
601 flat: &mut FlatTree<'a>,
602) {
603 *seen.entry(n.computed_id.clone()).or_default() += 1;
604 let computed = ui_state.rect(&n.computed_id);
605
606 let from_user_self = is_from_user(n.source);
607 let self_blame = if from_user_self {
614 Some(n.source)
615 } else {
616 parent_blame
617 };
618
619 let flat_idx = flat.nodes.len();
623 flat.nodes.push(FlatNode {
624 el: n,
625 rect: computed,
626 subtree_end: usize::MAX,
627 layer,
628 clip: nearest_clip.clone(),
629 blame: self_blame,
630 });
631
632 let inside_inlines = matches!(parent_kind, Some(Kind::Inlines));
638
639 if from_user_self {
642 if let Some(c) = n.fill
643 && c.token.is_none()
644 && c.a > 0.0
645 {
646 push_for(
647 r,
648 n,
649 Finding {
650 kind: FindingKind::RawColor,
651 node_id: n.computed_id.clone(),
652 source: n.source,
653 message: format!(
654 "fill is a raw rgba({},{},{},{}) — use a token",
655 c.r, c.g, c.b, c.a
656 ),
657 },
658 );
659 }
660 if let Some(c) = n.stroke
661 && c.token.is_none()
662 && c.a > 0.0
663 {
664 push_for(
665 r,
666 n,
667 Finding {
668 kind: FindingKind::RawColor,
669 node_id: n.computed_id.clone(),
670 source: n.source,
671 message: format!(
672 "stroke is a raw rgba({},{},{},{}) — use a token",
673 c.r, c.g, c.b, c.a
674 ),
675 },
676 );
677 }
678 if let Some(c) = n.text_color
679 && c.token.is_none()
680 && c.a > 0.0
681 {
682 push_for(
683 r,
684 n,
685 Finding {
686 kind: FindingKind::RawColor,
687 node_id: n.computed_id.clone(),
688 source: n.source,
689 message: format!(
690 "text_color is a raw rgba({},{},{},{}) — use a token",
691 c.r, c.g, c.b, c.a
692 ),
693 },
694 );
695 }
696 if n.tooltip.is_some() && n.key.is_none() {
702 push_for(
703 r,
704 n,
705 Finding {
706 kind: FindingKind::DeadTooltip,
707 node_id: n.computed_id.clone(),
708 source: n.source,
709 message: ".tooltip() on a node without .key() never fires — hit-test only \
710 returns keyed nodes, so hover skips past this leaf to the nearest \
711 keyed ancestor. Add .key(\"…\") on the same node that carries the \
712 tooltip; for info-only chrome inside list rows, a synthetic key \
713 like \"row:{idx}.<part>\" is enough."
714 .to_string(),
715 },
716 );
717 }
718
719 if n.fill.is_none() && matches!(n.surface_role, SurfaceRole::Panel) {
726 push_for(
727 r,
728 n,
729 Finding {
730 kind: FindingKind::MissingSurfaceFill,
731 node_id: n.computed_id.clone(),
732 source: n.source,
733 message:
734 "surface_role(Panel) without a fill paints only stroke + shadow — \
735 wrap in card() / sidebar() / dialog() for the canonical recipe, or set .fill(tokens::CARD)"
736 .to_string(),
737 },
738 );
739 }
740
741 if matches!(n.surface_role, SurfaceRole::Panel) {
742 check_unpadded_surface_panel(n, computed, ui_state, r, n.source);
743 }
744
745 if matches!(n.kind, Kind::Group) && !n.children.is_empty() {
759 let card_fill = n
760 .fill
761 .as_ref()
762 .and_then(|c| c.token)
763 .is_some_and(|t| t == "card");
764 let border_stroke = n
765 .stroke
766 .as_ref()
767 .and_then(|c| c.token)
768 .is_some_and(|t| t == "border");
769 if card_fill && border_stroke {
770 let is_panel_surface = matches!(n.surface_role, SurfaceRole::Panel);
771 let sidebar_width = matches!(n.width, Size::Fixed(w) if (w - crate::tokens::SIDEBAR_WIDTH).abs() < 0.5);
772 if !is_panel_surface {
773 if sidebar_width {
774 push_for(
775 r,
776 n,
777 Finding {
778 kind: FindingKind::ReinventedWidget,
779 node_id: n.computed_id.clone(),
780 source: n.source,
781 message:
782 "Group with fill=CARD, stroke=BORDER, width=SIDEBAR_WIDTH reinvents sidebar() — \
783 use sidebar([sidebar_header(...), sidebar_group([sidebar_menu([sidebar_menu_button(label, current)])])]) \
784 for the panel surface and the canonical row recipe"
785 .to_string(),
786 },
787 );
788 } else {
789 push_for(
799 r,
800 n,
801 Finding {
802 kind: FindingKind::ReinventedWidget,
803 node_id: n.computed_id.clone(),
804 source: n.source,
805 message:
806 "Group with fill=CARD, stroke=BORDER reinvents the panel-surface recipe — \
807 use card([card_header([card_title(\"...\")]), card_content([...])]) / titled_card(\"Title\", [...]) for boxed content, \
808 or sidebar([...]) for a full-height nav/inspector pane (sidebar() also handles the custom-width case via .width(Size::Fixed(...)))"
809 .to_string(),
810 },
811 );
812 }
813 }
814 }
815 }
816 }
817
818 if let Some(blame) = self_blame {
824 lint_row_alignment(n, computed, ui_state, r, blame);
825 lint_overlay_alignment(n, computed, ui_state, r, blame);
826 lint_row_visual_text_spacing(n, ui_state, r, blame);
827 }
828
829 if n.text.is_some()
835 && !inside_inlines
836 && let Some(blame) = self_blame
837 {
838 let available_width = match n.text_wrap {
839 TextWrap::NoWrap => None,
840 TextWrap::Wrap => Some(computed.w),
841 };
842 if let Some(text_layout) = layout::text_layout(n, available_width) {
843 let text_w = text_layout.width + n.padding.left + n.padding.right;
844 let text_h = text_layout.height + n.padding.top + n.padding.bottom;
845 let raw_overflow_x = (text_w - computed.w).max(0.0);
846 let overflow_x = if matches!(
847 (n.text_wrap, n.text_overflow),
848 (TextWrap::NoWrap, TextOverflow::Ellipsis)
849 ) {
850 0.0
851 } else {
852 raw_overflow_x
853 };
854 let overflow_y = (text_h - computed.h).max(0.0);
855 if overflow_x > 0.5 || overflow_y > 0.5 {
856 let is_clipped_nowrap = overflow_x > 0.5
857 && matches!(
858 (n.text_wrap, n.text_overflow),
859 (TextWrap::NoWrap, TextOverflow::Clip)
860 );
861 let kind = if is_clipped_nowrap {
862 FindingKind::TextOverflow
863 } else {
864 FindingKind::Overflow
865 };
866 let pad_y = n.padding.top + n.padding.bottom;
875 let height_is_fixed = matches!(n.height, Size::Fixed(_));
876 let text_alone_fits_height = text_layout.height <= computed.h + 0.5;
877 let padding_eats_fixed_height = overflow_y > 0.5
878 && overflow_x <= 0.5
879 && pad_y > 0.0
880 && text_alone_fits_height
881 && height_is_fixed;
882 let cell_h = text_layout.height;
883 let box_h = computed.h;
884 let message = if kind == FindingKind::TextOverflow {
885 format!(
886 "nowrap text exceeds its box by X={overflow_x:.0}; use .ellipsis(), wrap_text(), or a wider box"
887 )
888 } else if padding_eats_fixed_height {
889 let inner_h = (box_h - pad_y).max(0.0);
890 let pad_x_token = if (n.padding.left - n.padding.right).abs() < 0.5 {
891 format!("{:.0}", n.padding.left)
892 } else {
893 "...".to_string()
894 };
895 let control_h = crate::tokens::CONTROL_HEIGHT;
896 format!(
897 "vertical padding ({pad_y:.0}px) makes the inner content rect ({inner_h:.0}px) shorter than the text cell ({cell_h:.0}px) on a fixed-height box ({box_h:.0}px) — \
898 the label can't vertically center and paints into the padding band, off-center by Y={overflow_y:.0}. \
899 Reduce vertical padding (e.g. `Sides::xy({pad_x_token}, 0.0)` — `.padding(scalar)` is `Sides::all(scalar)`, which usually isn't what you want on a control-height box) or increase height (tokens::CONTROL_HEIGHT = {control_h:.0}px)"
900 )
901 } else if overflow_y > 0.5 && overflow_x <= 0.5 {
902 format!(
903 "text cell ({cell_h:.0}px) exceeds box height ({box_h:.0}px) by Y={overflow_y:.0}; \
904 increase height, reduce text size, or use paragraph()/wrap_text() with fewer lines"
905 )
906 } else {
907 format!(
908 "text content exceeds its box by X={overflow_x:.0} Y={overflow_y:.0}; use paragraph()/wrap_text(), a wider box, or explicit clipping"
909 )
910 };
911 push_for(
912 r,
913 n,
914 Finding {
915 kind,
916 node_id: n.computed_id.clone(),
917 source: blame,
918 message,
919 },
920 );
921 }
922 }
923 }
924
925 let suppress_overflow = n.scrollable
940 || n.clip
941 || matches!(n.kind, Kind::Inlines)
942 || matches!(n.kind, Kind::Custom("toast_stack"));
943
944 let parent_main_overran =
954 !suppress_overflow && flex_main_axis_overflowed(n, computed, ui_state);
955
956 let child_clip = if n.clip {
963 if n.scrollable {
964 ClipCtx::Scrolling {
965 rect: computed,
966 scroll_axis: n.axis,
967 node_id: n.computed_id.clone(),
968 }
969 } else {
970 ClipCtx::Static(computed)
971 }
972 } else {
973 nearest_clip.clone()
974 };
975
976 for c in n.children.iter() {
977 let from_user_child = is_from_user(c.source);
978 let child_blame = if from_user_child {
979 Some(c.source)
980 } else {
981 self_blame
982 };
983
984 let c_rect = ui_state.rect(&c.computed_id);
985 if !suppress_overflow
986 && !rect_contains(computed, c_rect, 0.5)
987 && let Some(blame) = child_blame
988 {
989 let dx_left = (computed.x - c_rect.x).max(0.0);
990 let dx_right = (c_rect.right() - computed.right()).max(0.0);
991 let dy_top = (computed.y - c_rect.y).max(0.0);
992 let dy_bottom = (c_rect.bottom() - computed.bottom()).max(0.0);
993 push_for(
994 r,
995 c,
996 Finding {
997 kind: FindingKind::Overflow,
998 node_id: c.computed_id.clone(),
999 source: blame,
1000 message: format!(
1001 "child overflows parent {parent_id} by L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}",
1002 parent_id = n.computed_id,
1003 ),
1004 },
1005 );
1006 }
1007
1008 let main_axis_is_hug = match n.axis {
1014 Axis::Row => matches!(c.width, Size::Hug),
1015 Axis::Column => matches!(c.height, Size::Hug),
1016 Axis::Overlay => false,
1017 };
1018 if parent_main_overran
1019 && main_axis_is_hug
1020 && c.text.is_some()
1021 && c.text_wrap == TextWrap::NoWrap
1022 && c.text_overflow == TextOverflow::Ellipsis
1023 && let Some(blame) = child_blame
1024 {
1025 push_for(
1026 r,
1027 c,
1028 Finding {
1029 kind: FindingKind::TextOverflow,
1030 node_id: c.computed_id.clone(),
1031 source: blame,
1032 message:
1033 ".ellipsis() has no effect on Size::Hug text — Hug forces the rect to the intrinsic content width, so the truncation budget equals the content and no glyph is ever trimmed. Set Size::Fill(_) or Size::Fixed(_) on the text or on a wrapping container so the layout can constrain the rect."
1034 .to_string(),
1035 },
1036 );
1037 }
1038
1039 if from_user_child
1047 && c.fill.is_some()
1048 && n.radius.any_nonzero()
1049 && let Some(blame) = child_blame
1050 {
1051 check_corner_stackup(n, computed, c, c_rect, r, blame);
1052 }
1053
1054 if from_user_child
1055 && c.focusable
1056 && let Some(blame) = child_blame
1057 {
1058 check_focus_ring_clipped(c, c_rect, &child_clip, r, blame);
1059 check_scrollbar_overlap(c, c_rect, &child_clip, ui_state, r, blame);
1063 }
1064
1065 let child_layer = if matches!(n.axis, Axis::Overlay) {
1069 flat.push_layer(layer)
1070 } else {
1071 layer
1072 };
1073
1074 walk(
1075 c,
1076 Some(&n.kind),
1077 child_blame,
1078 &child_clip,
1079 child_layer,
1080 ui_state,
1081 r,
1082 seen,
1083 flat,
1084 );
1085 }
1086
1087 flat.nodes[flat_idx].subtree_end = flat.nodes.len();
1088}
1089
1090fn focus_ring_overflow(n: &El) -> Sides {
1091 match n.focus_ring_placement {
1092 crate::tree::FocusRingPlacement::Outside => Sides::all(crate::tokens::RING_WIDTH),
1093 crate::tree::FocusRingPlacement::Inside => Sides::zero(),
1094 }
1095}
1096
1097fn any_side_overflows(sides: Sides) -> bool {
1100 sides.left > 0.5 || sides.right > 0.5 || sides.top > 0.5 || sides.bottom > 0.5
1101}
1102
1103fn clip_rect(ctx: &ClipCtx) -> Option<Rect> {
1104 match ctx {
1105 ClipCtx::None => None,
1106 ClipCtx::Static(rect) | ClipCtx::Scrolling { rect, .. } => Some(*rect),
1107 }
1108}
1109
1110fn clipped_rect(rect: Rect, ctx: &ClipCtx) -> Option<Rect> {
1111 match clip_rect(ctx) {
1112 Some(clip) => rect.intersect(clip),
1113 None => Some(rect),
1114 }
1115}
1116
1117fn check_hit_overflow_collisions(flat: &FlatTree, r: &mut LintReport) {
1129 for (left_idx, left) in flat.nodes.iter().enumerate() {
1130 if left.el.key.is_none() {
1131 continue;
1132 }
1133 let Some(left_hit) = clipped_rect(left.rect.outset(left.el.hit_overflow), &left.clip)
1134 else {
1135 continue;
1136 };
1137 for (right_idx, right) in flat.nodes.iter().enumerate().skip(left_idx + 1) {
1138 if right.el.key.is_none() {
1139 continue;
1140 }
1141 if !any_side_overflows(left.el.hit_overflow)
1142 && !any_side_overflows(right.el.hit_overflow)
1143 {
1144 continue;
1145 }
1146 if flat.is_descendant(left_idx, right_idx)
1147 || !flat.layers_comparable(left.layer, right.layer)
1148 {
1149 continue;
1150 }
1151 let Some(right_hit) =
1152 clipped_rect(right.rect.outset(right.el.hit_overflow), &right.clip)
1153 else {
1154 continue;
1155 };
1156 let Some(overlap) = left_hit.intersect(right_hit) else {
1157 continue;
1158 };
1159 if overlap.w <= 0.5 || overlap.h <= 0.5 {
1160 continue;
1161 }
1162
1163 let left_visual_contains = left.rect.contains(overlap.center_x(), overlap.center_y());
1164 let right_visual_contains = right.rect.contains(overlap.center_x(), overlap.center_y());
1165 if left_visual_contains && right_visual_contains {
1166 continue;
1170 }
1171
1172 let earlier = left.el.key.as_deref().unwrap_or("<unkeyed>");
1173 let later = right.el.key.as_deref().unwrap_or("<unkeyed>");
1174 let owner = if any_side_overflows(right.el.hit_overflow) {
1175 right
1176 } else {
1177 left
1178 };
1179 let Some(blame) = owner.blame else {
1180 continue;
1181 };
1182 push_for(
1183 r,
1184 owner.el,
1185 Finding {
1186 kind: FindingKind::HitOverflowCollision,
1187 node_id: owner.el.computed_id.clone(),
1188 source: blame,
1189 message: format!(
1190 "expanded hit targets for keys `{earlier}` and `{later}` overlap by {w:.0}x{h:.0}px — \
1191 hit-test resolves the collision by paint order, so `{later}` owns that invisible band. \
1192 Reduce `.hit_overflow(...)`, add real gap/padding, or make one visible row/control own the full intended target.",
1193 w = overlap.w,
1194 h = overlap.h,
1195 ),
1196 },
1197 );
1198 }
1199 }
1200}
1201
1202fn check_corner_stackup(
1210 parent: &El,
1211 parent_rect: Rect,
1212 child: &El,
1213 child_rect: Rect,
1214 r: &mut LintReport,
1215 blame: Source,
1216) {
1217 let pr = parent.radius;
1218 let cr = child.radius;
1219 let tl = (
1221 pr.tl,
1222 cr.tl,
1223 Rect::new(parent_rect.x, parent_rect.y, pr.tl, pr.tl),
1224 );
1225 let tr = (
1226 pr.tr,
1227 cr.tr,
1228 Rect::new(
1229 parent_rect.x + parent_rect.w - pr.tr,
1230 parent_rect.y,
1231 pr.tr,
1232 pr.tr,
1233 ),
1234 );
1235 let br = (
1236 pr.br,
1237 cr.br,
1238 Rect::new(
1239 parent_rect.x + parent_rect.w - pr.br,
1240 parent_rect.y + parent_rect.h - pr.br,
1241 pr.br,
1242 pr.br,
1243 ),
1244 );
1245 let bl = (
1246 pr.bl,
1247 cr.bl,
1248 Rect::new(
1249 parent_rect.x,
1250 parent_rect.y + parent_rect.h - pr.bl,
1251 pr.bl,
1252 pr.bl,
1253 ),
1254 );
1255 let leaks_at = |(p_r, c_r, corner_box): (f32, f32, Rect)| -> bool {
1256 if p_r <= 0.5 || c_r + 0.5 >= p_r {
1257 return false;
1258 }
1259 match child_rect.intersect(corner_box) {
1260 Some(overlap) => overlap.w >= 0.5 && overlap.h >= 0.5,
1261 None => false,
1262 }
1263 };
1264 let (leak_tl, leak_tr, leak_br, leak_bl) =
1265 (leaks_at(tl), leaks_at(tr), leaks_at(br), leaks_at(bl));
1266 if !(leak_tl || leak_tr || leak_br || leak_bl) {
1267 return;
1268 }
1269 let (descriptor, helper) = match (leak_tl, leak_tr, leak_br, leak_bl) {
1270 (true, true, false, false) => ("the parent's top corners", "Corners::top(...)"),
1271 (false, false, true, true) => ("the parent's bottom corners", "Corners::bottom(...)"),
1272 (true, false, false, true) => ("the parent's left corners", "Corners::left(...)"),
1273 (false, true, true, false) => ("the parent's right corners", "Corners::right(...)"),
1274 (true, true, true, true) => ("the parent's corners", "Corners::all(...)"),
1275 _ => (
1277 "a parent corner",
1278 "Corners { tl, tr, br, bl } with the matching corner set",
1279 ),
1280 };
1281 push_for(
1282 r,
1283 child,
1284 Finding {
1285 kind: FindingKind::CornerStackup,
1286 node_id: child.computed_id.clone(),
1287 source: blame,
1288 message: format!(
1289 "filled child paints into {descriptor} (rounded parent, max radius={pr_max:.0}) — \
1290 the flat corners obscure the parent's curve and stroke. \
1291 Set `.radius({helper})` on the child so its corners follow the parent's curve, \
1292 or add padding to the parent so the child is inset from the curve.",
1293 pr_max = pr.max(),
1294 ),
1295 },
1296 );
1297}
1298
1299fn check_unpadded_surface_panel(
1310 panel: &El,
1311 panel_rect: Rect,
1312 ui_state: &UiState,
1313 r: &mut LintReport,
1314 blame: Source,
1315) {
1316 let touch_eps = crate::tokens::RING_WIDTH;
1319 const PAD_EPS: f32 = 0.5;
1322
1323 let mut top = (false, false);
1325 let mut right = (false, false);
1326 let mut bottom = (false, false);
1327 let mut left = (false, false);
1328
1329 for c in &panel.children {
1330 let cr = ui_state.rect(&c.computed_id);
1331 if cr.w <= PAD_EPS || cr.h <= PAD_EPS {
1332 continue;
1334 }
1335 if (cr.y - panel_rect.y).abs() <= touch_eps {
1336 top.0 = true;
1337 if c.padding.top > PAD_EPS {
1338 top.1 = true;
1339 }
1340 }
1341 if (panel_rect.right() - cr.right()).abs() <= touch_eps {
1342 right.0 = true;
1343 if c.padding.right > PAD_EPS {
1344 right.1 = true;
1345 }
1346 }
1347 if (panel_rect.bottom() - cr.bottom()).abs() <= touch_eps {
1348 bottom.0 = true;
1349 if c.padding.bottom > PAD_EPS {
1350 bottom.1 = true;
1351 }
1352 }
1353 if (cr.x - panel_rect.x).abs() <= touch_eps {
1354 left.0 = true;
1355 if c.padding.left > PAD_EPS {
1356 left.1 = true;
1357 }
1358 }
1359 }
1360
1361 let pad = panel.padding;
1362 let mut sides: Vec<&'static str> = Vec::new();
1363 if pad.top <= PAD_EPS && top.0 && !top.1 {
1364 sides.push("top");
1365 }
1366 if pad.right <= PAD_EPS && right.0 && !right.1 {
1367 sides.push("right");
1368 }
1369 if pad.bottom <= PAD_EPS && bottom.0 && !bottom.1 {
1370 sides.push("bottom");
1371 }
1372 if pad.left <= PAD_EPS && left.0 && !left.1 {
1373 sides.push("left");
1374 }
1375 if sides.is_empty() {
1376 return;
1377 }
1378 let joined = sides.join("/");
1379 push_for(
1380 r,
1381 panel,
1382 Finding {
1383 kind: FindingKind::UnpaddedSurfacePanel,
1384 node_id: panel.computed_id.clone(),
1385 source: blame,
1386 message: format!(
1387 "Panel-surface children sit flush against the {joined} edge — \
1388 wrap content in the slot anatomy (`card_header(...)` / `card_content(...)` / `card_footer(...)` \
1389 each bake `SPACE_6` padding), or pad the panel itself \
1390 (e.g. `.padding(Sides::all(tokens::SPACE_4))` for dense list-row cards).",
1391 ),
1392 },
1393 );
1394}
1395
1396fn check_focus_ring_clipped(
1403 n: &El,
1404 n_rect: Rect,
1405 nearest_clip: &ClipCtx,
1406 r: &mut LintReport,
1407 blame: Source,
1408) {
1409 let ring_overflow = focus_ring_overflow(n);
1410 if !any_side_overflows(ring_overflow) {
1411 return;
1412 }
1413 let band = n_rect.outset(ring_overflow);
1414
1415 let (clip_rect, check_horiz, check_vert) = match nearest_clip {
1419 ClipCtx::None => (None, false, false),
1420 ClipCtx::Static(rect) => (Some(*rect), true, true),
1421 ClipCtx::Scrolling {
1422 rect, scroll_axis, ..
1423 } => match scroll_axis {
1424 Axis::Column => (Some(*rect), true, false),
1425 Axis::Row => (Some(*rect), false, true),
1426 Axis::Overlay => (Some(*rect), true, true),
1427 },
1428 };
1429 if let Some(clip) = clip_rect {
1430 let dx_left = if check_horiz {
1431 (clip.x - band.x).max(0.0)
1432 } else {
1433 0.0
1434 };
1435 let dx_right = if check_horiz {
1436 (band.right() - clip.right()).max(0.0)
1437 } else {
1438 0.0
1439 };
1440 let dy_top = if check_vert {
1441 (clip.y - band.y).max(0.0)
1442 } else {
1443 0.0
1444 };
1445 let dy_bottom = if check_vert {
1446 (band.bottom() - clip.bottom()).max(0.0)
1447 } else {
1448 0.0
1449 };
1450 if dx_left + dx_right + dy_top + dy_bottom > 0.5 {
1451 push_for(
1452 r,
1453 n,
1454 Finding {
1455 kind: FindingKind::FocusRingObscured,
1456 node_id: n.computed_id.clone(),
1457 source: blame,
1458 message: format!(
1459 "focus ring band clipped by ancestor scissor (L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}) — give a clipping ancestor padding ≥ tokens::RING_WIDTH on the clipped side",
1460 ),
1461 },
1462 );
1463 }
1464 }
1465}
1466
1467fn check_focus_ring_occluded(flat: &FlatTree, r: &mut LintReport) {
1481 for f in flat.nodes.iter() {
1482 if !f.el.focusable || !is_from_user(f.el.source) {
1483 continue;
1484 }
1485 let ring_overflow = focus_ring_overflow(f.el);
1486 if !any_side_overflows(ring_overflow) {
1487 continue;
1488 }
1489 for o in &flat.nodes[f.subtree_end..] {
1492 if !paints_pixels(o.el) || !flat.layers_comparable(f.layer, o.layer) {
1493 continue;
1494 }
1495 let Some(o_rect) = clipped_rect(occluder_paint_rect(o.el, o.rect), &o.clip) else {
1504 continue;
1505 };
1506 let Some(o_rect) = clipped_rect(o_rect, &f.clip) else {
1507 continue;
1508 };
1509 if let Some(side) = bleed_occlusion(f.rect, ring_overflow, o_rect) {
1510 push_for(
1511 r,
1512 f.el,
1513 Finding {
1514 kind: FindingKind::FocusRingObscured,
1515 node_id: f.el.computed_id.clone(),
1516 source: f.el.source,
1517 message: format!(
1518 "focus ring band occluded on the {side} edge by later-painted {occluder_id} — increase gap to ≥ tokens::RING_WIDTH or restructure so the neighbor doesn't sit on the edge",
1519 occluder_id = o.el.computed_id,
1520 ),
1521 },
1522 );
1523 break;
1525 }
1526 }
1527 }
1528}
1529
1530fn check_scrollbar_overlap(
1548 n: &El,
1549 n_rect: Rect,
1550 nearest_clip: &ClipCtx,
1551 ui_state: &UiState,
1552 r: &mut LintReport,
1553 blame: Source,
1554) {
1555 let ClipCtx::Scrolling { node_id, .. } = nearest_clip else {
1556 return;
1557 };
1558 let Some(track) = ui_state.scroll.thumb_tracks.get(node_id).copied() else {
1559 return;
1560 };
1561 let active_w = crate::tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE;
1567 let thumb_left = track.right() - active_w;
1568 let thumb_right = track.right();
1569 let overlap_x = n_rect.right().min(thumb_right) - n_rect.x.max(thumb_left);
1570 if overlap_x <= 0.5 {
1571 return;
1572 }
1573 push_for(
1574 r,
1575 n,
1576 Finding {
1577 kind: FindingKind::ScrollbarObscuresFocusable,
1578 node_id: n.computed_id.clone(),
1579 source: blame,
1580 message: format!(
1581 "scrollbar thumb overlaps this focusable on the right edge by {overlap_x:.0}px (thumb x={thumb_left:.0}..{thumb_right:.0}; control x={ctrl_x:.0}..{ctrl_right:.0}) — move horizontal padding *inside* the scroll, onto a wrapper that constrains children to a narrower content rect, so the thumb sits in a reserved gutter to the right of content",
1582 ctrl_x = n_rect.x,
1583 ctrl_right = n_rect.right(),
1584 ),
1585 },
1586 );
1587}
1588
1589fn paints_pixels(n: &El) -> bool {
1593 n.fill.is_some()
1594 || n.stroke.is_some()
1595 || n.image.is_some()
1596 || n.icon.is_some()
1597 || n.shadow > 0.0
1598 || n.text.is_some()
1599 || !matches!(n.surface_role, SurfaceRole::None)
1600}
1601
1602fn occluder_paint_rect(n: &El, rect: Rect) -> Rect {
1609 let full_rect_paint = n.fill.is_some()
1610 || n.stroke.is_some()
1611 || n.image.is_some()
1612 || n.shadow > 0.0
1613 || !matches!(n.surface_role, SurfaceRole::None);
1614 if full_rect_paint {
1615 rect
1616 } else {
1617 rect.inset(n.padding)
1618 }
1619}
1620
1621fn bleed_occlusion(n_rect: Rect, overflow: Sides, sib_rect: Rect) -> Option<&'static str> {
1626 const EPS: f32 = 0.5;
1627 let bands: [(&'static str, Rect); 4] = [
1628 (
1629 "top",
1630 Rect::new(n_rect.x, n_rect.y - overflow.top, n_rect.w, overflow.top),
1631 ),
1632 (
1633 "bottom",
1634 Rect::new(n_rect.x, n_rect.bottom(), n_rect.w, overflow.bottom),
1635 ),
1636 (
1637 "left",
1638 Rect::new(n_rect.x - overflow.left, n_rect.y, overflow.left, n_rect.h),
1639 ),
1640 (
1641 "right",
1642 Rect::new(n_rect.right(), n_rect.y, overflow.right, n_rect.h),
1643 ),
1644 ];
1645 for (side, band) in bands {
1646 if band.w <= 0.0 || band.h <= 0.0 {
1647 continue;
1648 }
1649 let iw = band.right().min(sib_rect.right()) - band.x.max(sib_rect.x);
1650 let ih = band.bottom().min(sib_rect.bottom()) - band.y.max(sib_rect.y);
1651 if iw > EPS && ih > EPS {
1652 return Some(side);
1653 }
1654 }
1655 None
1656}
1657
1658fn lint_row_alignment(
1659 n: &El,
1660 computed: Rect,
1661 ui_state: &UiState,
1662 r: &mut LintReport,
1663 blame: Source,
1664) {
1665 if !matches!(n.axis, Axis::Row) || !matches!(n.align, Align::Stretch) || n.children.len() < 2 {
1666 return;
1667 }
1668 if !n.children.iter().any(is_text_like_child) {
1669 return;
1670 }
1671
1672 let inner = computed.inset(n.padding);
1673 if inner.h <= 0.0 {
1674 return;
1675 }
1676
1677 for child in &n.children {
1678 if !is_fixed_visual_child(child) {
1679 continue;
1680 }
1681 let child_rect = ui_state.rect(&child.computed_id);
1682 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1683 let visibly_short = child_rect.h + 2.0 < inner.h;
1684 if top_pinned && visibly_short {
1685 push_for(
1686 r,
1687 n,
1688 Finding {
1689 kind: FindingKind::Alignment,
1690 node_id: n.computed_id.clone(),
1691 source: blame,
1692 message: "row has a fixed-size visual child pinned to the top beside text; add .align(Align::Center) to vertically center row content"
1693 .to_string(),
1694 },
1695 );
1696 return;
1697 }
1698 }
1699}
1700
1701fn lint_overlay_alignment(
1702 n: &El,
1703 computed: Rect,
1704 ui_state: &UiState,
1705 r: &mut LintReport,
1706 blame: Source,
1707) {
1708 if !matches!(n.axis, Axis::Overlay)
1709 || n.children.is_empty()
1710 || !matches!(n.align, Align::Start | Align::Stretch)
1711 || !matches!(n.justify, Justify::Start | Justify::SpaceBetween)
1712 || !has_visible_surface(n)
1713 {
1714 return;
1715 }
1716
1717 let inner = computed.inset(n.padding);
1718 if inner.w <= 0.0 || inner.h <= 0.0 {
1719 return;
1720 }
1721
1722 for child in &n.children {
1723 if !is_fixed_visual_child(child) {
1724 continue;
1725 }
1726 let child_rect = ui_state.rect(&child.computed_id);
1727 let left_pinned = (child_rect.x - inner.x).abs() <= 0.5;
1728 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1729 let visibly_narrow = child_rect.w + 2.0 < inner.w;
1730 let visibly_short = child_rect.h + 2.0 < inner.h;
1731 if left_pinned && top_pinned && visibly_narrow && visibly_short {
1732 push_for(
1733 r,
1734 n,
1735 Finding {
1736 kind: FindingKind::Alignment,
1737 node_id: n.computed_id.clone(),
1738 source: blame,
1739 message: "overlay has a smaller fixed-size visual child pinned to the top-left; add .align(Align::Center).justify(Justify::Center) to center overlay content"
1740 .to_string(),
1741 },
1742 );
1743 return;
1744 }
1745 }
1746}
1747
1748fn lint_row_visual_text_spacing(n: &El, ui_state: &UiState, r: &mut LintReport, blame: Source) {
1749 if !matches!(n.axis, Axis::Row) || n.children.len() < 2 {
1750 return;
1751 }
1752
1753 for pair in n.children.windows(2) {
1754 let [visual, text] = pair else {
1755 continue;
1756 };
1757 if !is_visual_cluster_child(visual) || !is_text_like_child(text) {
1758 continue;
1759 }
1760
1761 let visual_rect = ui_state.rect(&visual.computed_id);
1762 let text_rect = ui_state.rect(&text.computed_id);
1763 let gap = text_rect.x - visual_rect.right();
1764 if gap < 4.0 {
1765 push_for(
1766 r,
1767 n,
1768 Finding {
1769 kind: FindingKind::Spacing,
1770 node_id: n.computed_id.clone(),
1771 source: blame,
1772 message: format!(
1773 "row places text {:.0}px after an icon/control slot; add .gap(tokens::SPACE_2) or use a stock menu/list row",
1774 gap.max(0.0)
1775 ),
1776 },
1777 );
1778 return;
1779 }
1780 }
1781}
1782
1783fn is_text_like_child(c: &El) -> bool {
1784 c.text.is_some()
1785 || c.children
1786 .iter()
1787 .any(|child| child.text.is_some() || matches!(child.kind, Kind::Text | Kind::Heading))
1788}
1789
1790fn has_visible_surface(n: &El) -> bool {
1791 n.fill.is_some() || n.stroke.is_some()
1792}
1793
1794fn is_fixed_visual_child(c: &El) -> bool {
1795 let fixed_height = matches!(c.height, Size::Fixed(_));
1796 fixed_height
1797 && (c.icon.is_some()
1798 || matches!(c.kind, Kind::Badge)
1799 || matches!(
1800 c.metrics_role,
1801 Some(
1802 MetricsRole::Button
1803 | MetricsRole::IconButton
1804 | MetricsRole::Input
1805 | MetricsRole::Badge
1806 | MetricsRole::TabTrigger
1807 | MetricsRole::ChoiceControl
1808 | MetricsRole::Slider
1809 | MetricsRole::Progress
1810 )
1811 ))
1812}
1813
1814fn is_visual_cluster_child(c: &El) -> bool {
1815 let fixed_box = matches!(c.width, Size::Fixed(_)) && matches!(c.height, Size::Fixed(_));
1816 fixed_box
1817 && (c.icon.is_some()
1818 || matches!(c.kind, Kind::Badge)
1819 || matches!(
1820 c.metrics_role,
1821 Some(MetricsRole::IconButton | MetricsRole::Badge | MetricsRole::ChoiceControl)
1822 )
1823 || (has_visible_surface(c) && c.children.iter().any(is_fixed_visual_child)))
1824}
1825
1826fn rect_contains(parent: Rect, child: Rect, tol: f32) -> bool {
1827 child.x >= parent.x - tol
1828 && child.y >= parent.y - tol
1829 && child.right() <= parent.right() + tol
1830 && child.bottom() <= parent.bottom() + tol
1831}
1832
1833fn flex_main_axis_overflowed(parent: &El, parent_rect: Rect, ui_state: &UiState) -> bool {
1839 let n = parent.children.len();
1840 if n == 0 {
1841 return false;
1842 }
1843 let inner = parent_rect.inset(parent.padding);
1844 let inner_main = match parent.axis {
1845 Axis::Row => inner.w,
1846 Axis::Column => inner.h,
1847 Axis::Overlay => return false,
1848 };
1849 let total_gap = parent.gap * n.saturating_sub(1) as f32;
1850 let consumed: f32 = parent
1851 .children
1852 .iter()
1853 .map(|c| {
1854 let r = ui_state.rect(&c.computed_id);
1855 match parent.axis {
1856 Axis::Row => r.w,
1857 Axis::Column => r.h,
1858 Axis::Overlay => 0.0,
1859 }
1860 })
1861 .sum();
1862 consumed + total_gap > inner_main + 0.5
1863}
1864
1865fn short_path(p: &str) -> String {
1866 let parts: Vec<&str> = p.split(['/', '\\']).collect();
1867 if parts.len() >= 2 {
1868 format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
1869 } else {
1870 p.to_string()
1871 }
1872}
1873
1874#[cfg(test)]
1875mod tests {
1876 use super::*;
1877
1878 fn lint_one(mut root: El) -> LintReport {
1879 let mut ui_state = UiState::new();
1880 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1881 lint(&root, &ui_state)
1882 }
1883
1884 #[test]
1885 fn clipped_nowrap_text_reports_text_overflow() {
1886 let root = crate::text("A very long dashboard label")
1887 .width(Size::Fixed(42.0))
1888 .height(Size::Fixed(20.0));
1889
1890 let report = lint_one(root);
1891
1892 assert!(
1893 report
1894 .findings
1895 .iter()
1896 .any(|finding| finding.kind == FindingKind::TextOverflow),
1897 "{}",
1898 report.text()
1899 );
1900 }
1901
1902 #[test]
1903 fn ellipsis_nowrap_text_satisfies_horizontal_overflow_policy() {
1904 let root = crate::text("A very long dashboard label")
1905 .ellipsis()
1906 .width(Size::Fixed(42.0))
1907 .height(Size::Fixed(20.0));
1908
1909 let report = lint_one(root);
1910
1911 assert!(
1912 !report
1913 .findings
1914 .iter()
1915 .any(|finding| finding.kind == FindingKind::TextOverflow),
1916 "{}",
1917 report.text()
1918 );
1919 }
1920
1921 #[test]
1922 fn hug_ellipsis_in_overflowing_row_reports_dead_chain_issue_19() {
1923 let row = crate::row([
1932 crate::text("short_label"),
1933 crate::text("a long descriptive body that should truncate but cannot").ellipsis(),
1934 crate::text("right_side_metadata"),
1935 ])
1936 .width(Size::Fixed(160.0))
1937 .height(Size::Fixed(20.0));
1938
1939 let report = lint_one(row);
1940
1941 assert!(
1942 report
1943 .findings
1944 .iter()
1945 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1946 "expected dead-ellipsis finding pointing at Hug text\n{}",
1947 report.text()
1948 );
1949 }
1950
1951 #[test]
1952 fn hug_ellipsis_in_non_overflowing_row_is_quiet() {
1953 let row = crate::row([crate::text("ok").ellipsis()])
1958 .width(Size::Fixed(160.0))
1959 .height(Size::Fixed(20.0));
1960
1961 let report = lint_one(row);
1962
1963 assert!(
1964 !report
1965 .findings
1966 .iter()
1967 .any(|f| f.kind == FindingKind::TextOverflow),
1968 "{}",
1969 report.text()
1970 );
1971 }
1972
1973 #[test]
1974 fn fill_ellipsis_in_overflowing_row_is_quiet() {
1975 let row = crate::row([
1980 crate::text("short_label"),
1981 crate::text("a long descriptive body that should truncate but cannot")
1982 .width(Size::Fill(1.0))
1983 .ellipsis(),
1984 crate::text("right_side_metadata"),
1985 ])
1986 .width(Size::Fixed(160.0))
1987 .height(Size::Fixed(20.0));
1988
1989 let report = lint_one(row);
1990
1991 assert!(
1992 !report
1993 .findings
1994 .iter()
1995 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1996 "{}",
1997 report.text()
1998 );
1999 }
2000
2001 #[test]
2002 fn padding_eats_fixed_height_button_reports_padding_advice() {
2003 let root = crate::row([crate::button("Resume")
2013 .height(Size::Fixed(30.0))
2014 .padding(crate::tokens::SPACE_2)]);
2015
2016 let report = lint_one(root);
2017
2018 let finding = report
2019 .findings
2020 .iter()
2021 .find(|f| f.kind == FindingKind::Overflow)
2022 .unwrap_or_else(|| {
2023 panic!(
2024 "expected an Overflow finding for the padding-eats-height shape\n{}",
2025 report.text()
2026 )
2027 });
2028 assert!(
2029 finding.message.contains("vertical padding") && finding.message.contains("Sides::xy"),
2030 "expected padding-y advice, got:\n{}\n{}",
2031 finding.message,
2032 report.text(),
2033 );
2034 assert!(
2035 !finding.message.contains("paragraph()") && !finding.message.contains("wrap_text()"),
2036 "padding-eats-height case should not recommend paragraph/wrap_text:\n{}",
2037 finding.message,
2038 );
2039 }
2040
2041 #[test]
2042 fn padding_eats_fixed_height_y_only_does_not_fire_when_height_is_hug() {
2043 let root = crate::row([crate::text("Resume").padding(crate::tokens::SPACE_2)]);
2047
2048 let report = lint_one(root);
2049
2050 assert!(
2051 !report
2052 .findings
2053 .iter()
2054 .any(|f| f.kind == FindingKind::Overflow || f.kind == FindingKind::TextOverflow),
2055 "{}",
2056 report.text()
2057 );
2058 }
2059
2060 #[test]
2061 fn text_taller_than_fixed_height_without_padding_reports_height_advice() {
2062 let root = crate::row([crate::text("body")
2067 .width(Size::Fixed(80.0))
2068 .height(Size::Fixed(12.0))]);
2069
2070 let report = lint_one(root);
2071
2072 let finding = report
2073 .findings
2074 .iter()
2075 .find(|f| f.kind == FindingKind::Overflow)
2076 .unwrap_or_else(|| {
2077 panic!(
2078 "expected an Overflow finding for text-taller-than-box\n{}",
2079 report.text()
2080 )
2081 });
2082 assert!(
2083 finding.message.contains("exceeds box height") && finding.message.contains("height"),
2084 "expected height-advice message, got:\n{}",
2085 finding.message,
2086 );
2087 assert!(
2088 !finding.message.contains("vertical padding"),
2089 "no-padding case should not blame padding:\n{}",
2090 finding.message,
2091 );
2092 }
2093
2094 #[test]
2095 fn padding_aware_text_overflow_fires_when_text_spills_past_padded_region() {
2096 let leaf = crate::text("dashboard")
2106 .width(Size::Fixed(80.0))
2107 .height(Size::Fixed(28.0))
2108 .padding(Sides::xy(20.0, 0.0));
2109 let root = crate::row([leaf]);
2110
2111 let report = lint_one(root);
2112
2113 assert!(
2114 report
2115 .findings
2116 .iter()
2117 .any(|finding| finding.kind == FindingKind::TextOverflow),
2118 "{}",
2119 report.text()
2120 );
2121 }
2122
2123 #[test]
2124 fn stretch_row_with_top_pinned_icon_and_text_suggests_center_alignment() {
2125 let root = crate::row([
2126 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
2127 crate::text("Settings").width(Size::Fill(1.0)),
2128 ])
2129 .height(Size::Fixed(36.0));
2130
2131 let report = lint_one(root);
2132
2133 assert!(
2134 report
2135 .findings
2136 .iter()
2137 .any(|finding| finding.kind == FindingKind::Alignment
2138 && finding.message.contains(".align(Align::Center)")),
2139 "{}",
2140 report.text()
2141 );
2142 }
2143
2144 #[test]
2145 fn centered_row_with_icon_and_text_satisfies_alignment_policy() {
2146 let root = crate::row([
2147 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
2148 crate::text("Settings").width(Size::Fill(1.0)),
2149 ])
2150 .height(Size::Fixed(36.0))
2151 .align(Align::Center);
2152
2153 let report = lint_one(root);
2154
2155 assert!(
2156 !report
2157 .findings
2158 .iter()
2159 .any(|finding| finding.kind == FindingKind::Alignment),
2160 "{}",
2161 report.text()
2162 );
2163 }
2164
2165 #[test]
2166 fn row_with_icon_slot_touching_text_reports_spacing() {
2167 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
2168 .align(Align::Center)
2169 .justify(Justify::Center)
2170 .fill(crate::tokens::MUTED)
2171 .width(Size::Fixed(26.0))
2172 .height(Size::Fixed(26.0));
2173 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
2174 .height(Size::Fixed(32.0))
2175 .align(Align::Center);
2176
2177 let report = lint_one(root);
2178
2179 assert!(
2180 report
2181 .findings
2182 .iter()
2183 .any(|finding| finding.kind == FindingKind::Spacing
2184 && finding.message.contains(".gap(tokens::SPACE_2)")),
2185 "{}",
2186 report.text()
2187 );
2188 }
2189
2190 #[test]
2191 fn row_with_icon_slot_and_text_gap_satisfies_spacing_policy() {
2192 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
2193 .align(Align::Center)
2194 .justify(Justify::Center)
2195 .fill(crate::tokens::MUTED)
2196 .width(Size::Fixed(26.0))
2197 .height(Size::Fixed(26.0));
2198 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
2199 .height(Size::Fixed(32.0))
2200 .align(Align::Center)
2201 .gap(crate::tokens::SPACE_2);
2202
2203 let report = lint_one(root);
2204
2205 assert!(
2206 !report
2207 .findings
2208 .iter()
2209 .any(|finding| finding.kind == FindingKind::Spacing),
2210 "{}",
2211 report.text()
2212 );
2213 }
2214
2215 #[test]
2216 fn overlay_with_top_left_pinned_icon_suggests_center_alignment() {
2217 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
2218 .fill(crate::tokens::MUTED)
2219 .width(Size::Fixed(26.0))
2220 .height(Size::Fixed(26.0));
2221 let root = crate::column([icon_slot]);
2222
2223 let report = lint_one(root);
2224
2225 assert!(
2226 report
2227 .findings
2228 .iter()
2229 .any(|finding| finding.kind == FindingKind::Alignment
2230 && finding.message.contains(".justify(Justify::Center)")),
2231 "{}",
2232 report.text()
2233 );
2234 }
2235
2236 #[test]
2237 fn centered_overlay_icon_satisfies_alignment_policy() {
2238 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
2239 .align(Align::Center)
2240 .justify(Justify::Center)
2241 .fill(crate::tokens::MUTED)
2242 .width(Size::Fixed(26.0))
2243 .height(Size::Fixed(26.0));
2244 let root = crate::column([icon_slot]);
2245
2246 let report = lint_one(root);
2247
2248 assert!(
2249 !report
2250 .findings
2251 .iter()
2252 .any(|finding| finding.kind == FindingKind::Alignment),
2253 "{}",
2254 report.text()
2255 );
2256 }
2257
2258 #[test]
2259 fn overflow_findings_attribute_to_nearest_user_source_ancestor() {
2260 let user_source = Source {
2265 file: "src/screen.rs",
2266 line: 42,
2267 from_library: false,
2268 };
2269 let widget_source = Source {
2270 file: "src/widgets/tabs.rs",
2271 line: 200,
2272 from_library: true,
2273 };
2274
2275 let mut leaf = crate::text("A very long dashboard label")
2276 .width(Size::Fixed(40.0))
2277 .height(Size::Fixed(20.0));
2278 leaf.source = widget_source;
2279
2280 let mut root = crate::row([leaf])
2281 .width(Size::Fixed(160.0))
2282 .height(Size::Fixed(48.0));
2283 root.source = user_source;
2284
2285 let mut ui_state = UiState::new();
2286 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
2287 let report = lint(&root, &ui_state);
2288
2289 let text_overflow = report
2290 .findings
2291 .iter()
2292 .find(|f| f.kind == FindingKind::TextOverflow)
2293 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
2294 assert_eq!(text_overflow.source.file, user_source.file);
2295 assert_eq!(text_overflow.source.line, user_source.line);
2296 }
2297
2298 #[test]
2299 fn overflow_finding_self_attributes_when_node_is_already_user_source() {
2300 let mut node = crate::text("A very long dashboard label")
2301 .width(Size::Fixed(40.0))
2302 .height(Size::Fixed(20.0));
2303 let user_source = Source {
2304 file: "src/screen.rs",
2305 line: 99,
2306 from_library: false,
2307 };
2308 node.source = user_source;
2309
2310 let mut ui_state = UiState::new();
2311 layout::layout(&mut node, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
2312 let report = lint(&node, &ui_state);
2313
2314 let text_overflow = report
2315 .findings
2316 .iter()
2317 .find(|f| f.kind == FindingKind::TextOverflow)
2318 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
2319 assert_eq!(text_overflow.source.line, user_source.line);
2320 }
2321
2322 #[test]
2323 fn overflow_lint_fires_for_external_app_paths_issue_13() {
2324 let user_source = Source {
2331 file: "src/sidebar.rs",
2332 line: 17,
2333 from_library: false,
2334 };
2335 let mut child = crate::column(Vec::<El>::new())
2336 .width(Size::Fixed(32.0))
2337 .height(Size::Fixed(32.0));
2338 child.source = user_source;
2339
2340 let mut row = crate::row([child])
2341 .width(Size::Fixed(256.0))
2342 .height(Size::Fixed(28.0));
2343 row.source = user_source;
2344
2345 let mut ui_state = UiState::new();
2346 layout::layout(&mut row, &mut ui_state, Rect::new(0.0, 0.0, 256.0, 28.0));
2347 let report = lint(&row, &ui_state);
2348
2349 assert!(
2350 report
2351 .findings
2352 .iter()
2353 .any(|f| f.kind == FindingKind::Overflow),
2354 "expected an Overflow finding for the 32px child in a 28px row\n{}",
2355 report.text()
2356 );
2357 }
2358
2359 #[test]
2360 fn overflow_finding_suppressed_when_no_user_ancestor_exists() {
2361 let widget_source = Source {
2364 file: "src/widgets/tabs.rs",
2365 line: 200,
2366 from_library: true,
2367 };
2368 let mut leaf = crate::text("A very long dashboard label")
2369 .width(Size::Fixed(40.0))
2370 .height(Size::Fixed(20.0));
2371 leaf.source = widget_source;
2372
2373 let mut wrapper = crate::row([leaf])
2374 .width(Size::Fixed(160.0))
2375 .height(Size::Fixed(48.0));
2376 wrapper.source = widget_source;
2377
2378 let mut ui_state = UiState::new();
2379 layout::layout(
2380 &mut wrapper,
2381 &mut ui_state,
2382 Rect::new(0.0, 0.0, 160.0, 48.0),
2383 );
2384 let report = lint(&wrapper, &ui_state);
2385
2386 assert!(
2387 !report
2388 .findings
2389 .iter()
2390 .any(|f| f.kind == FindingKind::TextOverflow || f.kind == FindingKind::Overflow),
2391 "{}",
2392 report.text()
2393 );
2394 }
2395
2396 #[test]
2397 fn panel_role_without_fill_reports_missing_surface_fill() {
2398 let root = crate::column([crate::text("body")])
2399 .surface_role(SurfaceRole::Panel)
2400 .width(Size::Fixed(120.0))
2401 .height(Size::Fixed(40.0));
2402
2403 let report = lint_one(root);
2404
2405 assert!(
2406 report
2407 .findings
2408 .iter()
2409 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2410 "{}",
2411 report.text()
2412 );
2413 }
2414
2415 #[test]
2416 fn panel_role_with_fill_satisfies_surface_policy() {
2417 let root = crate::column([crate::text("body")])
2418 .surface_role(SurfaceRole::Panel)
2419 .fill(crate::tokens::CARD)
2420 .width(Size::Fixed(120.0))
2421 .height(Size::Fixed(40.0));
2422
2423 let report = lint_one(root);
2424
2425 assert!(
2426 !report
2427 .findings
2428 .iter()
2429 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2430 "{}",
2431 report.text()
2432 );
2433 }
2434
2435 #[test]
2436 fn card_widget_satisfies_surface_policy() {
2437 let root = crate::widgets::card::card([crate::text("body")])
2438 .width(Size::Fixed(120.0))
2439 .height(Size::Fixed(40.0));
2440
2441 let report = lint_one(root);
2442
2443 assert!(
2444 !report
2445 .findings
2446 .iter()
2447 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2448 "{}",
2449 report.text()
2450 );
2451 }
2452
2453 #[test]
2454 fn handrolled_card_recipe_reports_reinvented_widget() {
2455 let root = crate::column([crate::text("body")])
2458 .fill(crate::tokens::CARD)
2459 .stroke(crate::tokens::BORDER)
2460 .radius(crate::tokens::RADIUS_LG)
2461 .width(Size::Fixed(160.0))
2462 .height(Size::Fixed(48.0));
2463
2464 let report = lint_one(root);
2465
2466 assert!(
2467 report
2468 .findings
2469 .iter()
2470 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("card(")),
2471 "{}",
2472 report.text()
2473 );
2474 }
2475
2476 #[test]
2477 fn real_card_widget_does_not_report_reinvented_widget() {
2478 let root = crate::widgets::card::card([crate::text("body")])
2481 .width(Size::Fixed(160.0))
2482 .height(Size::Fixed(48.0));
2483
2484 let report = lint_one(root);
2485
2486 assert!(
2487 !report
2488 .findings
2489 .iter()
2490 .any(|f| f.kind == FindingKind::ReinventedWidget),
2491 "{}",
2492 report.text()
2493 );
2494 }
2495
2496 #[test]
2497 fn handrolled_sidebar_recipe_reports_reinvented_widget() {
2498 let root = crate::column([crate::text("nav")])
2501 .fill(crate::tokens::CARD)
2502 .stroke(crate::tokens::BORDER)
2503 .width(Size::Fixed(crate::tokens::SIDEBAR_WIDTH))
2504 .height(Size::Fill(1.0));
2505
2506 let report = lint_one(root);
2507
2508 assert!(
2509 report
2510 .findings
2511 .iter()
2512 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("sidebar(")),
2513 "{}",
2514 report.text()
2515 );
2516 }
2517
2518 #[test]
2519 fn real_sidebar_widget_does_not_report_reinvented_widget() {
2520 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
2523
2524 let report = lint_one(root);
2525
2526 assert!(
2527 !report
2528 .findings
2529 .iter()
2530 .any(|f| f.kind == FindingKind::ReinventedWidget),
2531 "{}",
2532 report.text()
2533 );
2534 }
2535
2536 #[test]
2537 fn empty_visual_swatch_does_not_report_reinvented_widget() {
2538 let root = crate::column(Vec::<El>::new())
2542 .fill(crate::tokens::CARD)
2543 .stroke(crate::tokens::BORDER)
2544 .radius(crate::tokens::RADIUS_SM)
2545 .width(Size::Fixed(42.0))
2546 .height(Size::Fixed(34.0));
2547
2548 let report = lint_one(root);
2549
2550 assert!(
2551 !report
2552 .findings
2553 .iter()
2554 .any(|f| f.kind == FindingKind::ReinventedWidget),
2555 "{}",
2556 report.text()
2557 );
2558 }
2559
2560 #[test]
2561 fn plain_column_does_not_report_reinvented_widget() {
2562 let root = crate::column([crate::text("a"), crate::text("b")])
2564 .gap(crate::tokens::SPACE_2)
2565 .width(Size::Fixed(120.0))
2566 .height(Size::Fixed(40.0));
2567
2568 let report = lint_one(root);
2569
2570 assert!(
2571 !report
2572 .findings
2573 .iter()
2574 .any(|f| f.kind == FindingKind::ReinventedWidget),
2575 "{}",
2576 report.text()
2577 );
2578 }
2579
2580 #[test]
2581 fn fill_providing_roles_do_not_require_explicit_fill() {
2582 let root = crate::column([crate::text("body")])
2587 .surface_role(SurfaceRole::Sunken)
2588 .width(Size::Fixed(120.0))
2589 .height(Size::Fixed(40.0));
2590
2591 let report = lint_one(root);
2592
2593 assert!(
2594 !report
2595 .findings
2596 .iter()
2597 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2598 "{}",
2599 report.text()
2600 );
2601 }
2602
2603 #[test]
2604 fn focus_ring_lint_fires_when_input_clipped_on_scroll_cross_axis() {
2605 let selection = crate::selection::Selection::default();
2608 let mut root = crate::tree::scroll([crate::tree::column([
2609 crate::widgets::text_input::text_input("", &selection, "field"),
2610 ])])
2611 .width(Size::Fixed(300.0))
2612 .height(Size::Fixed(120.0));
2613 let mut state = UiState::new();
2614 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2615 let report = lint(&root, &state);
2616
2617 assert!(
2618 report.findings.iter().any(|f| {
2619 f.kind == FindingKind::FocusRingObscured
2620 && f.message.contains("clipped")
2621 && (f.message.contains("L=2") || f.message.contains("R=2"))
2622 }),
2623 "expected a FocusRingObscured clipping finding (L=2 or R=2)\n{}",
2624 report.text()
2625 );
2626 }
2627
2628 #[test]
2629 fn focus_ring_lint_assumes_every_focusable_has_a_ring_band() {
2630 let mut root = crate::tree::scroll([crate::tree::column([El::new(Kind::Custom(
2635 "raw_focusable",
2636 ))
2637 .key("raw")
2638 .focusable()
2639 .fill(crate::tokens::CARD)
2640 .width(Size::Fill(1.0))
2641 .height(Size::Fixed(40.0))])])
2642 .width(Size::Fixed(300.0))
2643 .height(Size::Fixed(120.0));
2644 let mut state = UiState::new();
2645 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2646 let report = lint(&root, &state);
2647
2648 assert!(
2649 report.findings.iter().any(|f| {
2650 f.kind == FindingKind::FocusRingObscured
2651 && f.message.contains("clipped")
2652 && (f.message.contains("L=2") || f.message.contains("R=2"))
2653 }),
2654 "expected a FocusRingObscured clipping finding for implicit focus ring band\n{}",
2655 report.text()
2656 );
2657 }
2658
2659 #[test]
2660 fn hit_overflow_collision_lint_fires_for_sibling_target_overlap() {
2661 let root = crate::tree::row([
2662 crate::button("A")
2663 .key("a")
2664 .hit_overflow(Sides::right(8.0))
2665 .width(Size::Fixed(40.0))
2666 .height(Size::Fixed(24.0)),
2667 crate::button("B")
2668 .key("b")
2669 .width(Size::Fixed(40.0))
2670 .height(Size::Fixed(24.0)),
2671 ])
2672 .gap(4.0);
2673
2674 let report = lint_one(root);
2675
2676 assert!(
2677 report.findings.iter().any(|f| {
2678 f.kind == FindingKind::HitOverflowCollision
2679 && f.message.contains("`a`")
2680 && f.message.contains("`b`")
2681 }),
2682 "expected HitOverflowCollision when a hit_overflow band reaches the next sibling\n{}",
2683 report.text()
2684 );
2685 }
2686
2687 #[test]
2688 fn hit_overflow_collision_lint_is_quiet_when_gap_clears_band() {
2689 let root = crate::tree::row([
2690 crate::button("A")
2691 .key("a")
2692 .hit_overflow(Sides::right(8.0))
2693 .width(Size::Fixed(40.0))
2694 .height(Size::Fixed(24.0)),
2695 crate::button("B")
2696 .key("b")
2697 .width(Size::Fixed(40.0))
2698 .height(Size::Fixed(24.0)),
2699 ])
2700 .gap(12.0);
2701
2702 let report = lint_one(root);
2703
2704 assert!(
2705 !report
2706 .findings
2707 .iter()
2708 .any(|f| f.kind == FindingKind::HitOverflowCollision),
2709 "{}",
2710 report.text()
2711 );
2712 }
2713
2714 #[test]
2715 fn hit_overflow_collision_lint_skips_overlay_stacks() {
2716 let root = crate::tree::stack([
2717 crate::button("A")
2718 .key("a")
2719 .hit_overflow(Sides::all(8.0))
2720 .width(Size::Fixed(40.0))
2721 .height(Size::Fixed(24.0)),
2722 crate::button("B")
2723 .key("b")
2724 .width(Size::Fixed(40.0))
2725 .height(Size::Fixed(24.0)),
2726 ]);
2727
2728 let report = lint_one(root);
2729
2730 assert!(
2731 !report
2732 .findings
2733 .iter()
2734 .any(|f| f.kind == FindingKind::HitOverflowCollision),
2735 "{}",
2736 report.text()
2737 );
2738 }
2739
2740 #[test]
2741 fn focus_ring_lint_silenced_when_scroll_supplies_horizontal_slack() {
2742 let selection = crate::selection::Selection::default();
2745 let mut root =
2746 crate::tree::scroll(
2747 [crate::tree::column([crate::widgets::text_input::text_input(
2748 "", &selection, "field",
2749 )])
2750 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))],
2751 )
2752 .width(Size::Fixed(300.0))
2753 .height(Size::Fixed(120.0));
2754 let mut state = UiState::new();
2755 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2756 let report = lint(&root, &state);
2757
2758 assert!(
2759 !report
2760 .findings
2761 .iter()
2762 .any(|f| f.kind == FindingKind::FocusRingObscured),
2763 "{}",
2764 report.text()
2765 );
2766 }
2767
2768 #[test]
2769 fn focus_ring_lint_skips_clipping_on_scroll_axis() {
2770 let selection = crate::selection::Selection::default();
2774 let mut root = crate::tree::scroll([crate::tree::column([
2775 crate::tree::column(Vec::<El>::new())
2777 .width(Size::Fill(1.0))
2778 .height(Size::Fixed(200.0)),
2779 crate::widgets::text_input::text_input("", &selection, "field"),
2780 ])
2781 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))])
2782 .width(Size::Fixed(300.0))
2783 .height(Size::Fixed(120.0));
2784 let mut state = UiState::new();
2785 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2786 let report = lint(&root, &state);
2787
2788 assert!(
2789 !report
2790 .findings
2791 .iter()
2792 .any(|f| f.kind == FindingKind::FocusRingObscured),
2793 "expected no FocusRingObscured finding for a row clipped on the scroll axis\n{}",
2794 report.text()
2795 );
2796 }
2797
2798 #[test]
2799 fn focus_ring_lint_fires_on_static_clip_in_any_direction() {
2800 let selection = crate::selection::Selection::default();
2803 let mut root = crate::tree::column([crate::widgets::text_input::text_input(
2804 "", &selection, "field",
2805 )])
2806 .clip()
2807 .width(Size::Fixed(300.0))
2808 .height(Size::Fixed(120.0));
2809 let mut state = UiState::new();
2810 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2811 let report = lint(&root, &state);
2812
2813 assert!(
2814 report.findings.iter().any(|f| {
2815 f.kind == FindingKind::FocusRingObscured && f.message.contains("clipped")
2816 }),
2817 "expected a static-clip FocusRingObscured finding\n{}",
2818 report.text()
2819 );
2820 }
2821
2822 #[test]
2823 fn focus_ring_lint_fires_on_painted_later_sibling_overlap() {
2824 let selection = crate::selection::Selection::default();
2828 let mut root = crate::tree::row([
2829 crate::widgets::text_input::text_input("", &selection, "field"),
2830 crate::tree::column([crate::text("neighbor")])
2831 .fill(crate::tokens::CARD)
2832 .stroke(crate::tokens::BORDER)
2833 .width(Size::Fixed(80.0))
2834 .height(Size::Fixed(32.0)),
2835 ])
2836 .gap(0.0)
2837 .width(Size::Fixed(400.0))
2838 .height(Size::Fixed(32.0));
2839 let mut state = UiState::new();
2840 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2841 let report = lint(&root, &state);
2842
2843 assert!(
2844 report.findings.iter().any(|f| {
2845 f.kind == FindingKind::FocusRingObscured
2846 && f.message.contains("occluded")
2847 && f.message.contains("right")
2848 }),
2849 "expected an occlusion finding on the right edge\n{}",
2850 report.text()
2851 );
2852 }
2853
2854 #[test]
2855 fn adjacency_lints_fire_across_wrapper_containers_issue_37() {
2856 let mut root = crate::tree::column([
2863 crate::tree::row([crate::button("Alpha").key("a")]),
2864 crate::tree::row([crate::button("Beta").key("b")]),
2865 ])
2866 .width(Size::Fixed(200.0));
2867 let mut state = UiState::new();
2868 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
2869 let report = lint(&root, &state);
2870
2871 assert!(
2872 report.findings.iter().any(|f| {
2873 f.kind == FindingKind::HitOverflowCollision
2874 && f.message.contains("`a`")
2875 && f.message.contains("`b`")
2876 }),
2877 "expected HitOverflowCollision across wrapper rows\n{}",
2878 report.text()
2879 );
2880 assert!(
2881 report.findings.iter().any(|f| {
2882 f.kind == FindingKind::FocusRingObscured
2883 && f.message.contains("occluded")
2884 && f.message.contains("bottom")
2885 }),
2886 "expected a FocusRingObscured occlusion finding across wrapper rows\n{}",
2887 report.text()
2888 );
2889 }
2890
2891 #[test]
2892 fn hit_overflow_collision_lint_skips_nested_keyed_targets() {
2893 let root = crate::tree::row([crate::button("Inner")
2898 .key("inner")
2899 .width(Size::Fixed(40.0))
2900 .height(Size::Fixed(24.0))])
2901 .key("outer")
2902 .hit_overflow(Sides::all(8.0))
2903 .width(Size::Fixed(120.0))
2904 .height(Size::Fixed(32.0));
2905
2906 let report = lint_one(root);
2907
2908 assert!(
2909 !report
2910 .findings
2911 .iter()
2912 .any(|f| f.kind == FindingKind::HitOverflowCollision),
2913 "{}",
2914 report.text()
2915 );
2916 }
2917
2918 #[test]
2919 fn adjacency_lints_skip_sibling_overlay_layers_when_nested() {
2920 let mut root = crate::tree::stack([
2925 crate::tree::column([crate::button("Behind")
2926 .key("behind")
2927 .hit_overflow(Sides::all(8.0))]),
2928 crate::tree::column(Vec::<El>::new())
2929 .key("scrim")
2930 .fill(crate::tokens::CARD)
2931 .width(Size::Fixed(300.0))
2932 .height(Size::Fixed(200.0)),
2933 ]);
2934 let mut state = UiState::new();
2935 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2936 let report = lint(&root, &state);
2937
2938 assert!(
2939 !report.findings.iter().any(|f| {
2940 f.kind == FindingKind::HitOverflowCollision
2941 || (f.kind == FindingKind::FocusRingObscured && f.message.contains("occluded"))
2942 }),
2943 "{}",
2944 report.text()
2945 );
2946 }
2947
2948 #[test]
2949 fn focus_ring_lint_allows_flush_inside_ring_menu_items() {
2950 let mut root = crate::tree::column([
2951 crate::menu_item("Checkout").key("checkout"),
2952 crate::menu_item("Merge").key("merge"),
2953 crate::menu_item("Delete").key("delete"),
2954 ])
2955 .gap(0.0)
2956 .width(Size::Fixed(180.0));
2957 let mut state = UiState::new();
2958 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 220.0, 140.0));
2959 let report = lint(&root, &state);
2960
2961 assert!(
2962 !report
2963 .findings
2964 .iter()
2965 .any(|f| f.kind == FindingKind::FocusRingObscured),
2966 "{}",
2967 report.text()
2968 );
2969 }
2970
2971 #[test]
2972 fn focus_ring_lint_ignores_unpainted_structural_sibling() {
2973 let selection = crate::selection::Selection::default();
2976 let mut root = crate::tree::row([
2977 crate::widgets::text_input::text_input("", &selection, "field"),
2978 crate::tree::column(Vec::<El>::new())
2979 .width(Size::Fixed(80.0))
2980 .height(Size::Fixed(32.0)),
2981 ])
2982 .gap(0.0)
2983 .width(Size::Fixed(400.0))
2984 .height(Size::Fixed(32.0));
2985 let mut state = UiState::new();
2986 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2987 let report = lint(&root, &state);
2988
2989 assert!(
2990 !report
2991 .findings
2992 .iter()
2993 .any(|f| f.kind == FindingKind::FocusRingObscured),
2994 "{}",
2995 report.text()
2996 );
2997 }
2998
2999 #[test]
3000 fn scrollbar_overlap_lint_fires_when_thumb_covers_fill_child() {
3001 let body = crate::tree::column(
3005 (0..30)
3006 .map(|i| {
3007 crate::tree::row([
3008 crate::text(format!("Row {i}")),
3009 crate::tree::spacer(),
3010 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
3011 ])
3012 .gap(crate::tokens::SPACE_2)
3013 .width(Size::Fill(1.0))
3014 })
3015 .collect::<Vec<_>>(),
3016 )
3017 .gap(crate::tokens::SPACE_2)
3018 .width(Size::Fill(1.0));
3019
3020 let mut root = crate::tree::scroll([body])
3021 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
3022 .width(Size::Fixed(480.0))
3023 .height(Size::Fixed(320.0));
3024 let mut state = UiState::new();
3025 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
3026 let report = lint(&root, &state);
3027
3028 assert!(
3029 report
3030 .findings
3031 .iter()
3032 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
3033 "expected ScrollbarObscuresFocusable for a switch that reaches the scroll's inner.right()\n{}",
3034 report.text()
3035 );
3036 }
3037
3038 #[test]
3039 fn scrollbar_overlap_lint_silenced_when_padding_is_inside_scroll() {
3040 let body = crate::tree::column(
3044 (0..30)
3045 .map(|i| {
3046 crate::tree::row([
3047 crate::text(format!("Row {i}")),
3048 crate::tree::spacer(),
3049 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
3050 ])
3051 .gap(crate::tokens::SPACE_2)
3052 .width(Size::Fill(1.0))
3053 })
3054 .collect::<Vec<_>>(),
3055 )
3056 .gap(crate::tokens::SPACE_2)
3057 .width(Size::Fill(1.0));
3058
3059 let mut root = crate::tree::scroll([crate::tree::column([body])
3060 .padding(Sides::xy(crate::tokens::SPACE_3, 0.0))
3061 .width(Size::Fill(1.0))])
3062 .padding(Sides::xy(0.0, crate::tokens::SPACE_2))
3063 .width(Size::Fixed(480.0))
3064 .height(Size::Fixed(320.0));
3065 let mut state = UiState::new();
3066 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
3067 let report = lint(&root, &state);
3068
3069 assert!(
3070 !report
3071 .findings
3072 .iter()
3073 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
3074 "expected no ScrollbarObscuresFocusable when padding is inside the scroll\n{}",
3075 report.text()
3076 );
3077 }
3078
3079 #[test]
3080 fn scrollbar_overlap_lint_quiet_when_content_does_not_overflow() {
3081 let body = crate::tree::column([crate::tree::row([
3086 crate::text("only row"),
3087 crate::tree::spacer(),
3088 crate::widgets::switch::switch(false).key("only-toggle"),
3089 ])
3090 .gap(crate::tokens::SPACE_2)
3091 .width(Size::Fill(1.0))])
3092 .gap(crate::tokens::SPACE_2)
3093 .width(Size::Fill(1.0));
3094
3095 let mut root = crate::tree::scroll([body])
3096 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
3097 .width(Size::Fixed(480.0))
3098 .height(Size::Fixed(320.0));
3099 let mut state = UiState::new();
3100 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
3101 let report = lint(&root, &state);
3102
3103 assert!(
3104 !report
3105 .findings
3106 .iter()
3107 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
3108 "expected no ScrollbarObscuresFocusable when content fits in the viewport (no thumb rendered)\n{}",
3109 report.text()
3110 );
3111 }
3112
3113 #[test]
3114 fn unkeyed_tooltip_reports_dead_tooltip() {
3115 let root = crate::text("abc1234").tooltip("commit sha");
3121
3122 let report = lint_one(root);
3123
3124 assert!(
3125 report
3126 .findings
3127 .iter()
3128 .any(|f| f.kind == FindingKind::DeadTooltip),
3129 "expected DeadTooltip on unkeyed tooltipped text\n{}",
3130 report.text()
3131 );
3132 }
3133
3134 #[test]
3135 fn keyed_tooltip_satisfies_dead_tooltip_policy() {
3136 let root = crate::text("abc1234").key("sha").tooltip("commit sha");
3139
3140 let report = lint_one(root);
3141
3142 assert!(
3143 !report
3144 .findings
3145 .iter()
3146 .any(|f| f.kind == FindingKind::DeadTooltip),
3147 "{}",
3148 report.text()
3149 );
3150 }
3151
3152 fn lint_windowed(mut root: El) -> LintReport {
3153 let mut ui_state = UiState::new();
3154 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 640.0, 480.0));
3155 lint(&root, &ui_state)
3156 }
3157
3158 #[test]
3159 fn flush_toolbar_text_reports_unpadded_viewport_leaf() {
3160 let root = crate::column([crate::text("Library")]);
3165
3166 let report = lint_windowed(root);
3167
3168 let findings: Vec<_> = report
3169 .findings
3170 .iter()
3171 .filter(|f| f.kind == FindingKind::UnpaddedViewportLeaf)
3172 .collect();
3173 assert_eq!(
3174 findings.len(),
3175 1,
3176 "one leaf flush on several sides folds into one finding\n{}",
3177 report.text()
3178 );
3179 let msg = &findings[0].message;
3180 assert!(
3181 msg.contains("top/right/left") && msg.contains("page([...])"),
3182 "message should name the sides and the fix: {msg}"
3183 );
3184 }
3185
3186 #[test]
3187 fn padded_page_root_satisfies_viewport_leaf_policy() {
3188 let report = lint_windowed(crate::page([crate::text("Library")]));
3190 assert!(
3191 !report
3192 .findings
3193 .iter()
3194 .any(|f| f.kind == FindingKind::UnpaddedViewportLeaf),
3195 "{}",
3196 report.text()
3197 );
3198 }
3199
3200 #[test]
3201 fn bare_leaf_root_skips_viewport_leaf_policy() {
3202 let report = lint_windowed(crate::text("just a fragment"));
3205 assert!(
3206 !report
3207 .findings
3208 .iter()
3209 .any(|f| f.kind == FindingKind::UnpaddedViewportLeaf),
3210 "{}",
3211 report.text()
3212 );
3213 }
3214
3215 #[test]
3216 fn scrolled_content_skips_viewport_leaf_policy() {
3217 let root = crate::column([crate::scroll([crate::column([crate::text("nav item")])])
3223 .height(crate::Size::Fill(1.0))]);
3224 let report = lint_windowed(root);
3225 assert!(
3226 !report
3227 .findings
3228 .iter()
3229 .any(|f| f.kind == FindingKind::UnpaddedViewportLeaf),
3230 "{}",
3231 report.text()
3232 );
3233 }
3234
3235 #[test]
3236 fn full_bleed_leaf_can_allow_viewport_leaf_lint() {
3237 let root = crate::column([crate::text("intentional full-bleed strip")
3238 .allow_lint(FindingKind::UnpaddedViewportLeaf)]);
3239 let report = lint_windowed(root);
3240 assert!(
3241 !report
3242 .findings
3243 .iter()
3244 .any(|f| f.kind == FindingKind::UnpaddedViewportLeaf),
3245 "{}",
3246 report.text()
3247 );
3248 }
3249
3250 #[test]
3251 fn tooltip_under_non_overlay_root_reports_missing_overlay_root() {
3252 let root = crate::column([
3257 crate::text("toolbar"),
3258 crate::text("cell").key("cell").tooltip("a tooltip"),
3259 ]);
3260
3261 let report = lint_one(root);
3262
3263 let f = report
3264 .findings
3265 .iter()
3266 .find(|f| f.kind == FindingKind::TooltipWithoutOverlayRoot)
3267 .unwrap_or_else(|| {
3268 panic!(
3269 "expected TooltipWithoutOverlayRoot under a column root\n{}",
3270 report.text()
3271 )
3272 });
3273 assert!(
3274 f.message.contains("overlays(main, [])"),
3275 "message should carry the fix: {}",
3276 f.message
3277 );
3278 }
3279
3280 #[test]
3281 fn tooltip_under_overlay_root_satisfies_overlay_root_policy() {
3282 for root in [
3285 crate::overlays(
3286 crate::column([crate::text("cell").key("cell").tooltip("tip")]),
3287 [],
3288 ),
3289 crate::stack([crate::text("cell").key("cell").tooltip("tip")]),
3290 ] {
3291 let report = lint_one(root);
3292 assert!(
3293 !report
3294 .findings
3295 .iter()
3296 .any(|f| f.kind == FindingKind::TooltipWithoutOverlayRoot),
3297 "{}",
3298 report.text()
3299 );
3300 }
3301 }
3302
3303 #[test]
3304 fn tooltip_free_tree_satisfies_overlay_root_policy() {
3305 let report = lint_one(crate::column([crate::text("plain")]));
3306 assert!(
3307 !report
3308 .findings
3309 .iter()
3310 .any(|f| f.kind == FindingKind::TooltipWithoutOverlayRoot),
3311 "{}",
3312 report.text()
3313 );
3314 }
3315
3316 #[test]
3317 fn unkeyed_tooltip_inside_keyed_ancestor_still_reports_dead_tooltip() {
3318 let root =
3324 crate::row([crate::text("inner detail").tooltip("never shown")]).key("outer-row");
3325
3326 let report = lint_one(root);
3327
3328 assert!(
3329 report
3330 .findings
3331 .iter()
3332 .any(|f| f.kind == FindingKind::DeadTooltip),
3333 "expected DeadTooltip on unkeyed leaf even with keyed ancestor\n{}",
3334 report.text()
3335 );
3336 }
3337
3338 #[test]
3339 fn focus_ring_lint_is_quiet_inside_form_after_padding_fix() {
3340 let selection = crate::selection::Selection::default();
3344 let mut root = crate::tree::scroll([crate::widgets::form::form([
3345 crate::widgets::form::form_item([crate::widgets::form::form_control(
3346 crate::widgets::text_input::text_input("", &selection, "field"),
3347 )]),
3348 ])])
3349 .width(Size::Fixed(300.0))
3350 .height(Size::Fixed(120.0));
3351 let mut state = UiState::new();
3352 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3353 let report = lint(&root, &state);
3354
3355 assert!(
3356 !report
3357 .findings
3358 .iter()
3359 .any(|f| f.kind == FindingKind::FocusRingObscured),
3360 "{}",
3361 report.text()
3362 );
3363 }
3364
3365 fn lint_one_with_metrics(mut root: El) -> LintReport {
3370 crate::metrics::ThemeMetrics::default().apply_to_tree(&mut root);
3371 let mut ui_state = UiState::new();
3372 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 120.0));
3373 lint(&root, &ui_state)
3374 }
3375
3376 #[test]
3377 fn handrolled_rounded_container_with_flat_filled_header_reports_corner_stackup() {
3378 let parent = crate::column([
3382 crate::row([crate::text("Header")])
3383 .fill(crate::tokens::MUTED)
3384 .width(Size::Fill(1.0))
3385 .height(Size::Fixed(24.0)),
3386 crate::row([crate::text("Body")])
3387 .width(Size::Fill(1.0))
3388 .height(Size::Fixed(60.0)),
3389 ])
3390 .fill(crate::tokens::CARD)
3391 .stroke(crate::tokens::BORDER)
3392 .radius(crate::tokens::RADIUS_LG)
3393 .width(Size::Fixed(160.0))
3394 .height(Size::Fixed(96.0));
3395
3396 let report = lint_one(parent);
3397
3398 let found = report
3399 .findings
3400 .iter()
3401 .find(|f| f.kind == FindingKind::CornerStackup);
3402 let found =
3403 found.unwrap_or_else(|| panic!("expected CornerStackup, got:\n{}", report.text()));
3404 assert!(
3405 found.message.contains("Corners::top"),
3406 "top-strip leak should suggest Corners::top, got: {}",
3407 found.message
3408 );
3409 }
3410
3411 #[test]
3412 fn handrolled_rounded_container_with_inset_child_does_not_report_corner_stackup() {
3413 let parent = crate::column([crate::row([crate::text("Header")])
3415 .fill(crate::tokens::MUTED)
3416 .width(Size::Fill(1.0))
3417 .height(Size::Fixed(24.0))])
3418 .fill(crate::tokens::CARD)
3419 .stroke(crate::tokens::BORDER)
3420 .radius(crate::tokens::RADIUS_LG)
3421 .padding(Sides::all(crate::tokens::RADIUS_LG))
3422 .width(Size::Fixed(160.0))
3423 .height(Size::Fixed(96.0));
3424
3425 let report = lint_one(parent);
3426 assert!(
3427 !report
3428 .findings
3429 .iter()
3430 .any(|f| f.kind == FindingKind::CornerStackup),
3431 "inset child should not trip the lint, got:\n{}",
3432 report.text()
3433 );
3434 }
3435
3436 #[test]
3437 fn handrolled_rounded_container_with_matching_corners_does_not_report_corner_stackup() {
3438 let parent = crate::column([crate::row([crate::text("Header")])
3439 .fill(crate::tokens::MUTED)
3440 .radius(Corners::top(crate::tokens::RADIUS_LG))
3441 .width(Size::Fill(1.0))
3442 .height(Size::Fixed(24.0))])
3443 .fill(crate::tokens::CARD)
3444 .stroke(crate::tokens::BORDER)
3445 .radius(crate::tokens::RADIUS_LG)
3446 .width(Size::Fixed(160.0))
3447 .height(Size::Fixed(96.0));
3448
3449 let report = lint_one(parent);
3450 assert!(
3451 !report
3452 .findings
3453 .iter()
3454 .any(|f| f.kind == FindingKind::CornerStackup),
3455 "matching corners should not trip the lint, got:\n{}",
3456 report.text()
3457 );
3458 }
3459
3460 #[test]
3461 fn canonical_card_recipe_does_not_report_corner_stackup_after_metrics() {
3462 let root = crate::widgets::card::card([
3465 crate::widgets::card::card_header([crate::text("Header")]).fill(crate::tokens::MUTED),
3466 crate::widgets::card::card_content([crate::text("Body")]),
3467 ])
3468 .width(Size::Fixed(180.0))
3469 .height(Size::Fixed(110.0));
3470
3471 let report = lint_one_with_metrics(root);
3472 assert!(
3473 !report
3474 .findings
3475 .iter()
3476 .any(|f| f.kind == FindingKind::CornerStackup),
3477 "canonical card_header(...).fill(...) recipe should be quiet after metrics pass, got:\n{}",
3478 report.text()
3479 );
3480 }
3481
3482 #[test]
3483 fn bare_card_with_flush_content_reports_unpadded_surface_panel_issue_24() {
3484 let root = crate::widgets::card::card([crate::row([
3489 crate::text("some title").bold(),
3490 crate::text("description line").muted(),
3491 ])
3492 .gap(crate::tokens::SPACE_2)
3493 .width(Size::Fill(1.0))])
3494 .width(Size::Fixed(200.0))
3495 .height(Size::Fixed(80.0));
3496
3497 let report = lint_one(root);
3498 let f = report
3499 .findings
3500 .iter()
3501 .find(|f| f.kind == FindingKind::UnpaddedSurfacePanel)
3502 .unwrap_or_else(|| {
3503 panic!(
3504 "expected UnpaddedSurfacePanel finding, got:\n{}",
3505 report.text()
3506 )
3507 });
3508 assert!(
3509 f.message.contains("top"),
3510 "expected the flushing-side list to call out `top`, got: {}",
3511 f.message
3512 );
3513 }
3514
3515 #[test]
3516 fn card_with_explicit_padding_does_not_report_unpadded_surface_panel() {
3517 let root = crate::widgets::card::card([
3520 crate::row([crate::text("title").bold()]).width(Size::Fill(1.0))
3521 ])
3522 .padding(Sides::all(crate::tokens::SPACE_4))
3523 .width(Size::Fixed(200.0))
3524 .height(Size::Fixed(60.0));
3525
3526 let report = lint_one(root);
3527 assert!(
3528 !report
3529 .findings
3530 .iter()
3531 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
3532 "{}",
3533 report.text()
3534 );
3535 }
3536
3537 #[test]
3538 fn canonical_card_anatomy_does_not_report_unpadded_surface_panel() {
3539 let root = crate::widgets::card::card([
3543 crate::widgets::card::card_header([crate::widgets::card::card_title("Header")]),
3544 crate::widgets::card::card_content([crate::text("Body")]),
3545 crate::widgets::card::card_footer([crate::text("footer")]),
3546 ])
3547 .width(Size::Fixed(220.0))
3548 .height(Size::Fixed(160.0));
3549
3550 let report = lint_one(root);
3551 assert!(
3552 !report
3553 .findings
3554 .iter()
3555 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
3556 "canonical slot anatomy should be quiet, got:\n{}",
3557 report.text()
3558 );
3559 }
3560
3561 #[test]
3562 fn sidebar_widget_does_not_report_unpadded_surface_panel() {
3563 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
3566
3567 let report = lint_one(root);
3568 assert!(
3569 !report
3570 .findings
3571 .iter()
3572 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
3573 "{}",
3574 report.text()
3575 );
3576 }
3577
3578 #[test]
3579 fn raw_color_fires_without_allow_lint() {
3580 let root = crate::column(Vec::<El>::new())
3584 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
3585 .width(Size::Fixed(40.0))
3586 .height(Size::Fixed(40.0));
3587
3588 let report = lint_one(root);
3589 assert!(
3590 report
3591 .findings
3592 .iter()
3593 .any(|f| f.kind == FindingKind::RawColor),
3594 "{}",
3595 report.text()
3596 );
3597 }
3598
3599 #[test]
3600 fn allow_lint_silences_finding_on_same_node() {
3601 let root = crate::column(Vec::<El>::new())
3604 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
3605 .allow_lint(FindingKind::RawColor)
3606 .width(Size::Fixed(40.0))
3607 .height(Size::Fixed(40.0));
3608
3609 let report = lint_one(root);
3610 assert!(
3611 !report
3612 .findings
3613 .iter()
3614 .any(|f| f.kind == FindingKind::RawColor),
3615 "expected RawColor silenced on the allowed node, got:\n{}",
3616 report.text()
3617 );
3618 }
3619
3620 #[test]
3621 fn allow_lint_does_not_leak_to_siblings() {
3622 let row = crate::row([
3625 crate::column(Vec::<El>::new())
3626 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
3627 .allow_lint(FindingKind::RawColor)
3628 .width(Size::Fixed(20.0))
3629 .height(Size::Fixed(20.0)),
3630 crate::column(Vec::<El>::new())
3631 .fill(crate::Color::srgb_u8a(70, 80, 90, 255))
3632 .width(Size::Fixed(20.0))
3633 .height(Size::Fixed(20.0)),
3634 ])
3635 .width(Size::Fixed(160.0))
3636 .height(Size::Fixed(40.0));
3637
3638 let report = lint_one(row);
3639 let raw_color_count = report
3640 .findings
3641 .iter()
3642 .filter(|f| f.kind == FindingKind::RawColor)
3643 .count();
3644 assert_eq!(
3645 raw_color_count,
3646 1,
3647 "expected exactly one RawColor finding (the un-silenced sibling), got:\n{}",
3648 report.text()
3649 );
3650 }
3651
3652 #[test]
3653 fn allow_lint_does_not_propagate_to_descendants() {
3654 let parent = crate::column([crate::column(Vec::<El>::new())
3657 .fill(crate::Color::srgb_u8a(70, 80, 90, 255))
3658 .width(Size::Fixed(20.0))
3659 .height(Size::Fixed(20.0))])
3660 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
3661 .allow_lint(FindingKind::RawColor)
3662 .width(Size::Fixed(40.0))
3663 .height(Size::Fixed(40.0));
3664
3665 let report = lint_one(parent);
3666 assert!(
3667 report
3668 .findings
3669 .iter()
3670 .any(|f| f.kind == FindingKind::RawColor),
3671 "child RawColor must still fire when only parent silenced it, got:\n{}",
3672 report.text()
3673 );
3674 }
3675
3676 #[test]
3677 fn allow_lint_silences_text_overflow_on_same_node() {
3678 let root = crate::text("A very long dashboard label")
3682 .allow_lint(FindingKind::TextOverflow)
3683 .width(Size::Fixed(42.0))
3684 .height(Size::Fixed(20.0));
3685
3686 let report = lint_one(root);
3687 assert!(
3688 !report
3689 .findings
3690 .iter()
3691 .any(|f| f.kind == FindingKind::TextOverflow),
3692 "{}",
3693 report.text()
3694 );
3695 }
3696
3697 #[test]
3698 fn lint_report_retain_drops_matching_findings() {
3699 let root = crate::row([crate::text("a").key("dup"), crate::text("b").key("dup")])
3706 .width(Size::Fixed(160.0))
3707 .height(Size::Fixed(20.0));
3708
3709 let mut report = lint_one(root);
3710 assert!(
3711 report
3712 .findings
3713 .iter()
3714 .any(|f| f.kind == FindingKind::DuplicateId),
3715 "baseline DuplicateId must fire, got:\n{}",
3716 report.text()
3717 );
3718
3719 report.retain(|f| f.kind != FindingKind::DuplicateId);
3720 assert!(
3721 !report
3722 .findings
3723 .iter()
3724 .any(|f| f.kind == FindingKind::DuplicateId),
3725 "retain should have dropped DuplicateId, got:\n{}",
3726 report.text()
3727 );
3728 }
3729}