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 HitOverflowCollision,
123 DeadTooltip,
138 CornerStackup,
155 UnpaddedSurfacePanel,
182}
183
184#[derive(Clone, Debug, Default)]
185#[non_exhaustive]
186pub struct LintReport {
187 pub findings: Vec<Finding>,
188}
189
190impl LintReport {
191 pub fn retain(&mut self, mut pred: impl FnMut(&Finding) -> bool) {
199 self.findings.retain(|f| pred(f));
200 }
201
202 pub fn text(&self) -> String {
203 if self.findings.is_empty() {
204 return "no findings\n".to_string();
205 }
206 let mut s = String::new();
207 for f in &self.findings {
208 let _ = writeln!(
209 s,
210 "{kind:?} node={id} {source} :: {msg}",
211 kind = f.kind,
212 id = f.node_id,
213 source = if f.source.line == 0 {
214 "<no-source>".to_string()
215 } else {
216 format!("{}:{}", short_path(f.source.file), f.source.line)
217 },
218 msg = f.message,
219 );
220 }
221 s
222 }
223}
224
225pub fn lint(root: &El, ui_state: &UiState) -> LintReport {
235 let mut r = LintReport::default();
236 let mut seen_ids: std::collections::BTreeMap<String, usize> = Default::default();
237 walk(
238 root,
239 None,
240 None,
241 &ClipCtx::None,
242 ui_state,
243 &mut r,
244 &mut seen_ids,
245 );
246 for (id, n) in seen_ids {
247 if n > 1 {
248 r.findings.push(Finding {
249 kind: FindingKind::DuplicateId,
250 node_id: id.clone(),
251 source: Source::default(),
252 message: format!("{n} nodes share id {id}"),
253 });
254 }
255 }
256 r
257}
258
259fn is_from_user(source: Source) -> bool {
260 !source.from_library
261}
262
263fn push_for(r: &mut LintReport, target: &El, finding: Finding) {
270 debug_assert_eq!(
271 finding.node_id, target.computed_id,
272 "lint::push_for: target must be the finding's attribution node",
273 );
274 if target.allow_lint.contains(&finding.kind) {
275 return;
276 }
277 r.findings.push(finding);
278}
279
280#[derive(Clone)]
289enum ClipCtx {
290 None,
291 Static(Rect),
293 Scrolling {
297 rect: Rect,
298 scroll_axis: Axis,
299 node_id: String,
300 },
301}
302
303fn walk(
304 n: &El,
305 parent_kind: Option<&Kind>,
306 parent_blame: Option<Source>,
307 nearest_clip: &ClipCtx,
308 ui_state: &UiState,
309 r: &mut LintReport,
310 seen: &mut std::collections::BTreeMap<String, usize>,
311) {
312 *seen.entry(n.computed_id.clone()).or_default() += 1;
313 let computed = ui_state.rect(&n.computed_id);
314
315 let from_user_self = is_from_user(n.source);
316 let self_blame = if from_user_self {
323 Some(n.source)
324 } else {
325 parent_blame
326 };
327
328 let inside_inlines = matches!(parent_kind, Some(Kind::Inlines));
334
335 if from_user_self {
338 if let Some(c) = n.fill
339 && c.token.is_none()
340 && c.a > 0.0
341 {
342 push_for(
343 r,
344 n,
345 Finding {
346 kind: FindingKind::RawColor,
347 node_id: n.computed_id.clone(),
348 source: n.source,
349 message: format!(
350 "fill is a raw rgba({},{},{},{}) — use a token",
351 c.r, c.g, c.b, c.a
352 ),
353 },
354 );
355 }
356 if let Some(c) = n.stroke
357 && c.token.is_none()
358 && c.a > 0.0
359 {
360 push_for(
361 r,
362 n,
363 Finding {
364 kind: FindingKind::RawColor,
365 node_id: n.computed_id.clone(),
366 source: n.source,
367 message: format!(
368 "stroke is a raw rgba({},{},{},{}) — use a token",
369 c.r, c.g, c.b, c.a
370 ),
371 },
372 );
373 }
374 if let Some(c) = n.text_color
375 && c.token.is_none()
376 && c.a > 0.0
377 {
378 push_for(
379 r,
380 n,
381 Finding {
382 kind: FindingKind::RawColor,
383 node_id: n.computed_id.clone(),
384 source: n.source,
385 message: format!(
386 "text_color is a raw rgba({},{},{},{}) — use a token",
387 c.r, c.g, c.b, c.a
388 ),
389 },
390 );
391 }
392 if n.tooltip.is_some() && n.key.is_none() {
398 push_for(
399 r,
400 n,
401 Finding {
402 kind: FindingKind::DeadTooltip,
403 node_id: n.computed_id.clone(),
404 source: n.source,
405 message: ".tooltip() on a node without .key() never fires — hit-test only \
406 returns keyed nodes, so hover skips past this leaf to the nearest \
407 keyed ancestor. Add .key(\"…\") on the same node that carries the \
408 tooltip; for info-only chrome inside list rows, a synthetic key \
409 like \"row:{idx}.<part>\" is enough."
410 .to_string(),
411 },
412 );
413 }
414
415 if n.fill.is_none() && matches!(n.surface_role, SurfaceRole::Panel) {
422 push_for(
423 r,
424 n,
425 Finding {
426 kind: FindingKind::MissingSurfaceFill,
427 node_id: n.computed_id.clone(),
428 source: n.source,
429 message:
430 "surface_role(Panel) without a fill paints only stroke + shadow — \
431 wrap in card() / sidebar() / dialog() for the canonical recipe, or set .fill(tokens::CARD)"
432 .to_string(),
433 },
434 );
435 }
436
437 if matches!(n.surface_role, SurfaceRole::Panel) {
438 check_unpadded_surface_panel(n, computed, ui_state, r, n.source);
439 }
440
441 if matches!(n.kind, Kind::Group) && !n.children.is_empty() {
455 let card_fill = n
456 .fill
457 .as_ref()
458 .and_then(|c| c.token)
459 .is_some_and(|t| t == "card");
460 let border_stroke = n
461 .stroke
462 .as_ref()
463 .and_then(|c| c.token)
464 .is_some_and(|t| t == "border");
465 if card_fill && border_stroke {
466 let is_panel_surface = matches!(n.surface_role, SurfaceRole::Panel);
467 let sidebar_width = matches!(n.width, Size::Fixed(w) if (w - crate::tokens::SIDEBAR_WIDTH).abs() < 0.5);
468 if !is_panel_surface {
469 if sidebar_width {
470 push_for(
471 r,
472 n,
473 Finding {
474 kind: FindingKind::ReinventedWidget,
475 node_id: n.computed_id.clone(),
476 source: n.source,
477 message:
478 "Group with fill=CARD, stroke=BORDER, width=SIDEBAR_WIDTH reinvents sidebar() — \
479 use sidebar([sidebar_header(...), sidebar_group([sidebar_menu([sidebar_menu_button(label, current)])])]) \
480 for the panel surface and the canonical row recipe"
481 .to_string(),
482 },
483 );
484 } else {
485 push_for(
495 r,
496 n,
497 Finding {
498 kind: FindingKind::ReinventedWidget,
499 node_id: n.computed_id.clone(),
500 source: n.source,
501 message:
502 "Group with fill=CARD, stroke=BORDER reinvents the panel-surface recipe — \
503 use card([card_header([card_title(\"...\")]), card_content([...])]) / titled_card(\"Title\", [...]) for boxed content, \
504 or sidebar([...]) for a full-height nav/inspector pane (sidebar() also handles the custom-width case via .width(Size::Fixed(...)))"
505 .to_string(),
506 },
507 );
508 }
509 }
510 }
511 }
512 }
513
514 if let Some(blame) = self_blame {
520 lint_row_alignment(n, computed, ui_state, r, blame);
521 lint_overlay_alignment(n, computed, ui_state, r, blame);
522 lint_row_visual_text_spacing(n, ui_state, r, blame);
523 }
524
525 if n.text.is_some()
531 && !inside_inlines
532 && let Some(blame) = self_blame
533 {
534 let available_width = match n.text_wrap {
535 TextWrap::NoWrap => None,
536 TextWrap::Wrap => Some(computed.w),
537 };
538 if let Some(text_layout) = layout::text_layout(n, available_width) {
539 let text_w = text_layout.width + n.padding.left + n.padding.right;
540 let text_h = text_layout.height + n.padding.top + n.padding.bottom;
541 let raw_overflow_x = (text_w - computed.w).max(0.0);
542 let overflow_x = if matches!(
543 (n.text_wrap, n.text_overflow),
544 (TextWrap::NoWrap, TextOverflow::Ellipsis)
545 ) {
546 0.0
547 } else {
548 raw_overflow_x
549 };
550 let overflow_y = (text_h - computed.h).max(0.0);
551 if overflow_x > 0.5 || overflow_y > 0.5 {
552 let is_clipped_nowrap = overflow_x > 0.5
553 && matches!(
554 (n.text_wrap, n.text_overflow),
555 (TextWrap::NoWrap, TextOverflow::Clip)
556 );
557 let kind = if is_clipped_nowrap {
558 FindingKind::TextOverflow
559 } else {
560 FindingKind::Overflow
561 };
562 let pad_y = n.padding.top + n.padding.bottom;
571 let height_is_fixed = matches!(n.height, Size::Fixed(_));
572 let text_alone_fits_height = text_layout.height <= computed.h + 0.5;
573 let padding_eats_fixed_height = overflow_y > 0.5
574 && overflow_x <= 0.5
575 && pad_y > 0.0
576 && text_alone_fits_height
577 && height_is_fixed;
578 let cell_h = text_layout.height;
579 let box_h = computed.h;
580 let message = if kind == FindingKind::TextOverflow {
581 format!(
582 "nowrap text exceeds its box by X={overflow_x:.0}; use .ellipsis(), wrap_text(), or a wider box"
583 )
584 } else if padding_eats_fixed_height {
585 let inner_h = (box_h - pad_y).max(0.0);
586 let pad_x_token = if (n.padding.left - n.padding.right).abs() < 0.5 {
587 format!("{:.0}", n.padding.left)
588 } else {
589 "...".to_string()
590 };
591 let control_h = crate::tokens::CONTROL_HEIGHT;
592 format!(
593 "vertical padding ({pad_y:.0}px) makes the inner content rect ({inner_h:.0}px) shorter than the text cell ({cell_h:.0}px) on a fixed-height box ({box_h:.0}px) — \
594 the label can't vertically center and paints into the padding band, off-center by Y={overflow_y:.0}. \
595 Reduce vertical padding (e.g. `Sides::xy({pad_x_token}, 0.0)` — `.padding(scalar)` is `Sides::all(scalar)`, which usually isn't what you want on a control-height box) or increase height (tokens::CONTROL_HEIGHT = {control_h:.0}px)"
596 )
597 } else if overflow_y > 0.5 && overflow_x <= 0.5 {
598 format!(
599 "text cell ({cell_h:.0}px) exceeds box height ({box_h:.0}px) by Y={overflow_y:.0}; \
600 increase height, reduce text size, or use paragraph()/wrap_text() with fewer lines"
601 )
602 } else {
603 format!(
604 "text content exceeds its box by X={overflow_x:.0} Y={overflow_y:.0}; use paragraph()/wrap_text(), a wider box, or explicit clipping"
605 )
606 };
607 push_for(
608 r,
609 n,
610 Finding {
611 kind,
612 node_id: n.computed_id.clone(),
613 source: blame,
614 message,
615 },
616 );
617 }
618 }
619 }
620
621 let suppress_overflow = n.scrollable
636 || n.clip
637 || matches!(n.kind, Kind::Inlines)
638 || matches!(n.kind, Kind::Custom("toast_stack"));
639
640 let parent_main_overran =
650 !suppress_overflow && flex_main_axis_overflowed(n, computed, ui_state);
651
652 let child_clip = if n.clip {
659 if n.scrollable {
660 ClipCtx::Scrolling {
661 rect: computed,
662 scroll_axis: n.axis,
663 node_id: n.computed_id.clone(),
664 }
665 } else {
666 ClipCtx::Static(computed)
667 }
668 } else {
669 nearest_clip.clone()
670 };
671
672 if !matches!(n.axis, Axis::Overlay)
673 && let Some(blame) = self_blame
674 {
675 lint_hit_overflow_collisions(n, &child_clip, ui_state, r, blame);
676 }
677
678 for (child_idx, c) in n.children.iter().enumerate() {
679 let from_user_child = is_from_user(c.source);
680 let child_blame = if from_user_child {
681 Some(c.source)
682 } else {
683 self_blame
684 };
685
686 let c_rect = ui_state.rect(&c.computed_id);
687 if !suppress_overflow
688 && !rect_contains(computed, c_rect, 0.5)
689 && let Some(blame) = child_blame
690 {
691 let dx_left = (computed.x - c_rect.x).max(0.0);
692 let dx_right = (c_rect.right() - computed.right()).max(0.0);
693 let dy_top = (computed.y - c_rect.y).max(0.0);
694 let dy_bottom = (c_rect.bottom() - computed.bottom()).max(0.0);
695 push_for(
696 r,
697 c,
698 Finding {
699 kind: FindingKind::Overflow,
700 node_id: c.computed_id.clone(),
701 source: blame,
702 message: format!(
703 "child overflows parent {parent_id} by L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}",
704 parent_id = n.computed_id,
705 ),
706 },
707 );
708 }
709
710 let main_axis_is_hug = match n.axis {
716 Axis::Row => matches!(c.width, Size::Hug),
717 Axis::Column => matches!(c.height, Size::Hug),
718 Axis::Overlay => false,
719 };
720 if parent_main_overran
721 && main_axis_is_hug
722 && c.text.is_some()
723 && c.text_wrap == TextWrap::NoWrap
724 && c.text_overflow == TextOverflow::Ellipsis
725 && let Some(blame) = child_blame
726 {
727 push_for(
728 r,
729 c,
730 Finding {
731 kind: FindingKind::TextOverflow,
732 node_id: c.computed_id.clone(),
733 source: blame,
734 message:
735 ".ellipsis() has no effect on Size::Hug text — Hug forces the rect to the intrinsic content width, so the truncation budget equals the content and no glyph is ever trimmed. Set Size::Fill(_) or Size::Fixed(_) on the text or on a wrapping container so the layout can constrain the rect."
736 .to_string(),
737 },
738 );
739 }
740
741 if from_user_child
749 && c.fill.is_some()
750 && n.radius.any_nonzero()
751 && let Some(blame) = child_blame
752 {
753 check_corner_stackup(n, computed, c, c_rect, r, blame);
754 }
755
756 if from_user_child
757 && c.focusable
758 && let Some(blame) = child_blame
759 {
760 check_focus_ring_obscured(
761 c,
762 c_rect,
763 &child_clip,
764 &n.children[child_idx + 1..],
765 ui_state,
766 r,
767 blame,
768 );
769 check_scrollbar_overlap(c, c_rect, &child_clip, ui_state, r, blame);
773 }
774
775 walk(
776 c,
777 Some(&n.kind),
778 child_blame,
779 &child_clip,
780 ui_state,
781 r,
782 seen,
783 );
784 }
785}
786
787fn focus_ring_overflow(n: &El) -> Sides {
788 match n.focus_ring_placement {
789 crate::tree::FocusRingPlacement::Outside => Sides::all(crate::tokens::RING_WIDTH),
790 crate::tree::FocusRingPlacement::Inside => Sides::zero(),
791 }
792}
793
794fn has_hit_overflow(sides: Sides) -> bool {
795 sides.left > 0.5 || sides.right > 0.5 || sides.top > 0.5 || sides.bottom > 0.5
796}
797
798fn clip_rect(ctx: &ClipCtx) -> Option<Rect> {
799 match ctx {
800 ClipCtx::None => None,
801 ClipCtx::Static(rect) | ClipCtx::Scrolling { rect, .. } => Some(*rect),
802 }
803}
804
805fn clipped_rect(rect: Rect, ctx: &ClipCtx) -> Option<Rect> {
806 match clip_rect(ctx) {
807 Some(clip) => rect.intersect(clip),
808 None => Some(rect),
809 }
810}
811
812fn lint_hit_overflow_collisions(
819 parent: &El,
820 child_clip: &ClipCtx,
821 ui_state: &UiState,
822 r: &mut LintReport,
823 blame: Source,
824) {
825 for (left_idx, left) in parent.children.iter().enumerate() {
826 if left.key.is_none() {
827 continue;
828 }
829 let left_rect = ui_state.rect(&left.computed_id);
830 let Some(left_hit) = clipped_rect(left_rect.outset(left.hit_overflow), child_clip) else {
831 continue;
832 };
833 for right in parent.children.iter().skip(left_idx + 1) {
834 if right.key.is_none() {
835 continue;
836 }
837 if !has_hit_overflow(left.hit_overflow) && !has_hit_overflow(right.hit_overflow) {
838 continue;
839 }
840 let right_rect = ui_state.rect(&right.computed_id);
841 let Some(right_hit) = clipped_rect(right_rect.outset(right.hit_overflow), child_clip)
842 else {
843 continue;
844 };
845 let Some(overlap) = left_hit.intersect(right_hit) else {
846 continue;
847 };
848 if overlap.w <= 0.5 || overlap.h <= 0.5 {
849 continue;
850 }
851
852 let left_visual_contains = left_rect.contains(overlap.center_x(), overlap.center_y());
853 let right_visual_contains = right_rect.contains(overlap.center_x(), overlap.center_y());
854 if left_visual_contains && right_visual_contains {
855 continue;
859 }
860
861 let earlier = left.key.as_deref().unwrap_or("<unkeyed>");
862 let later = right.key.as_deref().unwrap_or("<unkeyed>");
863 let owner = if has_hit_overflow(right.hit_overflow) {
864 right
865 } else {
866 left
867 };
868 push_for(
869 r,
870 owner,
871 Finding {
872 kind: FindingKind::HitOverflowCollision,
873 node_id: owner.computed_id.clone(),
874 source: blame,
875 message: format!(
876 "expanded hit targets for sibling keys `{earlier}` and `{later}` overlap by {w:.0}x{h:.0}px — \
877 hit-test resolves the collision by paint order, so `{later}` owns that invisible band. \
878 Reduce `.hit_overflow(...)`, add real gap/padding, or make one visible row/control own the full intended target.",
879 w = overlap.w,
880 h = overlap.h,
881 ),
882 },
883 );
884 }
885 }
886}
887
888fn check_corner_stackup(
896 parent: &El,
897 parent_rect: Rect,
898 child: &El,
899 child_rect: Rect,
900 r: &mut LintReport,
901 blame: Source,
902) {
903 let pr = parent.radius;
904 let cr = child.radius;
905 let tl = (
907 pr.tl,
908 cr.tl,
909 Rect::new(parent_rect.x, parent_rect.y, pr.tl, pr.tl),
910 );
911 let tr = (
912 pr.tr,
913 cr.tr,
914 Rect::new(
915 parent_rect.x + parent_rect.w - pr.tr,
916 parent_rect.y,
917 pr.tr,
918 pr.tr,
919 ),
920 );
921 let br = (
922 pr.br,
923 cr.br,
924 Rect::new(
925 parent_rect.x + parent_rect.w - pr.br,
926 parent_rect.y + parent_rect.h - pr.br,
927 pr.br,
928 pr.br,
929 ),
930 );
931 let bl = (
932 pr.bl,
933 cr.bl,
934 Rect::new(
935 parent_rect.x,
936 parent_rect.y + parent_rect.h - pr.bl,
937 pr.bl,
938 pr.bl,
939 ),
940 );
941 let leaks_at = |(p_r, c_r, corner_box): (f32, f32, Rect)| -> bool {
942 if p_r <= 0.5 || c_r + 0.5 >= p_r {
943 return false;
944 }
945 match child_rect.intersect(corner_box) {
946 Some(overlap) => overlap.w >= 0.5 && overlap.h >= 0.5,
947 None => false,
948 }
949 };
950 let (leak_tl, leak_tr, leak_br, leak_bl) =
951 (leaks_at(tl), leaks_at(tr), leaks_at(br), leaks_at(bl));
952 if !(leak_tl || leak_tr || leak_br || leak_bl) {
953 return;
954 }
955 let (descriptor, helper) = match (leak_tl, leak_tr, leak_br, leak_bl) {
956 (true, true, false, false) => ("the parent's top corners", "Corners::top(...)"),
957 (false, false, true, true) => ("the parent's bottom corners", "Corners::bottom(...)"),
958 (true, false, false, true) => ("the parent's left corners", "Corners::left(...)"),
959 (false, true, true, false) => ("the parent's right corners", "Corners::right(...)"),
960 (true, true, true, true) => ("the parent's corners", "Corners::all(...)"),
961 _ => (
963 "a parent corner",
964 "Corners { tl, tr, br, bl } with the matching corner set",
965 ),
966 };
967 push_for(
968 r,
969 child,
970 Finding {
971 kind: FindingKind::CornerStackup,
972 node_id: child.computed_id.clone(),
973 source: blame,
974 message: format!(
975 "filled child paints into {descriptor} (rounded parent, max radius={pr_max:.0}) — \
976 the flat corners obscure the parent's curve and stroke. \
977 Set `.radius({helper})` on the child so its corners follow the parent's curve, \
978 or add padding to the parent so the child is inset from the curve.",
979 pr_max = pr.max(),
980 ),
981 },
982 );
983}
984
985fn check_unpadded_surface_panel(
996 panel: &El,
997 panel_rect: Rect,
998 ui_state: &UiState,
999 r: &mut LintReport,
1000 blame: Source,
1001) {
1002 let touch_eps = crate::tokens::RING_WIDTH;
1005 const PAD_EPS: f32 = 0.5;
1008
1009 let mut top = (false, false);
1011 let mut right = (false, false);
1012 let mut bottom = (false, false);
1013 let mut left = (false, false);
1014
1015 for c in &panel.children {
1016 let cr = ui_state.rect(&c.computed_id);
1017 if cr.w <= PAD_EPS || cr.h <= PAD_EPS {
1018 continue;
1020 }
1021 if (cr.y - panel_rect.y).abs() <= touch_eps {
1022 top.0 = true;
1023 if c.padding.top > PAD_EPS {
1024 top.1 = true;
1025 }
1026 }
1027 if (panel_rect.right() - cr.right()).abs() <= touch_eps {
1028 right.0 = true;
1029 if c.padding.right > PAD_EPS {
1030 right.1 = true;
1031 }
1032 }
1033 if (panel_rect.bottom() - cr.bottom()).abs() <= touch_eps {
1034 bottom.0 = true;
1035 if c.padding.bottom > PAD_EPS {
1036 bottom.1 = true;
1037 }
1038 }
1039 if (cr.x - panel_rect.x).abs() <= touch_eps {
1040 left.0 = true;
1041 if c.padding.left > PAD_EPS {
1042 left.1 = true;
1043 }
1044 }
1045 }
1046
1047 let pad = panel.padding;
1048 let mut sides: Vec<&'static str> = Vec::new();
1049 if pad.top <= PAD_EPS && top.0 && !top.1 {
1050 sides.push("top");
1051 }
1052 if pad.right <= PAD_EPS && right.0 && !right.1 {
1053 sides.push("right");
1054 }
1055 if pad.bottom <= PAD_EPS && bottom.0 && !bottom.1 {
1056 sides.push("bottom");
1057 }
1058 if pad.left <= PAD_EPS && left.0 && !left.1 {
1059 sides.push("left");
1060 }
1061 if sides.is_empty() {
1062 return;
1063 }
1064 let joined = sides.join("/");
1065 push_for(
1066 r,
1067 panel,
1068 Finding {
1069 kind: FindingKind::UnpaddedSurfacePanel,
1070 node_id: panel.computed_id.clone(),
1071 source: blame,
1072 message: format!(
1073 "Panel-surface children sit flush against the {joined} edge — \
1074 wrap content in the slot anatomy (`card_header(...)` / `card_content(...)` / `card_footer(...)` \
1075 each bake `SPACE_6` padding), or pad the panel itself \
1076 (e.g. `.padding(Sides::all(tokens::SPACE_4))` for dense list-row cards).",
1077 ),
1078 },
1079 );
1080}
1081
1082fn check_focus_ring_obscured(
1083 n: &El,
1084 n_rect: Rect,
1085 nearest_clip: &ClipCtx,
1086 later_siblings: &[El],
1087 ui_state: &UiState,
1088 r: &mut LintReport,
1089 blame: Source,
1090) {
1091 let ring_overflow = focus_ring_overflow(n);
1092 if ring_overflow.left <= 0.5
1093 && ring_overflow.right <= 0.5
1094 && ring_overflow.top <= 0.5
1095 && ring_overflow.bottom <= 0.5
1096 {
1097 return;
1098 }
1099 let band = n_rect.outset(ring_overflow);
1100
1101 let (clip_rect, check_horiz, check_vert) = match nearest_clip {
1105 ClipCtx::None => (None, false, false),
1106 ClipCtx::Static(rect) => (Some(*rect), true, true),
1107 ClipCtx::Scrolling {
1108 rect, scroll_axis, ..
1109 } => match scroll_axis {
1110 Axis::Column => (Some(*rect), true, false),
1111 Axis::Row => (Some(*rect), false, true),
1112 Axis::Overlay => (Some(*rect), true, true),
1113 },
1114 };
1115 if let Some(clip) = clip_rect {
1116 let dx_left = if check_horiz {
1117 (clip.x - band.x).max(0.0)
1118 } else {
1119 0.0
1120 };
1121 let dx_right = if check_horiz {
1122 (band.right() - clip.right()).max(0.0)
1123 } else {
1124 0.0
1125 };
1126 let dy_top = if check_vert {
1127 (clip.y - band.y).max(0.0)
1128 } else {
1129 0.0
1130 };
1131 let dy_bottom = if check_vert {
1132 (band.bottom() - clip.bottom()).max(0.0)
1133 } else {
1134 0.0
1135 };
1136 if dx_left + dx_right + dy_top + dy_bottom > 0.5 {
1137 push_for(
1138 r,
1139 n,
1140 Finding {
1141 kind: FindingKind::FocusRingObscured,
1142 node_id: n.computed_id.clone(),
1143 source: blame,
1144 message: format!(
1145 "focus ring band clipped by ancestor scissor (L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}) — give a clipping ancestor padding ≥ tokens::RING_WIDTH on the clipped side",
1146 ),
1147 },
1148 );
1149 }
1150 }
1151
1152 for sib in later_siblings {
1156 let sib_rect = ui_state.rect(&sib.computed_id);
1157 if let Some(side) = bleed_occlusion(n_rect, ring_overflow, sib_rect)
1158 && paints_pixels(sib)
1159 {
1160 push_for(
1161 r,
1162 n,
1163 Finding {
1164 kind: FindingKind::FocusRingObscured,
1165 node_id: n.computed_id.clone(),
1166 source: blame,
1167 message: format!(
1168 "focus ring band occluded on the {side} edge by later-painted sibling {sib_id} — increase gap to ≥ tokens::RING_WIDTH or restructure so the neighbor doesn't sit on the edge",
1169 sib_id = sib.computed_id,
1170 ),
1171 },
1172 );
1173 break;
1175 }
1176 }
1177}
1178
1179fn check_scrollbar_overlap(
1197 n: &El,
1198 n_rect: Rect,
1199 nearest_clip: &ClipCtx,
1200 ui_state: &UiState,
1201 r: &mut LintReport,
1202 blame: Source,
1203) {
1204 let ClipCtx::Scrolling { node_id, .. } = nearest_clip else {
1205 return;
1206 };
1207 let Some(track) = ui_state.scroll.thumb_tracks.get(node_id).copied() else {
1208 return;
1209 };
1210 let active_w = crate::tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE;
1216 let thumb_left = track.right() - active_w;
1217 let thumb_right = track.right();
1218 let overlap_x = n_rect.right().min(thumb_right) - n_rect.x.max(thumb_left);
1219 if overlap_x <= 0.5 {
1220 return;
1221 }
1222 push_for(
1223 r,
1224 n,
1225 Finding {
1226 kind: FindingKind::ScrollbarObscuresFocusable,
1227 node_id: n.computed_id.clone(),
1228 source: blame,
1229 message: format!(
1230 "scrollbar thumb overlaps this focusable on the right edge by {overlap_x:.0}px (thumb x={thumb_left:.0}..{thumb_right:.0}; control x={ctrl_x:.0}..{ctrl_right:.0}) — move horizontal padding *inside* the scroll, onto a wrapper that constrains children to a narrower content rect, so the thumb sits in a reserved gutter to the right of content",
1231 ctrl_x = n_rect.x,
1232 ctrl_right = n_rect.right(),
1233 ),
1234 },
1235 );
1236}
1237
1238fn paints_pixels(n: &El) -> bool {
1242 n.fill.is_some()
1243 || n.stroke.is_some()
1244 || n.image.is_some()
1245 || n.icon.is_some()
1246 || n.shadow > 0.0
1247 || n.text.is_some()
1248 || !matches!(n.surface_role, SurfaceRole::None)
1249}
1250
1251fn bleed_occlusion(n_rect: Rect, overflow: Sides, sib_rect: Rect) -> Option<&'static str> {
1256 const EPS: f32 = 0.5;
1257 let bands: [(&'static str, Rect); 4] = [
1258 (
1259 "top",
1260 Rect::new(n_rect.x, n_rect.y - overflow.top, n_rect.w, overflow.top),
1261 ),
1262 (
1263 "bottom",
1264 Rect::new(n_rect.x, n_rect.bottom(), n_rect.w, overflow.bottom),
1265 ),
1266 (
1267 "left",
1268 Rect::new(n_rect.x - overflow.left, n_rect.y, overflow.left, n_rect.h),
1269 ),
1270 (
1271 "right",
1272 Rect::new(n_rect.right(), n_rect.y, overflow.right, n_rect.h),
1273 ),
1274 ];
1275 for (side, band) in bands {
1276 if band.w <= 0.0 || band.h <= 0.0 {
1277 continue;
1278 }
1279 let iw = band.right().min(sib_rect.right()) - band.x.max(sib_rect.x);
1280 let ih = band.bottom().min(sib_rect.bottom()) - band.y.max(sib_rect.y);
1281 if iw > EPS && ih > EPS {
1282 return Some(side);
1283 }
1284 }
1285 None
1286}
1287
1288fn lint_row_alignment(
1289 n: &El,
1290 computed: Rect,
1291 ui_state: &UiState,
1292 r: &mut LintReport,
1293 blame: Source,
1294) {
1295 if !matches!(n.axis, Axis::Row) || !matches!(n.align, Align::Stretch) || n.children.len() < 2 {
1296 return;
1297 }
1298 if !n.children.iter().any(is_text_like_child) {
1299 return;
1300 }
1301
1302 let inner = computed.inset(n.padding);
1303 if inner.h <= 0.0 {
1304 return;
1305 }
1306
1307 for child in &n.children {
1308 if !is_fixed_visual_child(child) {
1309 continue;
1310 }
1311 let child_rect = ui_state.rect(&child.computed_id);
1312 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1313 let visibly_short = child_rect.h + 2.0 < inner.h;
1314 if top_pinned && visibly_short {
1315 push_for(
1316 r,
1317 n,
1318 Finding {
1319 kind: FindingKind::Alignment,
1320 node_id: n.computed_id.clone(),
1321 source: blame,
1322 message: "row has a fixed-size visual child pinned to the top beside text; add .align(Align::Center) to vertically center row content"
1323 .to_string(),
1324 },
1325 );
1326 return;
1327 }
1328 }
1329}
1330
1331fn lint_overlay_alignment(
1332 n: &El,
1333 computed: Rect,
1334 ui_state: &UiState,
1335 r: &mut LintReport,
1336 blame: Source,
1337) {
1338 if !matches!(n.axis, Axis::Overlay)
1339 || n.children.is_empty()
1340 || !matches!(n.align, Align::Start | Align::Stretch)
1341 || !matches!(n.justify, Justify::Start | Justify::SpaceBetween)
1342 || !has_visible_surface(n)
1343 {
1344 return;
1345 }
1346
1347 let inner = computed.inset(n.padding);
1348 if inner.w <= 0.0 || inner.h <= 0.0 {
1349 return;
1350 }
1351
1352 for child in &n.children {
1353 if !is_fixed_visual_child(child) {
1354 continue;
1355 }
1356 let child_rect = ui_state.rect(&child.computed_id);
1357 let left_pinned = (child_rect.x - inner.x).abs() <= 0.5;
1358 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1359 let visibly_narrow = child_rect.w + 2.0 < inner.w;
1360 let visibly_short = child_rect.h + 2.0 < inner.h;
1361 if left_pinned && top_pinned && visibly_narrow && visibly_short {
1362 push_for(
1363 r,
1364 n,
1365 Finding {
1366 kind: FindingKind::Alignment,
1367 node_id: n.computed_id.clone(),
1368 source: blame,
1369 message: "overlay has a smaller fixed-size visual child pinned to the top-left; add .align(Align::Center).justify(Justify::Center) to center overlay content"
1370 .to_string(),
1371 },
1372 );
1373 return;
1374 }
1375 }
1376}
1377
1378fn lint_row_visual_text_spacing(n: &El, ui_state: &UiState, r: &mut LintReport, blame: Source) {
1379 if !matches!(n.axis, Axis::Row) || n.children.len() < 2 {
1380 return;
1381 }
1382
1383 for pair in n.children.windows(2) {
1384 let [visual, text] = pair else {
1385 continue;
1386 };
1387 if !is_visual_cluster_child(visual) || !is_text_like_child(text) {
1388 continue;
1389 }
1390
1391 let visual_rect = ui_state.rect(&visual.computed_id);
1392 let text_rect = ui_state.rect(&text.computed_id);
1393 let gap = text_rect.x - visual_rect.right();
1394 if gap < 4.0 {
1395 push_for(
1396 r,
1397 n,
1398 Finding {
1399 kind: FindingKind::Spacing,
1400 node_id: n.computed_id.clone(),
1401 source: blame,
1402 message: format!(
1403 "row places text {:.0}px after an icon/control slot; add .gap(tokens::SPACE_2) or use a stock menu/list row",
1404 gap.max(0.0)
1405 ),
1406 },
1407 );
1408 return;
1409 }
1410 }
1411}
1412
1413fn is_text_like_child(c: &El) -> bool {
1414 c.text.is_some()
1415 || c.children
1416 .iter()
1417 .any(|child| child.text.is_some() || matches!(child.kind, Kind::Text | Kind::Heading))
1418}
1419
1420fn has_visible_surface(n: &El) -> bool {
1421 n.fill.is_some() || n.stroke.is_some()
1422}
1423
1424fn is_fixed_visual_child(c: &El) -> bool {
1425 let fixed_height = matches!(c.height, Size::Fixed(_));
1426 fixed_height
1427 && (c.icon.is_some()
1428 || matches!(c.kind, Kind::Badge)
1429 || matches!(
1430 c.metrics_role,
1431 Some(
1432 MetricsRole::Button
1433 | MetricsRole::IconButton
1434 | MetricsRole::Input
1435 | MetricsRole::Badge
1436 | MetricsRole::TabTrigger
1437 | MetricsRole::ChoiceControl
1438 | MetricsRole::Slider
1439 | MetricsRole::Progress
1440 )
1441 ))
1442}
1443
1444fn is_visual_cluster_child(c: &El) -> bool {
1445 let fixed_box = matches!(c.width, Size::Fixed(_)) && matches!(c.height, Size::Fixed(_));
1446 fixed_box
1447 && (c.icon.is_some()
1448 || matches!(c.kind, Kind::Badge)
1449 || matches!(
1450 c.metrics_role,
1451 Some(MetricsRole::IconButton | MetricsRole::Badge | MetricsRole::ChoiceControl)
1452 )
1453 || (has_visible_surface(c) && c.children.iter().any(is_fixed_visual_child)))
1454}
1455
1456fn rect_contains(parent: Rect, child: Rect, tol: f32) -> bool {
1457 child.x >= parent.x - tol
1458 && child.y >= parent.y - tol
1459 && child.right() <= parent.right() + tol
1460 && child.bottom() <= parent.bottom() + tol
1461}
1462
1463fn flex_main_axis_overflowed(parent: &El, parent_rect: Rect, ui_state: &UiState) -> bool {
1469 let n = parent.children.len();
1470 if n == 0 {
1471 return false;
1472 }
1473 let inner = parent_rect.inset(parent.padding);
1474 let inner_main = match parent.axis {
1475 Axis::Row => inner.w,
1476 Axis::Column => inner.h,
1477 Axis::Overlay => return false,
1478 };
1479 let total_gap = parent.gap * n.saturating_sub(1) as f32;
1480 let consumed: f32 = parent
1481 .children
1482 .iter()
1483 .map(|c| {
1484 let r = ui_state.rect(&c.computed_id);
1485 match parent.axis {
1486 Axis::Row => r.w,
1487 Axis::Column => r.h,
1488 Axis::Overlay => 0.0,
1489 }
1490 })
1491 .sum();
1492 consumed + total_gap > inner_main + 0.5
1493}
1494
1495fn short_path(p: &str) -> String {
1496 let parts: Vec<&str> = p.split(['/', '\\']).collect();
1497 if parts.len() >= 2 {
1498 format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
1499 } else {
1500 p.to_string()
1501 }
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506 use super::*;
1507
1508 fn lint_one(mut root: El) -> LintReport {
1509 let mut ui_state = UiState::new();
1510 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1511 lint(&root, &ui_state)
1512 }
1513
1514 #[test]
1515 fn clipped_nowrap_text_reports_text_overflow() {
1516 let root = crate::text("A very long dashboard label")
1517 .width(Size::Fixed(42.0))
1518 .height(Size::Fixed(20.0));
1519
1520 let report = lint_one(root);
1521
1522 assert!(
1523 report
1524 .findings
1525 .iter()
1526 .any(|finding| finding.kind == FindingKind::TextOverflow),
1527 "{}",
1528 report.text()
1529 );
1530 }
1531
1532 #[test]
1533 fn ellipsis_nowrap_text_satisfies_horizontal_overflow_policy() {
1534 let root = crate::text("A very long dashboard label")
1535 .ellipsis()
1536 .width(Size::Fixed(42.0))
1537 .height(Size::Fixed(20.0));
1538
1539 let report = lint_one(root);
1540
1541 assert!(
1542 !report
1543 .findings
1544 .iter()
1545 .any(|finding| finding.kind == FindingKind::TextOverflow),
1546 "{}",
1547 report.text()
1548 );
1549 }
1550
1551 #[test]
1552 fn hug_ellipsis_in_overflowing_row_reports_dead_chain_issue_19() {
1553 let row = crate::row([
1562 crate::text("short_label"),
1563 crate::text("a long descriptive body that should truncate but cannot").ellipsis(),
1564 crate::text("right_side_metadata"),
1565 ])
1566 .width(Size::Fixed(160.0))
1567 .height(Size::Fixed(20.0));
1568
1569 let report = lint_one(row);
1570
1571 assert!(
1572 report
1573 .findings
1574 .iter()
1575 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1576 "expected dead-ellipsis finding pointing at Hug text\n{}",
1577 report.text()
1578 );
1579 }
1580
1581 #[test]
1582 fn hug_ellipsis_in_non_overflowing_row_is_quiet() {
1583 let row = crate::row([crate::text("ok").ellipsis()])
1588 .width(Size::Fixed(160.0))
1589 .height(Size::Fixed(20.0));
1590
1591 let report = lint_one(row);
1592
1593 assert!(
1594 !report
1595 .findings
1596 .iter()
1597 .any(|f| f.kind == FindingKind::TextOverflow),
1598 "{}",
1599 report.text()
1600 );
1601 }
1602
1603 #[test]
1604 fn fill_ellipsis_in_overflowing_row_is_quiet() {
1605 let row = crate::row([
1610 crate::text("short_label"),
1611 crate::text("a long descriptive body that should truncate but cannot")
1612 .width(Size::Fill(1.0))
1613 .ellipsis(),
1614 crate::text("right_side_metadata"),
1615 ])
1616 .width(Size::Fixed(160.0))
1617 .height(Size::Fixed(20.0));
1618
1619 let report = lint_one(row);
1620
1621 assert!(
1622 !report
1623 .findings
1624 .iter()
1625 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1626 "{}",
1627 report.text()
1628 );
1629 }
1630
1631 #[test]
1632 fn padding_eats_fixed_height_button_reports_padding_advice() {
1633 let root = crate::row([crate::button("Resume")
1643 .height(Size::Fixed(30.0))
1644 .padding(crate::tokens::SPACE_2)]);
1645
1646 let report = lint_one(root);
1647
1648 let finding = report
1649 .findings
1650 .iter()
1651 .find(|f| f.kind == FindingKind::Overflow)
1652 .unwrap_or_else(|| {
1653 panic!(
1654 "expected an Overflow finding for the padding-eats-height shape\n{}",
1655 report.text()
1656 )
1657 });
1658 assert!(
1659 finding.message.contains("vertical padding") && finding.message.contains("Sides::xy"),
1660 "expected padding-y advice, got:\n{}\n{}",
1661 finding.message,
1662 report.text(),
1663 );
1664 assert!(
1665 !finding.message.contains("paragraph()") && !finding.message.contains("wrap_text()"),
1666 "padding-eats-height case should not recommend paragraph/wrap_text:\n{}",
1667 finding.message,
1668 );
1669 }
1670
1671 #[test]
1672 fn padding_eats_fixed_height_y_only_does_not_fire_when_height_is_hug() {
1673 let root = crate::row([crate::text("Resume").padding(crate::tokens::SPACE_2)]);
1677
1678 let report = lint_one(root);
1679
1680 assert!(
1681 !report
1682 .findings
1683 .iter()
1684 .any(|f| f.kind == FindingKind::Overflow || f.kind == FindingKind::TextOverflow),
1685 "{}",
1686 report.text()
1687 );
1688 }
1689
1690 #[test]
1691 fn text_taller_than_fixed_height_without_padding_reports_height_advice() {
1692 let root = crate::row([crate::text("body")
1697 .width(Size::Fixed(80.0))
1698 .height(Size::Fixed(12.0))]);
1699
1700 let report = lint_one(root);
1701
1702 let finding = report
1703 .findings
1704 .iter()
1705 .find(|f| f.kind == FindingKind::Overflow)
1706 .unwrap_or_else(|| {
1707 panic!(
1708 "expected an Overflow finding for text-taller-than-box\n{}",
1709 report.text()
1710 )
1711 });
1712 assert!(
1713 finding.message.contains("exceeds box height") && finding.message.contains("height"),
1714 "expected height-advice message, got:\n{}",
1715 finding.message,
1716 );
1717 assert!(
1718 !finding.message.contains("vertical padding"),
1719 "no-padding case should not blame padding:\n{}",
1720 finding.message,
1721 );
1722 }
1723
1724 #[test]
1725 fn padding_aware_text_overflow_fires_when_text_spills_past_padded_region() {
1726 let leaf = crate::text("dashboard")
1736 .width(Size::Fixed(80.0))
1737 .height(Size::Fixed(28.0))
1738 .padding(Sides::xy(20.0, 0.0));
1739 let root = crate::row([leaf]);
1740
1741 let report = lint_one(root);
1742
1743 assert!(
1744 report
1745 .findings
1746 .iter()
1747 .any(|finding| finding.kind == FindingKind::TextOverflow),
1748 "{}",
1749 report.text()
1750 );
1751 }
1752
1753 #[test]
1754 fn stretch_row_with_top_pinned_icon_and_text_suggests_center_alignment() {
1755 let root = crate::row([
1756 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1757 crate::text("Settings").width(Size::Fill(1.0)),
1758 ])
1759 .height(Size::Fixed(36.0));
1760
1761 let report = lint_one(root);
1762
1763 assert!(
1764 report
1765 .findings
1766 .iter()
1767 .any(|finding| finding.kind == FindingKind::Alignment
1768 && finding.message.contains(".align(Align::Center)")),
1769 "{}",
1770 report.text()
1771 );
1772 }
1773
1774 #[test]
1775 fn centered_row_with_icon_and_text_satisfies_alignment_policy() {
1776 let root = crate::row([
1777 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1778 crate::text("Settings").width(Size::Fill(1.0)),
1779 ])
1780 .height(Size::Fixed(36.0))
1781 .align(Align::Center);
1782
1783 let report = lint_one(root);
1784
1785 assert!(
1786 !report
1787 .findings
1788 .iter()
1789 .any(|finding| finding.kind == FindingKind::Alignment),
1790 "{}",
1791 report.text()
1792 );
1793 }
1794
1795 #[test]
1796 fn row_with_icon_slot_touching_text_reports_spacing() {
1797 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1798 .align(Align::Center)
1799 .justify(Justify::Center)
1800 .fill(crate::tokens::MUTED)
1801 .width(Size::Fixed(26.0))
1802 .height(Size::Fixed(26.0));
1803 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1804 .height(Size::Fixed(32.0))
1805 .align(Align::Center);
1806
1807 let report = lint_one(root);
1808
1809 assert!(
1810 report
1811 .findings
1812 .iter()
1813 .any(|finding| finding.kind == FindingKind::Spacing
1814 && finding.message.contains(".gap(tokens::SPACE_2)")),
1815 "{}",
1816 report.text()
1817 );
1818 }
1819
1820 #[test]
1821 fn row_with_icon_slot_and_text_gap_satisfies_spacing_policy() {
1822 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1823 .align(Align::Center)
1824 .justify(Justify::Center)
1825 .fill(crate::tokens::MUTED)
1826 .width(Size::Fixed(26.0))
1827 .height(Size::Fixed(26.0));
1828 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1829 .height(Size::Fixed(32.0))
1830 .align(Align::Center)
1831 .gap(crate::tokens::SPACE_2);
1832
1833 let report = lint_one(root);
1834
1835 assert!(
1836 !report
1837 .findings
1838 .iter()
1839 .any(|finding| finding.kind == FindingKind::Spacing),
1840 "{}",
1841 report.text()
1842 );
1843 }
1844
1845 #[test]
1846 fn overlay_with_top_left_pinned_icon_suggests_center_alignment() {
1847 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1848 .fill(crate::tokens::MUTED)
1849 .width(Size::Fixed(26.0))
1850 .height(Size::Fixed(26.0));
1851 let root = crate::column([icon_slot]);
1852
1853 let report = lint_one(root);
1854
1855 assert!(
1856 report
1857 .findings
1858 .iter()
1859 .any(|finding| finding.kind == FindingKind::Alignment
1860 && finding.message.contains(".justify(Justify::Center)")),
1861 "{}",
1862 report.text()
1863 );
1864 }
1865
1866 #[test]
1867 fn centered_overlay_icon_satisfies_alignment_policy() {
1868 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1869 .align(Align::Center)
1870 .justify(Justify::Center)
1871 .fill(crate::tokens::MUTED)
1872 .width(Size::Fixed(26.0))
1873 .height(Size::Fixed(26.0));
1874 let root = crate::column([icon_slot]);
1875
1876 let report = lint_one(root);
1877
1878 assert!(
1879 !report
1880 .findings
1881 .iter()
1882 .any(|finding| finding.kind == FindingKind::Alignment),
1883 "{}",
1884 report.text()
1885 );
1886 }
1887
1888 #[test]
1889 fn overflow_findings_attribute_to_nearest_user_source_ancestor() {
1890 let user_source = Source {
1895 file: "src/screen.rs",
1896 line: 42,
1897 from_library: false,
1898 };
1899 let widget_source = Source {
1900 file: "src/widgets/tabs.rs",
1901 line: 200,
1902 from_library: true,
1903 };
1904
1905 let mut leaf = crate::text("A very long dashboard label")
1906 .width(Size::Fixed(40.0))
1907 .height(Size::Fixed(20.0));
1908 leaf.source = widget_source;
1909
1910 let mut root = crate::row([leaf])
1911 .width(Size::Fixed(160.0))
1912 .height(Size::Fixed(48.0));
1913 root.source = user_source;
1914
1915 let mut ui_state = UiState::new();
1916 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1917 let report = lint(&root, &ui_state);
1918
1919 let text_overflow = report
1920 .findings
1921 .iter()
1922 .find(|f| f.kind == FindingKind::TextOverflow)
1923 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1924 assert_eq!(text_overflow.source.file, user_source.file);
1925 assert_eq!(text_overflow.source.line, user_source.line);
1926 }
1927
1928 #[test]
1929 fn overflow_finding_self_attributes_when_node_is_already_user_source() {
1930 let mut node = crate::text("A very long dashboard label")
1931 .width(Size::Fixed(40.0))
1932 .height(Size::Fixed(20.0));
1933 let user_source = Source {
1934 file: "src/screen.rs",
1935 line: 99,
1936 from_library: false,
1937 };
1938 node.source = user_source;
1939
1940 let mut ui_state = UiState::new();
1941 layout::layout(&mut node, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1942 let report = lint(&node, &ui_state);
1943
1944 let text_overflow = report
1945 .findings
1946 .iter()
1947 .find(|f| f.kind == FindingKind::TextOverflow)
1948 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1949 assert_eq!(text_overflow.source.line, user_source.line);
1950 }
1951
1952 #[test]
1953 fn overflow_lint_fires_for_external_app_paths_issue_13() {
1954 let user_source = Source {
1961 file: "src/sidebar.rs",
1962 line: 17,
1963 from_library: false,
1964 };
1965 let mut child = crate::column(Vec::<El>::new())
1966 .width(Size::Fixed(32.0))
1967 .height(Size::Fixed(32.0));
1968 child.source = user_source;
1969
1970 let mut row = crate::row([child])
1971 .width(Size::Fixed(256.0))
1972 .height(Size::Fixed(28.0));
1973 row.source = user_source;
1974
1975 let mut ui_state = UiState::new();
1976 layout::layout(&mut row, &mut ui_state, Rect::new(0.0, 0.0, 256.0, 28.0));
1977 let report = lint(&row, &ui_state);
1978
1979 assert!(
1980 report
1981 .findings
1982 .iter()
1983 .any(|f| f.kind == FindingKind::Overflow),
1984 "expected an Overflow finding for the 32px child in a 28px row\n{}",
1985 report.text()
1986 );
1987 }
1988
1989 #[test]
1990 fn overflow_finding_suppressed_when_no_user_ancestor_exists() {
1991 let widget_source = Source {
1994 file: "src/widgets/tabs.rs",
1995 line: 200,
1996 from_library: true,
1997 };
1998 let mut leaf = crate::text("A very long dashboard label")
1999 .width(Size::Fixed(40.0))
2000 .height(Size::Fixed(20.0));
2001 leaf.source = widget_source;
2002
2003 let mut wrapper = crate::row([leaf])
2004 .width(Size::Fixed(160.0))
2005 .height(Size::Fixed(48.0));
2006 wrapper.source = widget_source;
2007
2008 let mut ui_state = UiState::new();
2009 layout::layout(
2010 &mut wrapper,
2011 &mut ui_state,
2012 Rect::new(0.0, 0.0, 160.0, 48.0),
2013 );
2014 let report = lint(&wrapper, &ui_state);
2015
2016 assert!(
2017 !report
2018 .findings
2019 .iter()
2020 .any(|f| f.kind == FindingKind::TextOverflow || f.kind == FindingKind::Overflow),
2021 "{}",
2022 report.text()
2023 );
2024 }
2025
2026 #[test]
2027 fn panel_role_without_fill_reports_missing_surface_fill() {
2028 let root = crate::column([crate::text("body")])
2029 .surface_role(SurfaceRole::Panel)
2030 .width(Size::Fixed(120.0))
2031 .height(Size::Fixed(40.0));
2032
2033 let report = lint_one(root);
2034
2035 assert!(
2036 report
2037 .findings
2038 .iter()
2039 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2040 "{}",
2041 report.text()
2042 );
2043 }
2044
2045 #[test]
2046 fn panel_role_with_fill_satisfies_surface_policy() {
2047 let root = crate::column([crate::text("body")])
2048 .surface_role(SurfaceRole::Panel)
2049 .fill(crate::tokens::CARD)
2050 .width(Size::Fixed(120.0))
2051 .height(Size::Fixed(40.0));
2052
2053 let report = lint_one(root);
2054
2055 assert!(
2056 !report
2057 .findings
2058 .iter()
2059 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2060 "{}",
2061 report.text()
2062 );
2063 }
2064
2065 #[test]
2066 fn card_widget_satisfies_surface_policy() {
2067 let root = crate::widgets::card::card([crate::text("body")])
2068 .width(Size::Fixed(120.0))
2069 .height(Size::Fixed(40.0));
2070
2071 let report = lint_one(root);
2072
2073 assert!(
2074 !report
2075 .findings
2076 .iter()
2077 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2078 "{}",
2079 report.text()
2080 );
2081 }
2082
2083 #[test]
2084 fn handrolled_card_recipe_reports_reinvented_widget() {
2085 let root = crate::column([crate::text("body")])
2088 .fill(crate::tokens::CARD)
2089 .stroke(crate::tokens::BORDER)
2090 .radius(crate::tokens::RADIUS_LG)
2091 .width(Size::Fixed(160.0))
2092 .height(Size::Fixed(48.0));
2093
2094 let report = lint_one(root);
2095
2096 assert!(
2097 report
2098 .findings
2099 .iter()
2100 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("card(")),
2101 "{}",
2102 report.text()
2103 );
2104 }
2105
2106 #[test]
2107 fn real_card_widget_does_not_report_reinvented_widget() {
2108 let root = crate::widgets::card::card([crate::text("body")])
2111 .width(Size::Fixed(160.0))
2112 .height(Size::Fixed(48.0));
2113
2114 let report = lint_one(root);
2115
2116 assert!(
2117 !report
2118 .findings
2119 .iter()
2120 .any(|f| f.kind == FindingKind::ReinventedWidget),
2121 "{}",
2122 report.text()
2123 );
2124 }
2125
2126 #[test]
2127 fn handrolled_sidebar_recipe_reports_reinvented_widget() {
2128 let root = crate::column([crate::text("nav")])
2131 .fill(crate::tokens::CARD)
2132 .stroke(crate::tokens::BORDER)
2133 .width(Size::Fixed(crate::tokens::SIDEBAR_WIDTH))
2134 .height(Size::Fill(1.0));
2135
2136 let report = lint_one(root);
2137
2138 assert!(
2139 report
2140 .findings
2141 .iter()
2142 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("sidebar(")),
2143 "{}",
2144 report.text()
2145 );
2146 }
2147
2148 #[test]
2149 fn real_sidebar_widget_does_not_report_reinvented_widget() {
2150 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
2153
2154 let report = lint_one(root);
2155
2156 assert!(
2157 !report
2158 .findings
2159 .iter()
2160 .any(|f| f.kind == FindingKind::ReinventedWidget),
2161 "{}",
2162 report.text()
2163 );
2164 }
2165
2166 #[test]
2167 fn empty_visual_swatch_does_not_report_reinvented_widget() {
2168 let root = crate::column(Vec::<El>::new())
2172 .fill(crate::tokens::CARD)
2173 .stroke(crate::tokens::BORDER)
2174 .radius(crate::tokens::RADIUS_SM)
2175 .width(Size::Fixed(42.0))
2176 .height(Size::Fixed(34.0));
2177
2178 let report = lint_one(root);
2179
2180 assert!(
2181 !report
2182 .findings
2183 .iter()
2184 .any(|f| f.kind == FindingKind::ReinventedWidget),
2185 "{}",
2186 report.text()
2187 );
2188 }
2189
2190 #[test]
2191 fn plain_column_does_not_report_reinvented_widget() {
2192 let root = crate::column([crate::text("a"), crate::text("b")])
2194 .gap(crate::tokens::SPACE_2)
2195 .width(Size::Fixed(120.0))
2196 .height(Size::Fixed(40.0));
2197
2198 let report = lint_one(root);
2199
2200 assert!(
2201 !report
2202 .findings
2203 .iter()
2204 .any(|f| f.kind == FindingKind::ReinventedWidget),
2205 "{}",
2206 report.text()
2207 );
2208 }
2209
2210 #[test]
2211 fn fill_providing_roles_do_not_require_explicit_fill() {
2212 let root = crate::column([crate::text("body")])
2217 .surface_role(SurfaceRole::Sunken)
2218 .width(Size::Fixed(120.0))
2219 .height(Size::Fixed(40.0));
2220
2221 let report = lint_one(root);
2222
2223 assert!(
2224 !report
2225 .findings
2226 .iter()
2227 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2228 "{}",
2229 report.text()
2230 );
2231 }
2232
2233 #[test]
2234 fn focus_ring_lint_fires_when_input_clipped_on_scroll_cross_axis() {
2235 let selection = crate::selection::Selection::default();
2238 let mut root = crate::tree::scroll([crate::tree::column([
2239 crate::widgets::text_input::text_input("", &selection, "field"),
2240 ])])
2241 .width(Size::Fixed(300.0))
2242 .height(Size::Fixed(120.0));
2243 let mut state = UiState::new();
2244 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2245 let report = lint(&root, &state);
2246
2247 assert!(
2248 report.findings.iter().any(|f| {
2249 f.kind == FindingKind::FocusRingObscured
2250 && f.message.contains("clipped")
2251 && (f.message.contains("L=2") || f.message.contains("R=2"))
2252 }),
2253 "expected a FocusRingObscured clipping finding (L=2 or R=2)\n{}",
2254 report.text()
2255 );
2256 }
2257
2258 #[test]
2259 fn focus_ring_lint_assumes_every_focusable_has_a_ring_band() {
2260 let mut root = crate::tree::scroll([crate::tree::column([El::new(Kind::Custom(
2265 "raw_focusable",
2266 ))
2267 .key("raw")
2268 .focusable()
2269 .fill(crate::tokens::CARD)
2270 .width(Size::Fill(1.0))
2271 .height(Size::Fixed(40.0))])])
2272 .width(Size::Fixed(300.0))
2273 .height(Size::Fixed(120.0));
2274 let mut state = UiState::new();
2275 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2276 let report = lint(&root, &state);
2277
2278 assert!(
2279 report.findings.iter().any(|f| {
2280 f.kind == FindingKind::FocusRingObscured
2281 && f.message.contains("clipped")
2282 && (f.message.contains("L=2") || f.message.contains("R=2"))
2283 }),
2284 "expected a FocusRingObscured clipping finding for implicit focus ring band\n{}",
2285 report.text()
2286 );
2287 }
2288
2289 #[test]
2290 fn hit_overflow_collision_lint_fires_for_sibling_target_overlap() {
2291 let root = crate::tree::row([
2292 crate::button("A")
2293 .key("a")
2294 .hit_overflow(Sides::right(8.0))
2295 .width(Size::Fixed(40.0))
2296 .height(Size::Fixed(24.0)),
2297 crate::button("B")
2298 .key("b")
2299 .width(Size::Fixed(40.0))
2300 .height(Size::Fixed(24.0)),
2301 ])
2302 .gap(4.0);
2303
2304 let report = lint_one(root);
2305
2306 assert!(
2307 report.findings.iter().any(|f| {
2308 f.kind == FindingKind::HitOverflowCollision
2309 && f.message.contains("`a`")
2310 && f.message.contains("`b`")
2311 }),
2312 "expected HitOverflowCollision when a hit_overflow band reaches the next sibling\n{}",
2313 report.text()
2314 );
2315 }
2316
2317 #[test]
2318 fn hit_overflow_collision_lint_is_quiet_when_gap_clears_band() {
2319 let root = crate::tree::row([
2320 crate::button("A")
2321 .key("a")
2322 .hit_overflow(Sides::right(8.0))
2323 .width(Size::Fixed(40.0))
2324 .height(Size::Fixed(24.0)),
2325 crate::button("B")
2326 .key("b")
2327 .width(Size::Fixed(40.0))
2328 .height(Size::Fixed(24.0)),
2329 ])
2330 .gap(12.0);
2331
2332 let report = lint_one(root);
2333
2334 assert!(
2335 !report
2336 .findings
2337 .iter()
2338 .any(|f| f.kind == FindingKind::HitOverflowCollision),
2339 "{}",
2340 report.text()
2341 );
2342 }
2343
2344 #[test]
2345 fn hit_overflow_collision_lint_skips_overlay_stacks() {
2346 let root = crate::tree::stack([
2347 crate::button("A")
2348 .key("a")
2349 .hit_overflow(Sides::all(8.0))
2350 .width(Size::Fixed(40.0))
2351 .height(Size::Fixed(24.0)),
2352 crate::button("B")
2353 .key("b")
2354 .width(Size::Fixed(40.0))
2355 .height(Size::Fixed(24.0)),
2356 ]);
2357
2358 let report = lint_one(root);
2359
2360 assert!(
2361 !report
2362 .findings
2363 .iter()
2364 .any(|f| f.kind == FindingKind::HitOverflowCollision),
2365 "{}",
2366 report.text()
2367 );
2368 }
2369
2370 #[test]
2371 fn focus_ring_lint_silenced_when_scroll_supplies_horizontal_slack() {
2372 let selection = crate::selection::Selection::default();
2375 let mut root =
2376 crate::tree::scroll(
2377 [crate::tree::column([crate::widgets::text_input::text_input(
2378 "", &selection, "field",
2379 )])
2380 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))],
2381 )
2382 .width(Size::Fixed(300.0))
2383 .height(Size::Fixed(120.0));
2384 let mut state = UiState::new();
2385 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2386 let report = lint(&root, &state);
2387
2388 assert!(
2389 !report
2390 .findings
2391 .iter()
2392 .any(|f| f.kind == FindingKind::FocusRingObscured),
2393 "{}",
2394 report.text()
2395 );
2396 }
2397
2398 #[test]
2399 fn focus_ring_lint_skips_clipping_on_scroll_axis() {
2400 let selection = crate::selection::Selection::default();
2404 let mut root = crate::tree::scroll([crate::tree::column([
2405 crate::tree::column(Vec::<El>::new())
2407 .width(Size::Fill(1.0))
2408 .height(Size::Fixed(200.0)),
2409 crate::widgets::text_input::text_input("", &selection, "field"),
2410 ])
2411 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))])
2412 .width(Size::Fixed(300.0))
2413 .height(Size::Fixed(120.0));
2414 let mut state = UiState::new();
2415 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2416 let report = lint(&root, &state);
2417
2418 assert!(
2419 !report
2420 .findings
2421 .iter()
2422 .any(|f| f.kind == FindingKind::FocusRingObscured),
2423 "expected no FocusRingObscured finding for a row clipped on the scroll axis\n{}",
2424 report.text()
2425 );
2426 }
2427
2428 #[test]
2429 fn focus_ring_lint_fires_on_static_clip_in_any_direction() {
2430 let selection = crate::selection::Selection::default();
2433 let mut root = crate::tree::column([crate::widgets::text_input::text_input(
2434 "", &selection, "field",
2435 )])
2436 .clip()
2437 .width(Size::Fixed(300.0))
2438 .height(Size::Fixed(120.0));
2439 let mut state = UiState::new();
2440 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2441 let report = lint(&root, &state);
2442
2443 assert!(
2444 report.findings.iter().any(|f| {
2445 f.kind == FindingKind::FocusRingObscured && f.message.contains("clipped")
2446 }),
2447 "expected a static-clip FocusRingObscured finding\n{}",
2448 report.text()
2449 );
2450 }
2451
2452 #[test]
2453 fn focus_ring_lint_fires_on_painted_later_sibling_overlap() {
2454 let selection = crate::selection::Selection::default();
2458 let mut root = crate::tree::row([
2459 crate::widgets::text_input::text_input("", &selection, "field"),
2460 crate::tree::column([crate::text("neighbor")])
2461 .fill(crate::tokens::CARD)
2462 .stroke(crate::tokens::BORDER)
2463 .width(Size::Fixed(80.0))
2464 .height(Size::Fixed(32.0)),
2465 ])
2466 .gap(0.0)
2467 .width(Size::Fixed(400.0))
2468 .height(Size::Fixed(32.0));
2469 let mut state = UiState::new();
2470 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2471 let report = lint(&root, &state);
2472
2473 assert!(
2474 report.findings.iter().any(|f| {
2475 f.kind == FindingKind::FocusRingObscured
2476 && f.message.contains("occluded")
2477 && f.message.contains("right")
2478 }),
2479 "expected an occlusion finding on the right edge\n{}",
2480 report.text()
2481 );
2482 }
2483
2484 #[test]
2485 fn focus_ring_lint_allows_flush_inside_ring_menu_items() {
2486 let mut root = crate::tree::column([
2487 crate::menu_item("Checkout").key("checkout"),
2488 crate::menu_item("Merge").key("merge"),
2489 crate::menu_item("Delete").key("delete"),
2490 ])
2491 .gap(0.0)
2492 .width(Size::Fixed(180.0));
2493 let mut state = UiState::new();
2494 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 220.0, 140.0));
2495 let report = lint(&root, &state);
2496
2497 assert!(
2498 !report
2499 .findings
2500 .iter()
2501 .any(|f| f.kind == FindingKind::FocusRingObscured),
2502 "{}",
2503 report.text()
2504 );
2505 }
2506
2507 #[test]
2508 fn focus_ring_lint_ignores_unpainted_structural_sibling() {
2509 let selection = crate::selection::Selection::default();
2512 let mut root = crate::tree::row([
2513 crate::widgets::text_input::text_input("", &selection, "field"),
2514 crate::tree::column(Vec::<El>::new())
2515 .width(Size::Fixed(80.0))
2516 .height(Size::Fixed(32.0)),
2517 ])
2518 .gap(0.0)
2519 .width(Size::Fixed(400.0))
2520 .height(Size::Fixed(32.0));
2521 let mut state = UiState::new();
2522 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2523 let report = lint(&root, &state);
2524
2525 assert!(
2526 !report
2527 .findings
2528 .iter()
2529 .any(|f| f.kind == FindingKind::FocusRingObscured),
2530 "{}",
2531 report.text()
2532 );
2533 }
2534
2535 #[test]
2536 fn scrollbar_overlap_lint_fires_when_thumb_covers_fill_child() {
2537 let body = crate::tree::column(
2541 (0..30)
2542 .map(|i| {
2543 crate::tree::row([
2544 crate::text(format!("Row {i}")),
2545 crate::tree::spacer(),
2546 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2547 ])
2548 .gap(crate::tokens::SPACE_2)
2549 .width(Size::Fill(1.0))
2550 })
2551 .collect::<Vec<_>>(),
2552 )
2553 .gap(crate::tokens::SPACE_2)
2554 .width(Size::Fill(1.0));
2555
2556 let mut root = crate::tree::scroll([body])
2557 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2558 .width(Size::Fixed(480.0))
2559 .height(Size::Fixed(320.0));
2560 let mut state = UiState::new();
2561 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2562 let report = lint(&root, &state);
2563
2564 assert!(
2565 report
2566 .findings
2567 .iter()
2568 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2569 "expected ScrollbarObscuresFocusable for a switch that reaches the scroll's inner.right()\n{}",
2570 report.text()
2571 );
2572 }
2573
2574 #[test]
2575 fn scrollbar_overlap_lint_silenced_when_padding_is_inside_scroll() {
2576 let body = crate::tree::column(
2580 (0..30)
2581 .map(|i| {
2582 crate::tree::row([
2583 crate::text(format!("Row {i}")),
2584 crate::tree::spacer(),
2585 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2586 ])
2587 .gap(crate::tokens::SPACE_2)
2588 .width(Size::Fill(1.0))
2589 })
2590 .collect::<Vec<_>>(),
2591 )
2592 .gap(crate::tokens::SPACE_2)
2593 .width(Size::Fill(1.0));
2594
2595 let mut root = crate::tree::scroll([crate::tree::column([body])
2596 .padding(Sides::xy(crate::tokens::SPACE_3, 0.0))
2597 .width(Size::Fill(1.0))])
2598 .padding(Sides::xy(0.0, crate::tokens::SPACE_2))
2599 .width(Size::Fixed(480.0))
2600 .height(Size::Fixed(320.0));
2601 let mut state = UiState::new();
2602 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2603 let report = lint(&root, &state);
2604
2605 assert!(
2606 !report
2607 .findings
2608 .iter()
2609 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2610 "expected no ScrollbarObscuresFocusable when padding is inside the scroll\n{}",
2611 report.text()
2612 );
2613 }
2614
2615 #[test]
2616 fn scrollbar_overlap_lint_quiet_when_content_does_not_overflow() {
2617 let body = crate::tree::column([crate::tree::row([
2622 crate::text("only row"),
2623 crate::tree::spacer(),
2624 crate::widgets::switch::switch(false).key("only-toggle"),
2625 ])
2626 .gap(crate::tokens::SPACE_2)
2627 .width(Size::Fill(1.0))])
2628 .gap(crate::tokens::SPACE_2)
2629 .width(Size::Fill(1.0));
2630
2631 let mut root = crate::tree::scroll([body])
2632 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2633 .width(Size::Fixed(480.0))
2634 .height(Size::Fixed(320.0));
2635 let mut state = UiState::new();
2636 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2637 let report = lint(&root, &state);
2638
2639 assert!(
2640 !report
2641 .findings
2642 .iter()
2643 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2644 "expected no ScrollbarObscuresFocusable when content fits in the viewport (no thumb rendered)\n{}",
2645 report.text()
2646 );
2647 }
2648
2649 #[test]
2650 fn unkeyed_tooltip_reports_dead_tooltip() {
2651 let root = crate::text("abc1234").tooltip("commit sha");
2657
2658 let report = lint_one(root);
2659
2660 assert!(
2661 report
2662 .findings
2663 .iter()
2664 .any(|f| f.kind == FindingKind::DeadTooltip),
2665 "expected DeadTooltip on unkeyed tooltipped text\n{}",
2666 report.text()
2667 );
2668 }
2669
2670 #[test]
2671 fn keyed_tooltip_satisfies_dead_tooltip_policy() {
2672 let root = crate::text("abc1234").key("sha").tooltip("commit sha");
2675
2676 let report = lint_one(root);
2677
2678 assert!(
2679 !report
2680 .findings
2681 .iter()
2682 .any(|f| f.kind == FindingKind::DeadTooltip),
2683 "{}",
2684 report.text()
2685 );
2686 }
2687
2688 #[test]
2689 fn unkeyed_tooltip_inside_keyed_ancestor_still_reports_dead_tooltip() {
2690 let root =
2696 crate::row([crate::text("inner detail").tooltip("never shown")]).key("outer-row");
2697
2698 let report = lint_one(root);
2699
2700 assert!(
2701 report
2702 .findings
2703 .iter()
2704 .any(|f| f.kind == FindingKind::DeadTooltip),
2705 "expected DeadTooltip on unkeyed leaf even with keyed ancestor\n{}",
2706 report.text()
2707 );
2708 }
2709
2710 #[test]
2711 fn focus_ring_lint_is_quiet_inside_form_after_padding_fix() {
2712 let selection = crate::selection::Selection::default();
2716 let mut root = crate::tree::scroll([crate::widgets::form::form([
2717 crate::widgets::form::form_item([crate::widgets::form::form_control(
2718 crate::widgets::text_input::text_input("", &selection, "field"),
2719 )]),
2720 ])])
2721 .width(Size::Fixed(300.0))
2722 .height(Size::Fixed(120.0));
2723 let mut state = UiState::new();
2724 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2725 let report = lint(&root, &state);
2726
2727 assert!(
2728 !report
2729 .findings
2730 .iter()
2731 .any(|f| f.kind == FindingKind::FocusRingObscured),
2732 "{}",
2733 report.text()
2734 );
2735 }
2736
2737 fn lint_one_with_metrics(mut root: El) -> LintReport {
2742 crate::metrics::ThemeMetrics::default().apply_to_tree(&mut root);
2743 let mut ui_state = UiState::new();
2744 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 120.0));
2745 lint(&root, &ui_state)
2746 }
2747
2748 #[test]
2749 fn handrolled_rounded_container_with_flat_filled_header_reports_corner_stackup() {
2750 let parent = crate::column([
2754 crate::row([crate::text("Header")])
2755 .fill(crate::tokens::MUTED)
2756 .width(Size::Fill(1.0))
2757 .height(Size::Fixed(24.0)),
2758 crate::row([crate::text("Body")])
2759 .width(Size::Fill(1.0))
2760 .height(Size::Fixed(60.0)),
2761 ])
2762 .fill(crate::tokens::CARD)
2763 .stroke(crate::tokens::BORDER)
2764 .radius(crate::tokens::RADIUS_LG)
2765 .width(Size::Fixed(160.0))
2766 .height(Size::Fixed(96.0));
2767
2768 let report = lint_one(parent);
2769
2770 let found = report
2771 .findings
2772 .iter()
2773 .find(|f| f.kind == FindingKind::CornerStackup);
2774 let found =
2775 found.unwrap_or_else(|| panic!("expected CornerStackup, got:\n{}", report.text()));
2776 assert!(
2777 found.message.contains("Corners::top"),
2778 "top-strip leak should suggest Corners::top, got: {}",
2779 found.message
2780 );
2781 }
2782
2783 #[test]
2784 fn handrolled_rounded_container_with_inset_child_does_not_report_corner_stackup() {
2785 let parent = crate::column([crate::row([crate::text("Header")])
2787 .fill(crate::tokens::MUTED)
2788 .width(Size::Fill(1.0))
2789 .height(Size::Fixed(24.0))])
2790 .fill(crate::tokens::CARD)
2791 .stroke(crate::tokens::BORDER)
2792 .radius(crate::tokens::RADIUS_LG)
2793 .padding(Sides::all(crate::tokens::RADIUS_LG))
2794 .width(Size::Fixed(160.0))
2795 .height(Size::Fixed(96.0));
2796
2797 let report = lint_one(parent);
2798 assert!(
2799 !report
2800 .findings
2801 .iter()
2802 .any(|f| f.kind == FindingKind::CornerStackup),
2803 "inset child should not trip the lint, got:\n{}",
2804 report.text()
2805 );
2806 }
2807
2808 #[test]
2809 fn handrolled_rounded_container_with_matching_corners_does_not_report_corner_stackup() {
2810 let parent = crate::column([crate::row([crate::text("Header")])
2811 .fill(crate::tokens::MUTED)
2812 .radius(Corners::top(crate::tokens::RADIUS_LG))
2813 .width(Size::Fill(1.0))
2814 .height(Size::Fixed(24.0))])
2815 .fill(crate::tokens::CARD)
2816 .stroke(crate::tokens::BORDER)
2817 .radius(crate::tokens::RADIUS_LG)
2818 .width(Size::Fixed(160.0))
2819 .height(Size::Fixed(96.0));
2820
2821 let report = lint_one(parent);
2822 assert!(
2823 !report
2824 .findings
2825 .iter()
2826 .any(|f| f.kind == FindingKind::CornerStackup),
2827 "matching corners should not trip the lint, got:\n{}",
2828 report.text()
2829 );
2830 }
2831
2832 #[test]
2833 fn canonical_card_recipe_does_not_report_corner_stackup_after_metrics() {
2834 let root = crate::widgets::card::card([
2837 crate::widgets::card::card_header([crate::text("Header")]).fill(crate::tokens::MUTED),
2838 crate::widgets::card::card_content([crate::text("Body")]),
2839 ])
2840 .width(Size::Fixed(180.0))
2841 .height(Size::Fixed(110.0));
2842
2843 let report = lint_one_with_metrics(root);
2844 assert!(
2845 !report
2846 .findings
2847 .iter()
2848 .any(|f| f.kind == FindingKind::CornerStackup),
2849 "canonical card_header(...).fill(...) recipe should be quiet after metrics pass, got:\n{}",
2850 report.text()
2851 );
2852 }
2853
2854 #[test]
2855 fn bare_card_with_flush_content_reports_unpadded_surface_panel_issue_24() {
2856 let root = crate::widgets::card::card([crate::row([
2861 crate::text("some title").bold(),
2862 crate::text("description line").muted(),
2863 ])
2864 .gap(crate::tokens::SPACE_2)
2865 .width(Size::Fill(1.0))])
2866 .width(Size::Fixed(200.0))
2867 .height(Size::Fixed(80.0));
2868
2869 let report = lint_one(root);
2870 let f = report
2871 .findings
2872 .iter()
2873 .find(|f| f.kind == FindingKind::UnpaddedSurfacePanel)
2874 .unwrap_or_else(|| {
2875 panic!(
2876 "expected UnpaddedSurfacePanel finding, got:\n{}",
2877 report.text()
2878 )
2879 });
2880 assert!(
2881 f.message.contains("top"),
2882 "expected the flushing-side list to call out `top`, got: {}",
2883 f.message
2884 );
2885 }
2886
2887 #[test]
2888 fn card_with_explicit_padding_does_not_report_unpadded_surface_panel() {
2889 let root = crate::widgets::card::card([
2892 crate::row([crate::text("title").bold()]).width(Size::Fill(1.0))
2893 ])
2894 .padding(Sides::all(crate::tokens::SPACE_4))
2895 .width(Size::Fixed(200.0))
2896 .height(Size::Fixed(60.0));
2897
2898 let report = lint_one(root);
2899 assert!(
2900 !report
2901 .findings
2902 .iter()
2903 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2904 "{}",
2905 report.text()
2906 );
2907 }
2908
2909 #[test]
2910 fn canonical_card_anatomy_does_not_report_unpadded_surface_panel() {
2911 let root = crate::widgets::card::card([
2915 crate::widgets::card::card_header([crate::widgets::card::card_title("Header")]),
2916 crate::widgets::card::card_content([crate::text("Body")]),
2917 crate::widgets::card::card_footer([crate::text("footer")]),
2918 ])
2919 .width(Size::Fixed(220.0))
2920 .height(Size::Fixed(160.0));
2921
2922 let report = lint_one(root);
2923 assert!(
2924 !report
2925 .findings
2926 .iter()
2927 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2928 "canonical slot anatomy should be quiet, got:\n{}",
2929 report.text()
2930 );
2931 }
2932
2933 #[test]
2934 fn sidebar_widget_does_not_report_unpadded_surface_panel() {
2935 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
2938
2939 let report = lint_one(root);
2940 assert!(
2941 !report
2942 .findings
2943 .iter()
2944 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2945 "{}",
2946 report.text()
2947 );
2948 }
2949
2950 #[test]
2951 fn raw_color_fires_without_allow_lint() {
2952 let root = crate::column(Vec::<El>::new())
2956 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
2957 .width(Size::Fixed(40.0))
2958 .height(Size::Fixed(40.0));
2959
2960 let report = lint_one(root);
2961 assert!(
2962 report
2963 .findings
2964 .iter()
2965 .any(|f| f.kind == FindingKind::RawColor),
2966 "{}",
2967 report.text()
2968 );
2969 }
2970
2971 #[test]
2972 fn allow_lint_silences_finding_on_same_node() {
2973 let root = crate::column(Vec::<El>::new())
2976 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
2977 .allow_lint(FindingKind::RawColor)
2978 .width(Size::Fixed(40.0))
2979 .height(Size::Fixed(40.0));
2980
2981 let report = lint_one(root);
2982 assert!(
2983 !report
2984 .findings
2985 .iter()
2986 .any(|f| f.kind == FindingKind::RawColor),
2987 "expected RawColor silenced on the allowed node, got:\n{}",
2988 report.text()
2989 );
2990 }
2991
2992 #[test]
2993 fn allow_lint_does_not_leak_to_siblings() {
2994 let row = crate::row([
2997 crate::column(Vec::<El>::new())
2998 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
2999 .allow_lint(FindingKind::RawColor)
3000 .width(Size::Fixed(20.0))
3001 .height(Size::Fixed(20.0)),
3002 crate::column(Vec::<El>::new())
3003 .fill(crate::Color::srgb_u8a(70, 80, 90, 255))
3004 .width(Size::Fixed(20.0))
3005 .height(Size::Fixed(20.0)),
3006 ])
3007 .width(Size::Fixed(160.0))
3008 .height(Size::Fixed(40.0));
3009
3010 let report = lint_one(row);
3011 let raw_color_count = report
3012 .findings
3013 .iter()
3014 .filter(|f| f.kind == FindingKind::RawColor)
3015 .count();
3016 assert_eq!(
3017 raw_color_count,
3018 1,
3019 "expected exactly one RawColor finding (the un-silenced sibling), got:\n{}",
3020 report.text()
3021 );
3022 }
3023
3024 #[test]
3025 fn allow_lint_does_not_propagate_to_descendants() {
3026 let parent = crate::column([crate::column(Vec::<El>::new())
3029 .fill(crate::Color::srgb_u8a(70, 80, 90, 255))
3030 .width(Size::Fixed(20.0))
3031 .height(Size::Fixed(20.0))])
3032 .fill(crate::Color::srgb_u8a(40, 50, 60, 255))
3033 .allow_lint(FindingKind::RawColor)
3034 .width(Size::Fixed(40.0))
3035 .height(Size::Fixed(40.0));
3036
3037 let report = lint_one(parent);
3038 assert!(
3039 report
3040 .findings
3041 .iter()
3042 .any(|f| f.kind == FindingKind::RawColor),
3043 "child RawColor must still fire when only parent silenced it, got:\n{}",
3044 report.text()
3045 );
3046 }
3047
3048 #[test]
3049 fn allow_lint_silences_text_overflow_on_same_node() {
3050 let root = crate::text("A very long dashboard label")
3054 .allow_lint(FindingKind::TextOverflow)
3055 .width(Size::Fixed(42.0))
3056 .height(Size::Fixed(20.0));
3057
3058 let report = lint_one(root);
3059 assert!(
3060 !report
3061 .findings
3062 .iter()
3063 .any(|f| f.kind == FindingKind::TextOverflow),
3064 "{}",
3065 report.text()
3066 );
3067 }
3068
3069 #[test]
3070 fn lint_report_retain_drops_matching_findings() {
3071 let root = crate::row([crate::text("a").key("dup"), crate::text("b").key("dup")])
3078 .width(Size::Fixed(160.0))
3079 .height(Size::Fixed(20.0));
3080
3081 let mut report = lint_one(root);
3082 assert!(
3083 report
3084 .findings
3085 .iter()
3086 .any(|f| f.kind == FindingKind::DuplicateId),
3087 "baseline DuplicateId must fire, got:\n{}",
3088 report.text()
3089 );
3090
3091 report.retain(|f| f.kind != FindingKind::DuplicateId);
3092 assert!(
3093 !report
3094 .findings
3095 .iter()
3096 .any(|f| f.kind == FindingKind::DuplicateId),
3097 "retain should have dropped DuplicateId, got:\n{}",
3098 report.text()
3099 );
3100 }
3101}