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 text(&self) -> String {
192 if self.findings.is_empty() {
193 return "no findings\n".to_string();
194 }
195 let mut s = String::new();
196 for f in &self.findings {
197 let _ = writeln!(
198 s,
199 "{kind:?} node={id} {source} :: {msg}",
200 kind = f.kind,
201 id = f.node_id,
202 source = if f.source.line == 0 {
203 "<no-source>".to_string()
204 } else {
205 format!("{}:{}", short_path(f.source.file), f.source.line)
206 },
207 msg = f.message,
208 );
209 }
210 s
211 }
212}
213
214pub fn lint(root: &El, ui_state: &UiState) -> LintReport {
224 let mut r = LintReport::default();
225 let mut seen_ids: std::collections::BTreeMap<String, usize> = Default::default();
226 walk(
227 root,
228 None,
229 None,
230 &ClipCtx::None,
231 ui_state,
232 &mut r,
233 &mut seen_ids,
234 );
235 for (id, n) in seen_ids {
236 if n > 1 {
237 r.findings.push(Finding {
238 kind: FindingKind::DuplicateId,
239 node_id: id.clone(),
240 source: Source::default(),
241 message: format!("{n} nodes share id {id}"),
242 });
243 }
244 }
245 r
246}
247
248fn is_from_user(source: Source) -> bool {
249 !source.from_library
250}
251
252#[derive(Clone)]
261enum ClipCtx {
262 None,
263 Static(Rect),
265 Scrolling {
269 rect: Rect,
270 scroll_axis: Axis,
271 node_id: String,
272 },
273}
274
275fn walk(
276 n: &El,
277 parent_kind: Option<&Kind>,
278 parent_blame: Option<Source>,
279 nearest_clip: &ClipCtx,
280 ui_state: &UiState,
281 r: &mut LintReport,
282 seen: &mut std::collections::BTreeMap<String, usize>,
283) {
284 *seen.entry(n.computed_id.clone()).or_default() += 1;
285 let computed = ui_state.rect(&n.computed_id);
286
287 let from_user_self = is_from_user(n.source);
288 let self_blame = if from_user_self {
295 Some(n.source)
296 } else {
297 parent_blame
298 };
299
300 let inside_inlines = matches!(parent_kind, Some(Kind::Inlines));
306
307 if from_user_self {
310 if let Some(c) = n.fill
311 && c.token.is_none()
312 && c.a > 0
313 {
314 r.findings.push(Finding {
315 kind: FindingKind::RawColor,
316 node_id: n.computed_id.clone(),
317 source: n.source,
318 message: format!(
319 "fill is a raw rgba({},{},{},{}) — use a token",
320 c.r, c.g, c.b, c.a
321 ),
322 });
323 }
324 if let Some(c) = n.stroke
325 && c.token.is_none()
326 && c.a > 0
327 {
328 r.findings.push(Finding {
329 kind: FindingKind::RawColor,
330 node_id: n.computed_id.clone(),
331 source: n.source,
332 message: format!(
333 "stroke is a raw rgba({},{},{},{}) — use a token",
334 c.r, c.g, c.b, c.a
335 ),
336 });
337 }
338 if let Some(c) = n.text_color
339 && c.token.is_none()
340 && c.a > 0
341 {
342 r.findings.push(Finding {
343 kind: FindingKind::RawColor,
344 node_id: n.computed_id.clone(),
345 source: n.source,
346 message: format!(
347 "text_color is a raw rgba({},{},{},{}) — use a token",
348 c.r, c.g, c.b, c.a
349 ),
350 });
351 }
352 if n.tooltip.is_some() && n.key.is_none() {
358 r.findings.push(Finding {
359 kind: FindingKind::DeadTooltip,
360 node_id: n.computed_id.clone(),
361 source: n.source,
362 message: ".tooltip() on a node without .key() never fires — hit-test only \
363 returns keyed nodes, so hover skips past this leaf to the nearest \
364 keyed ancestor. Add .key(\"…\") on the same node that carries the \
365 tooltip; for info-only chrome inside list rows, a synthetic key \
366 like \"row:{idx}.<part>\" is enough."
367 .to_string(),
368 });
369 }
370
371 if n.fill.is_none() && matches!(n.surface_role, SurfaceRole::Panel) {
378 r.findings.push(Finding {
379 kind: FindingKind::MissingSurfaceFill,
380 node_id: n.computed_id.clone(),
381 source: n.source,
382 message:
383 "surface_role(Panel) without a fill paints only stroke + shadow — \
384 wrap in card() / sidebar() / dialog() for the canonical recipe, or set .fill(tokens::CARD)"
385 .to_string(),
386 });
387 }
388
389 if matches!(n.surface_role, SurfaceRole::Panel) {
390 check_unpadded_surface_panel(n, computed, ui_state, r, n.source);
391 }
392
393 if matches!(n.kind, Kind::Group) && !n.children.is_empty() {
407 let card_fill = n
408 .fill
409 .as_ref()
410 .and_then(|c| c.token)
411 .is_some_and(|t| t == "card");
412 let border_stroke = n
413 .stroke
414 .as_ref()
415 .and_then(|c| c.token)
416 .is_some_and(|t| t == "border");
417 if card_fill && border_stroke {
418 let is_panel_surface = matches!(n.surface_role, SurfaceRole::Panel);
419 let sidebar_width = matches!(n.width, Size::Fixed(w) if (w - crate::tokens::SIDEBAR_WIDTH).abs() < 0.5);
420 if !is_panel_surface {
421 if sidebar_width {
422 r.findings.push(Finding {
423 kind: FindingKind::ReinventedWidget,
424 node_id: n.computed_id.clone(),
425 source: n.source,
426 message:
427 "Group with fill=CARD, stroke=BORDER, width=SIDEBAR_WIDTH reinvents sidebar() — \
428 use sidebar([sidebar_header(...), sidebar_group([sidebar_menu([sidebar_menu_button(label, current)])])]) \
429 for the panel surface and the canonical row recipe"
430 .to_string(),
431 });
432 } else {
433 r.findings.push(Finding {
443 kind: FindingKind::ReinventedWidget,
444 node_id: n.computed_id.clone(),
445 source: n.source,
446 message:
447 "Group with fill=CARD, stroke=BORDER reinvents the panel-surface recipe — \
448 use card([card_header([card_title(\"...\")]), card_content([...])]) / titled_card(\"Title\", [...]) for boxed content, \
449 or sidebar([...]) for a full-height nav/inspector pane (sidebar() also handles the custom-width case via .width(Size::Fixed(...)))"
450 .to_string(),
451 });
452 }
453 }
454 }
455 }
456 }
457
458 if let Some(blame) = self_blame {
464 lint_row_alignment(n, computed, ui_state, r, blame);
465 lint_overlay_alignment(n, computed, ui_state, r, blame);
466 lint_row_visual_text_spacing(n, ui_state, r, blame);
467 }
468
469 if n.text.is_some()
475 && !inside_inlines
476 && let Some(blame) = self_blame
477 {
478 let available_width = match n.text_wrap {
479 TextWrap::NoWrap => None,
480 TextWrap::Wrap => Some(computed.w),
481 };
482 if let Some(text_layout) = layout::text_layout(n, available_width) {
483 let text_w = text_layout.width + n.padding.left + n.padding.right;
484 let text_h = text_layout.height + n.padding.top + n.padding.bottom;
485 let raw_overflow_x = (text_w - computed.w).max(0.0);
486 let overflow_x = if matches!(
487 (n.text_wrap, n.text_overflow),
488 (TextWrap::NoWrap, TextOverflow::Ellipsis)
489 ) {
490 0.0
491 } else {
492 raw_overflow_x
493 };
494 let overflow_y = (text_h - computed.h).max(0.0);
495 if overflow_x > 0.5 || overflow_y > 0.5 {
496 let is_clipped_nowrap = overflow_x > 0.5
497 && matches!(
498 (n.text_wrap, n.text_overflow),
499 (TextWrap::NoWrap, TextOverflow::Clip)
500 );
501 let kind = if is_clipped_nowrap {
502 FindingKind::TextOverflow
503 } else {
504 FindingKind::Overflow
505 };
506 let pad_y = n.padding.top + n.padding.bottom;
515 let height_is_fixed = matches!(n.height, Size::Fixed(_));
516 let text_alone_fits_height = text_layout.height <= computed.h + 0.5;
517 let padding_eats_fixed_height = overflow_y > 0.5
518 && overflow_x <= 0.5
519 && pad_y > 0.0
520 && text_alone_fits_height
521 && height_is_fixed;
522 let cell_h = text_layout.height;
523 let box_h = computed.h;
524 let message = if kind == FindingKind::TextOverflow {
525 format!(
526 "nowrap text exceeds its box by X={overflow_x:.0}; use .ellipsis(), wrap_text(), or a wider box"
527 )
528 } else if padding_eats_fixed_height {
529 let inner_h = (box_h - pad_y).max(0.0);
530 let pad_x_token = if (n.padding.left - n.padding.right).abs() < 0.5 {
531 format!("{:.0}", n.padding.left)
532 } else {
533 "...".to_string()
534 };
535 let control_h = crate::tokens::CONTROL_HEIGHT;
536 format!(
537 "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) — \
538 the label can't vertically center and paints into the padding band, off-center by Y={overflow_y:.0}. \
539 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)"
540 )
541 } else if overflow_y > 0.5 && overflow_x <= 0.5 {
542 format!(
543 "text cell ({cell_h:.0}px) exceeds box height ({box_h:.0}px) by Y={overflow_y:.0}; \
544 increase height, reduce text size, or use paragraph()/wrap_text() with fewer lines"
545 )
546 } else {
547 format!(
548 "text content exceeds its box by X={overflow_x:.0} Y={overflow_y:.0}; use paragraph()/wrap_text(), a wider box, or explicit clipping"
549 )
550 };
551 r.findings.push(Finding {
552 kind,
553 node_id: n.computed_id.clone(),
554 source: blame,
555 message,
556 });
557 }
558 }
559 }
560
561 let suppress_overflow = n.scrollable
576 || n.clip
577 || matches!(n.kind, Kind::Inlines)
578 || matches!(n.kind, Kind::Custom("toast_stack"));
579
580 let parent_main_overran =
590 !suppress_overflow && flex_main_axis_overflowed(n, computed, ui_state);
591
592 let child_clip = if n.clip {
599 if n.scrollable {
600 ClipCtx::Scrolling {
601 rect: computed,
602 scroll_axis: n.axis,
603 node_id: n.computed_id.clone(),
604 }
605 } else {
606 ClipCtx::Static(computed)
607 }
608 } else {
609 nearest_clip.clone()
610 };
611
612 if !matches!(n.axis, Axis::Overlay)
613 && let Some(blame) = self_blame
614 {
615 lint_hit_overflow_collisions(n, &child_clip, ui_state, r, blame);
616 }
617
618 for (child_idx, c) in n.children.iter().enumerate() {
619 let from_user_child = is_from_user(c.source);
620 let child_blame = if from_user_child {
621 Some(c.source)
622 } else {
623 self_blame
624 };
625
626 let c_rect = ui_state.rect(&c.computed_id);
627 if !suppress_overflow
628 && !rect_contains(computed, c_rect, 0.5)
629 && let Some(blame) = child_blame
630 {
631 let dx_left = (computed.x - c_rect.x).max(0.0);
632 let dx_right = (c_rect.right() - computed.right()).max(0.0);
633 let dy_top = (computed.y - c_rect.y).max(0.0);
634 let dy_bottom = (c_rect.bottom() - computed.bottom()).max(0.0);
635 r.findings.push(Finding {
636 kind: FindingKind::Overflow,
637 node_id: c.computed_id.clone(),
638 source: blame,
639 message: format!(
640 "child overflows parent {parent_id} by L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}",
641 parent_id = n.computed_id,
642 ),
643 });
644 }
645
646 let main_axis_is_hug = match n.axis {
652 Axis::Row => matches!(c.width, Size::Hug),
653 Axis::Column => matches!(c.height, Size::Hug),
654 Axis::Overlay => false,
655 };
656 if parent_main_overran
657 && main_axis_is_hug
658 && c.text.is_some()
659 && c.text_wrap == TextWrap::NoWrap
660 && c.text_overflow == TextOverflow::Ellipsis
661 && let Some(blame) = child_blame
662 {
663 r.findings.push(Finding {
664 kind: FindingKind::TextOverflow,
665 node_id: c.computed_id.clone(),
666 source: blame,
667 message:
668 ".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."
669 .to_string(),
670 });
671 }
672
673 if from_user_child
681 && c.fill.is_some()
682 && n.radius.any_nonzero()
683 && let Some(blame) = child_blame
684 {
685 check_corner_stackup(n, computed, c, c_rect, r, blame);
686 }
687
688 if from_user_child
689 && c.focusable
690 && let Some(blame) = child_blame
691 {
692 check_focus_ring_obscured(
693 c,
694 c_rect,
695 &child_clip,
696 &n.children[child_idx + 1..],
697 ui_state,
698 r,
699 blame,
700 );
701 check_scrollbar_overlap(c, c_rect, &child_clip, ui_state, r, blame);
705 }
706
707 walk(
708 c,
709 Some(&n.kind),
710 child_blame,
711 &child_clip,
712 ui_state,
713 r,
714 seen,
715 );
716 }
717}
718
719fn focus_ring_overflow(n: &El) -> Sides {
720 match n.focus_ring_placement {
721 crate::tree::FocusRingPlacement::Outside => Sides::all(crate::tokens::RING_WIDTH),
722 crate::tree::FocusRingPlacement::Inside => Sides::zero(),
723 }
724}
725
726fn has_hit_overflow(sides: Sides) -> bool {
727 sides.left > 0.5 || sides.right > 0.5 || sides.top > 0.5 || sides.bottom > 0.5
728}
729
730fn clip_rect(ctx: &ClipCtx) -> Option<Rect> {
731 match ctx {
732 ClipCtx::None => None,
733 ClipCtx::Static(rect) | ClipCtx::Scrolling { rect, .. } => Some(*rect),
734 }
735}
736
737fn clipped_rect(rect: Rect, ctx: &ClipCtx) -> Option<Rect> {
738 match clip_rect(ctx) {
739 Some(clip) => rect.intersect(clip),
740 None => Some(rect),
741 }
742}
743
744fn lint_hit_overflow_collisions(
751 parent: &El,
752 child_clip: &ClipCtx,
753 ui_state: &UiState,
754 r: &mut LintReport,
755 blame: Source,
756) {
757 for (left_idx, left) in parent.children.iter().enumerate() {
758 if left.key.is_none() {
759 continue;
760 }
761 let left_rect = ui_state.rect(&left.computed_id);
762 let Some(left_hit) = clipped_rect(left_rect.outset(left.hit_overflow), child_clip) else {
763 continue;
764 };
765 for right in parent.children.iter().skip(left_idx + 1) {
766 if right.key.is_none() {
767 continue;
768 }
769 if !has_hit_overflow(left.hit_overflow) && !has_hit_overflow(right.hit_overflow) {
770 continue;
771 }
772 let right_rect = ui_state.rect(&right.computed_id);
773 let Some(right_hit) = clipped_rect(right_rect.outset(right.hit_overflow), child_clip)
774 else {
775 continue;
776 };
777 let Some(overlap) = left_hit.intersect(right_hit) else {
778 continue;
779 };
780 if overlap.w <= 0.5 || overlap.h <= 0.5 {
781 continue;
782 }
783
784 let left_visual_contains = left_rect.contains(overlap.center_x(), overlap.center_y());
785 let right_visual_contains = right_rect.contains(overlap.center_x(), overlap.center_y());
786 if left_visual_contains && right_visual_contains {
787 continue;
791 }
792
793 let earlier = left.key.as_deref().unwrap_or("<unkeyed>");
794 let later = right.key.as_deref().unwrap_or("<unkeyed>");
795 let owner = if has_hit_overflow(right.hit_overflow) {
796 right
797 } else {
798 left
799 };
800 r.findings.push(Finding {
801 kind: FindingKind::HitOverflowCollision,
802 node_id: owner.computed_id.clone(),
803 source: blame,
804 message: format!(
805 "expanded hit targets for sibling keys `{earlier}` and `{later}` overlap by {w:.0}x{h:.0}px — \
806 hit-test resolves the collision by paint order, so `{later}` owns that invisible band. \
807 Reduce `.hit_overflow(...)`, add real gap/padding, or make one visible row/control own the full intended target.",
808 w = overlap.w,
809 h = overlap.h,
810 ),
811 });
812 }
813 }
814}
815
816fn check_corner_stackup(
824 parent: &El,
825 parent_rect: Rect,
826 child: &El,
827 child_rect: Rect,
828 r: &mut LintReport,
829 blame: Source,
830) {
831 let pr = parent.radius;
832 let cr = child.radius;
833 let tl = (
835 pr.tl,
836 cr.tl,
837 Rect::new(parent_rect.x, parent_rect.y, pr.tl, pr.tl),
838 );
839 let tr = (
840 pr.tr,
841 cr.tr,
842 Rect::new(
843 parent_rect.x + parent_rect.w - pr.tr,
844 parent_rect.y,
845 pr.tr,
846 pr.tr,
847 ),
848 );
849 let br = (
850 pr.br,
851 cr.br,
852 Rect::new(
853 parent_rect.x + parent_rect.w - pr.br,
854 parent_rect.y + parent_rect.h - pr.br,
855 pr.br,
856 pr.br,
857 ),
858 );
859 let bl = (
860 pr.bl,
861 cr.bl,
862 Rect::new(
863 parent_rect.x,
864 parent_rect.y + parent_rect.h - pr.bl,
865 pr.bl,
866 pr.bl,
867 ),
868 );
869 let leaks_at = |(p_r, c_r, corner_box): (f32, f32, Rect)| -> bool {
870 if p_r <= 0.5 || c_r + 0.5 >= p_r {
871 return false;
872 }
873 match child_rect.intersect(corner_box) {
874 Some(overlap) => overlap.w >= 0.5 && overlap.h >= 0.5,
875 None => false,
876 }
877 };
878 let (leak_tl, leak_tr, leak_br, leak_bl) =
879 (leaks_at(tl), leaks_at(tr), leaks_at(br), leaks_at(bl));
880 if !(leak_tl || leak_tr || leak_br || leak_bl) {
881 return;
882 }
883 let (descriptor, helper) = match (leak_tl, leak_tr, leak_br, leak_bl) {
884 (true, true, false, false) => ("the parent's top corners", "Corners::top(...)"),
885 (false, false, true, true) => ("the parent's bottom corners", "Corners::bottom(...)"),
886 (true, false, false, true) => ("the parent's left corners", "Corners::left(...)"),
887 (false, true, true, false) => ("the parent's right corners", "Corners::right(...)"),
888 (true, true, true, true) => ("the parent's corners", "Corners::all(...)"),
889 _ => (
891 "a parent corner",
892 "Corners { tl, tr, br, bl } with the matching corner set",
893 ),
894 };
895 r.findings.push(Finding {
896 kind: FindingKind::CornerStackup,
897 node_id: child.computed_id.clone(),
898 source: blame,
899 message: format!(
900 "filled child paints into {descriptor} (rounded parent, max radius={pr_max:.0}) — \
901 the flat corners obscure the parent's curve and stroke. \
902 Set `.radius({helper})` on the child so its corners follow the parent's curve, \
903 or add padding to the parent so the child is inset from the curve.",
904 pr_max = pr.max(),
905 ),
906 });
907}
908
909fn check_unpadded_surface_panel(
920 panel: &El,
921 panel_rect: Rect,
922 ui_state: &UiState,
923 r: &mut LintReport,
924 blame: Source,
925) {
926 let touch_eps = crate::tokens::RING_WIDTH;
929 const PAD_EPS: f32 = 0.5;
932
933 let mut top = (false, false);
935 let mut right = (false, false);
936 let mut bottom = (false, false);
937 let mut left = (false, false);
938
939 for c in &panel.children {
940 let cr = ui_state.rect(&c.computed_id);
941 if cr.w <= PAD_EPS || cr.h <= PAD_EPS {
942 continue;
944 }
945 if (cr.y - panel_rect.y).abs() <= touch_eps {
946 top.0 = true;
947 if c.padding.top > PAD_EPS {
948 top.1 = true;
949 }
950 }
951 if (panel_rect.right() - cr.right()).abs() <= touch_eps {
952 right.0 = true;
953 if c.padding.right > PAD_EPS {
954 right.1 = true;
955 }
956 }
957 if (panel_rect.bottom() - cr.bottom()).abs() <= touch_eps {
958 bottom.0 = true;
959 if c.padding.bottom > PAD_EPS {
960 bottom.1 = true;
961 }
962 }
963 if (cr.x - panel_rect.x).abs() <= touch_eps {
964 left.0 = true;
965 if c.padding.left > PAD_EPS {
966 left.1 = true;
967 }
968 }
969 }
970
971 let pad = panel.padding;
972 let mut sides: Vec<&'static str> = Vec::new();
973 if pad.top <= PAD_EPS && top.0 && !top.1 {
974 sides.push("top");
975 }
976 if pad.right <= PAD_EPS && right.0 && !right.1 {
977 sides.push("right");
978 }
979 if pad.bottom <= PAD_EPS && bottom.0 && !bottom.1 {
980 sides.push("bottom");
981 }
982 if pad.left <= PAD_EPS && left.0 && !left.1 {
983 sides.push("left");
984 }
985 if sides.is_empty() {
986 return;
987 }
988 let joined = sides.join("/");
989 r.findings.push(Finding {
990 kind: FindingKind::UnpaddedSurfacePanel,
991 node_id: panel.computed_id.clone(),
992 source: blame,
993 message: format!(
994 "Panel-surface children sit flush against the {joined} edge — \
995 wrap content in the slot anatomy (`card_header(...)` / `card_content(...)` / `card_footer(...)` \
996 each bake `SPACE_6` padding), or pad the panel itself \
997 (e.g. `.padding(Sides::all(tokens::SPACE_4))` for dense list-row cards).",
998 ),
999 });
1000}
1001
1002fn check_focus_ring_obscured(
1003 n: &El,
1004 n_rect: Rect,
1005 nearest_clip: &ClipCtx,
1006 later_siblings: &[El],
1007 ui_state: &UiState,
1008 r: &mut LintReport,
1009 blame: Source,
1010) {
1011 let ring_overflow = focus_ring_overflow(n);
1012 if ring_overflow.left <= 0.5
1013 && ring_overflow.right <= 0.5
1014 && ring_overflow.top <= 0.5
1015 && ring_overflow.bottom <= 0.5
1016 {
1017 return;
1018 }
1019 let band = n_rect.outset(ring_overflow);
1020
1021 let (clip_rect, check_horiz, check_vert) = match nearest_clip {
1025 ClipCtx::None => (None, false, false),
1026 ClipCtx::Static(rect) => (Some(*rect), true, true),
1027 ClipCtx::Scrolling {
1028 rect, scroll_axis, ..
1029 } => match scroll_axis {
1030 Axis::Column => (Some(*rect), true, false),
1031 Axis::Row => (Some(*rect), false, true),
1032 Axis::Overlay => (Some(*rect), true, true),
1033 },
1034 };
1035 if let Some(clip) = clip_rect {
1036 let dx_left = if check_horiz {
1037 (clip.x - band.x).max(0.0)
1038 } else {
1039 0.0
1040 };
1041 let dx_right = if check_horiz {
1042 (band.right() - clip.right()).max(0.0)
1043 } else {
1044 0.0
1045 };
1046 let dy_top = if check_vert {
1047 (clip.y - band.y).max(0.0)
1048 } else {
1049 0.0
1050 };
1051 let dy_bottom = if check_vert {
1052 (band.bottom() - clip.bottom()).max(0.0)
1053 } else {
1054 0.0
1055 };
1056 if dx_left + dx_right + dy_top + dy_bottom > 0.5 {
1057 r.findings.push(Finding {
1058 kind: FindingKind::FocusRingObscured,
1059 node_id: n.computed_id.clone(),
1060 source: blame,
1061 message: format!(
1062 "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",
1063 ),
1064 });
1065 }
1066 }
1067
1068 for sib in later_siblings {
1072 let sib_rect = ui_state.rect(&sib.computed_id);
1073 if let Some(side) = bleed_occlusion(n_rect, ring_overflow, sib_rect)
1074 && paints_pixels(sib)
1075 {
1076 r.findings.push(Finding {
1077 kind: FindingKind::FocusRingObscured,
1078 node_id: n.computed_id.clone(),
1079 source: blame,
1080 message: format!(
1081 "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",
1082 sib_id = sib.computed_id,
1083 ),
1084 });
1085 break;
1087 }
1088 }
1089}
1090
1091fn check_scrollbar_overlap(
1109 n: &El,
1110 n_rect: Rect,
1111 nearest_clip: &ClipCtx,
1112 ui_state: &UiState,
1113 r: &mut LintReport,
1114 blame: Source,
1115) {
1116 let ClipCtx::Scrolling { node_id, .. } = nearest_clip else {
1117 return;
1118 };
1119 let Some(track) = ui_state.scroll.thumb_tracks.get(node_id).copied() else {
1120 return;
1121 };
1122 let active_w = crate::tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE;
1128 let thumb_left = track.right() - active_w;
1129 let thumb_right = track.right();
1130 let overlap_x = n_rect.right().min(thumb_right) - n_rect.x.max(thumb_left);
1131 if overlap_x <= 0.5 {
1132 return;
1133 }
1134 r.findings.push(Finding {
1135 kind: FindingKind::ScrollbarObscuresFocusable,
1136 node_id: n.computed_id.clone(),
1137 source: blame,
1138 message: format!(
1139 "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",
1140 ctrl_x = n_rect.x,
1141 ctrl_right = n_rect.right(),
1142 ),
1143 });
1144}
1145
1146fn paints_pixels(n: &El) -> bool {
1150 n.fill.is_some()
1151 || n.stroke.is_some()
1152 || n.image.is_some()
1153 || n.icon.is_some()
1154 || n.shadow > 0.0
1155 || n.text.is_some()
1156 || !matches!(n.surface_role, SurfaceRole::None)
1157}
1158
1159fn bleed_occlusion(n_rect: Rect, overflow: Sides, sib_rect: Rect) -> Option<&'static str> {
1164 const EPS: f32 = 0.5;
1165 let bands: [(&'static str, Rect); 4] = [
1166 (
1167 "top",
1168 Rect::new(n_rect.x, n_rect.y - overflow.top, n_rect.w, overflow.top),
1169 ),
1170 (
1171 "bottom",
1172 Rect::new(n_rect.x, n_rect.bottom(), n_rect.w, overflow.bottom),
1173 ),
1174 (
1175 "left",
1176 Rect::new(n_rect.x - overflow.left, n_rect.y, overflow.left, n_rect.h),
1177 ),
1178 (
1179 "right",
1180 Rect::new(n_rect.right(), n_rect.y, overflow.right, n_rect.h),
1181 ),
1182 ];
1183 for (side, band) in bands {
1184 if band.w <= 0.0 || band.h <= 0.0 {
1185 continue;
1186 }
1187 let iw = band.right().min(sib_rect.right()) - band.x.max(sib_rect.x);
1188 let ih = band.bottom().min(sib_rect.bottom()) - band.y.max(sib_rect.y);
1189 if iw > EPS && ih > EPS {
1190 return Some(side);
1191 }
1192 }
1193 None
1194}
1195
1196fn lint_row_alignment(
1197 n: &El,
1198 computed: Rect,
1199 ui_state: &UiState,
1200 r: &mut LintReport,
1201 blame: Source,
1202) {
1203 if !matches!(n.axis, Axis::Row) || !matches!(n.align, Align::Stretch) || n.children.len() < 2 {
1204 return;
1205 }
1206 if !n.children.iter().any(is_text_like_child) {
1207 return;
1208 }
1209
1210 let inner = computed.inset(n.padding);
1211 if inner.h <= 0.0 {
1212 return;
1213 }
1214
1215 for child in &n.children {
1216 if !is_fixed_visual_child(child) {
1217 continue;
1218 }
1219 let child_rect = ui_state.rect(&child.computed_id);
1220 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1221 let visibly_short = child_rect.h + 2.0 < inner.h;
1222 if top_pinned && visibly_short {
1223 r.findings.push(Finding {
1224 kind: FindingKind::Alignment,
1225 node_id: n.computed_id.clone(),
1226 source: blame,
1227 message: "row has a fixed-size visual child pinned to the top beside text; add .align(Align::Center) to vertically center row content"
1228 .to_string(),
1229 });
1230 return;
1231 }
1232 }
1233}
1234
1235fn lint_overlay_alignment(
1236 n: &El,
1237 computed: Rect,
1238 ui_state: &UiState,
1239 r: &mut LintReport,
1240 blame: Source,
1241) {
1242 if !matches!(n.axis, Axis::Overlay)
1243 || n.children.is_empty()
1244 || !matches!(n.align, Align::Start | Align::Stretch)
1245 || !matches!(n.justify, Justify::Start | Justify::SpaceBetween)
1246 || !has_visible_surface(n)
1247 {
1248 return;
1249 }
1250
1251 let inner = computed.inset(n.padding);
1252 if inner.w <= 0.0 || inner.h <= 0.0 {
1253 return;
1254 }
1255
1256 for child in &n.children {
1257 if !is_fixed_visual_child(child) {
1258 continue;
1259 }
1260 let child_rect = ui_state.rect(&child.computed_id);
1261 let left_pinned = (child_rect.x - inner.x).abs() <= 0.5;
1262 let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
1263 let visibly_narrow = child_rect.w + 2.0 < inner.w;
1264 let visibly_short = child_rect.h + 2.0 < inner.h;
1265 if left_pinned && top_pinned && visibly_narrow && visibly_short {
1266 r.findings.push(Finding {
1267 kind: FindingKind::Alignment,
1268 node_id: n.computed_id.clone(),
1269 source: blame,
1270 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"
1271 .to_string(),
1272 });
1273 return;
1274 }
1275 }
1276}
1277
1278fn lint_row_visual_text_spacing(n: &El, ui_state: &UiState, r: &mut LintReport, blame: Source) {
1279 if !matches!(n.axis, Axis::Row) || n.children.len() < 2 {
1280 return;
1281 }
1282
1283 for pair in n.children.windows(2) {
1284 let [visual, text] = pair else {
1285 continue;
1286 };
1287 if !is_visual_cluster_child(visual) || !is_text_like_child(text) {
1288 continue;
1289 }
1290
1291 let visual_rect = ui_state.rect(&visual.computed_id);
1292 let text_rect = ui_state.rect(&text.computed_id);
1293 let gap = text_rect.x - visual_rect.right();
1294 if gap < 4.0 {
1295 r.findings.push(Finding {
1296 kind: FindingKind::Spacing,
1297 node_id: n.computed_id.clone(),
1298 source: blame,
1299 message: format!(
1300 "row places text {:.0}px after an icon/control slot; add .gap(tokens::SPACE_2) or use a stock menu/list row",
1301 gap.max(0.0)
1302 ),
1303 });
1304 return;
1305 }
1306 }
1307}
1308
1309fn is_text_like_child(c: &El) -> bool {
1310 c.text.is_some()
1311 || c.children
1312 .iter()
1313 .any(|child| child.text.is_some() || matches!(child.kind, Kind::Text | Kind::Heading))
1314}
1315
1316fn has_visible_surface(n: &El) -> bool {
1317 n.fill.is_some() || n.stroke.is_some()
1318}
1319
1320fn is_fixed_visual_child(c: &El) -> bool {
1321 let fixed_height = matches!(c.height, Size::Fixed(_));
1322 fixed_height
1323 && (c.icon.is_some()
1324 || matches!(c.kind, Kind::Badge)
1325 || matches!(
1326 c.metrics_role,
1327 Some(
1328 MetricsRole::Button
1329 | MetricsRole::IconButton
1330 | MetricsRole::Input
1331 | MetricsRole::Badge
1332 | MetricsRole::TabTrigger
1333 | MetricsRole::ChoiceControl
1334 | MetricsRole::Slider
1335 | MetricsRole::Progress
1336 )
1337 ))
1338}
1339
1340fn is_visual_cluster_child(c: &El) -> bool {
1341 let fixed_box = matches!(c.width, Size::Fixed(_)) && matches!(c.height, Size::Fixed(_));
1342 fixed_box
1343 && (c.icon.is_some()
1344 || matches!(c.kind, Kind::Badge)
1345 || matches!(
1346 c.metrics_role,
1347 Some(MetricsRole::IconButton | MetricsRole::Badge | MetricsRole::ChoiceControl)
1348 )
1349 || (has_visible_surface(c) && c.children.iter().any(is_fixed_visual_child)))
1350}
1351
1352fn rect_contains(parent: Rect, child: Rect, tol: f32) -> bool {
1353 child.x >= parent.x - tol
1354 && child.y >= parent.y - tol
1355 && child.right() <= parent.right() + tol
1356 && child.bottom() <= parent.bottom() + tol
1357}
1358
1359fn flex_main_axis_overflowed(parent: &El, parent_rect: Rect, ui_state: &UiState) -> bool {
1365 let n = parent.children.len();
1366 if n == 0 {
1367 return false;
1368 }
1369 let inner = parent_rect.inset(parent.padding);
1370 let inner_main = match parent.axis {
1371 Axis::Row => inner.w,
1372 Axis::Column => inner.h,
1373 Axis::Overlay => return false,
1374 };
1375 let total_gap = parent.gap * n.saturating_sub(1) as f32;
1376 let consumed: f32 = parent
1377 .children
1378 .iter()
1379 .map(|c| {
1380 let r = ui_state.rect(&c.computed_id);
1381 match parent.axis {
1382 Axis::Row => r.w,
1383 Axis::Column => r.h,
1384 Axis::Overlay => 0.0,
1385 }
1386 })
1387 .sum();
1388 consumed + total_gap > inner_main + 0.5
1389}
1390
1391fn short_path(p: &str) -> String {
1392 let parts: Vec<&str> = p.split(['/', '\\']).collect();
1393 if parts.len() >= 2 {
1394 format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
1395 } else {
1396 p.to_string()
1397 }
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402 use super::*;
1403
1404 fn lint_one(mut root: El) -> LintReport {
1405 let mut ui_state = UiState::new();
1406 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1407 lint(&root, &ui_state)
1408 }
1409
1410 #[test]
1411 fn clipped_nowrap_text_reports_text_overflow() {
1412 let root = crate::text("A very long dashboard label")
1413 .width(Size::Fixed(42.0))
1414 .height(Size::Fixed(20.0));
1415
1416 let report = lint_one(root);
1417
1418 assert!(
1419 report
1420 .findings
1421 .iter()
1422 .any(|finding| finding.kind == FindingKind::TextOverflow),
1423 "{}",
1424 report.text()
1425 );
1426 }
1427
1428 #[test]
1429 fn ellipsis_nowrap_text_satisfies_horizontal_overflow_policy() {
1430 let root = crate::text("A very long dashboard label")
1431 .ellipsis()
1432 .width(Size::Fixed(42.0))
1433 .height(Size::Fixed(20.0));
1434
1435 let report = lint_one(root);
1436
1437 assert!(
1438 !report
1439 .findings
1440 .iter()
1441 .any(|finding| finding.kind == FindingKind::TextOverflow),
1442 "{}",
1443 report.text()
1444 );
1445 }
1446
1447 #[test]
1448 fn hug_ellipsis_in_overflowing_row_reports_dead_chain_issue_19() {
1449 let row = crate::row([
1458 crate::text("short_label"),
1459 crate::text("a long descriptive body that should truncate but cannot").ellipsis(),
1460 crate::text("right_side_metadata"),
1461 ])
1462 .width(Size::Fixed(160.0))
1463 .height(Size::Fixed(20.0));
1464
1465 let report = lint_one(row);
1466
1467 assert!(
1468 report
1469 .findings
1470 .iter()
1471 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1472 "expected dead-ellipsis finding pointing at Hug text\n{}",
1473 report.text()
1474 );
1475 }
1476
1477 #[test]
1478 fn hug_ellipsis_in_non_overflowing_row_is_quiet() {
1479 let row = crate::row([crate::text("ok").ellipsis()])
1484 .width(Size::Fixed(160.0))
1485 .height(Size::Fixed(20.0));
1486
1487 let report = lint_one(row);
1488
1489 assert!(
1490 !report
1491 .findings
1492 .iter()
1493 .any(|f| f.kind == FindingKind::TextOverflow),
1494 "{}",
1495 report.text()
1496 );
1497 }
1498
1499 #[test]
1500 fn fill_ellipsis_in_overflowing_row_is_quiet() {
1501 let row = crate::row([
1506 crate::text("short_label"),
1507 crate::text("a long descriptive body that should truncate but cannot")
1508 .width(Size::Fill(1.0))
1509 .ellipsis(),
1510 crate::text("right_side_metadata"),
1511 ])
1512 .width(Size::Fixed(160.0))
1513 .height(Size::Fixed(20.0));
1514
1515 let report = lint_one(row);
1516
1517 assert!(
1518 !report
1519 .findings
1520 .iter()
1521 .any(|f| f.kind == FindingKind::TextOverflow && f.message.contains("Size::Hug")),
1522 "{}",
1523 report.text()
1524 );
1525 }
1526
1527 #[test]
1528 fn padding_eats_fixed_height_button_reports_padding_advice() {
1529 let root = crate::row([crate::button("Resume")
1539 .height(Size::Fixed(30.0))
1540 .padding(crate::tokens::SPACE_2)]);
1541
1542 let report = lint_one(root);
1543
1544 let finding = report
1545 .findings
1546 .iter()
1547 .find(|f| f.kind == FindingKind::Overflow)
1548 .unwrap_or_else(|| {
1549 panic!(
1550 "expected an Overflow finding for the padding-eats-height shape\n{}",
1551 report.text()
1552 )
1553 });
1554 assert!(
1555 finding.message.contains("vertical padding") && finding.message.contains("Sides::xy"),
1556 "expected padding-y advice, got:\n{}\n{}",
1557 finding.message,
1558 report.text(),
1559 );
1560 assert!(
1561 !finding.message.contains("paragraph()") && !finding.message.contains("wrap_text()"),
1562 "padding-eats-height case should not recommend paragraph/wrap_text:\n{}",
1563 finding.message,
1564 );
1565 }
1566
1567 #[test]
1568 fn padding_eats_fixed_height_y_only_does_not_fire_when_height_is_hug() {
1569 let root = crate::row([crate::text("Resume").padding(crate::tokens::SPACE_2)]);
1573
1574 let report = lint_one(root);
1575
1576 assert!(
1577 !report
1578 .findings
1579 .iter()
1580 .any(|f| f.kind == FindingKind::Overflow || f.kind == FindingKind::TextOverflow),
1581 "{}",
1582 report.text()
1583 );
1584 }
1585
1586 #[test]
1587 fn text_taller_than_fixed_height_without_padding_reports_height_advice() {
1588 let root = crate::row([crate::text("body")
1593 .width(Size::Fixed(80.0))
1594 .height(Size::Fixed(12.0))]);
1595
1596 let report = lint_one(root);
1597
1598 let finding = report
1599 .findings
1600 .iter()
1601 .find(|f| f.kind == FindingKind::Overflow)
1602 .unwrap_or_else(|| {
1603 panic!(
1604 "expected an Overflow finding for text-taller-than-box\n{}",
1605 report.text()
1606 )
1607 });
1608 assert!(
1609 finding.message.contains("exceeds box height") && finding.message.contains("height"),
1610 "expected height-advice message, got:\n{}",
1611 finding.message,
1612 );
1613 assert!(
1614 !finding.message.contains("vertical padding"),
1615 "no-padding case should not blame padding:\n{}",
1616 finding.message,
1617 );
1618 }
1619
1620 #[test]
1621 fn padding_aware_text_overflow_fires_when_text_spills_past_padded_region() {
1622 let leaf = crate::text("dashboard")
1632 .width(Size::Fixed(80.0))
1633 .height(Size::Fixed(28.0))
1634 .padding(Sides::xy(20.0, 0.0));
1635 let root = crate::row([leaf]);
1636
1637 let report = lint_one(root);
1638
1639 assert!(
1640 report
1641 .findings
1642 .iter()
1643 .any(|finding| finding.kind == FindingKind::TextOverflow),
1644 "{}",
1645 report.text()
1646 );
1647 }
1648
1649 #[test]
1650 fn stretch_row_with_top_pinned_icon_and_text_suggests_center_alignment() {
1651 let root = crate::row([
1652 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1653 crate::text("Settings").width(Size::Fill(1.0)),
1654 ])
1655 .height(Size::Fixed(36.0));
1656
1657 let report = lint_one(root);
1658
1659 assert!(
1660 report
1661 .findings
1662 .iter()
1663 .any(|finding| finding.kind == FindingKind::Alignment
1664 && finding.message.contains(".align(Align::Center)")),
1665 "{}",
1666 report.text()
1667 );
1668 }
1669
1670 #[test]
1671 fn centered_row_with_icon_and_text_satisfies_alignment_policy() {
1672 let root = crate::row([
1673 crate::icon("settings").icon_size(crate::tokens::ICON_SM),
1674 crate::text("Settings").width(Size::Fill(1.0)),
1675 ])
1676 .height(Size::Fixed(36.0))
1677 .align(Align::Center);
1678
1679 let report = lint_one(root);
1680
1681 assert!(
1682 !report
1683 .findings
1684 .iter()
1685 .any(|finding| finding.kind == FindingKind::Alignment),
1686 "{}",
1687 report.text()
1688 );
1689 }
1690
1691 #[test]
1692 fn row_with_icon_slot_touching_text_reports_spacing() {
1693 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1694 .align(Align::Center)
1695 .justify(Justify::Center)
1696 .fill(crate::tokens::MUTED)
1697 .width(Size::Fixed(26.0))
1698 .height(Size::Fixed(26.0));
1699 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1700 .height(Size::Fixed(32.0))
1701 .align(Align::Center);
1702
1703 let report = lint_one(root);
1704
1705 assert!(
1706 report
1707 .findings
1708 .iter()
1709 .any(|finding| finding.kind == FindingKind::Spacing
1710 && finding.message.contains(".gap(tokens::SPACE_2)")),
1711 "{}",
1712 report.text()
1713 );
1714 }
1715
1716 #[test]
1717 fn row_with_icon_slot_and_text_gap_satisfies_spacing_policy() {
1718 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1719 .align(Align::Center)
1720 .justify(Justify::Center)
1721 .fill(crate::tokens::MUTED)
1722 .width(Size::Fixed(26.0))
1723 .height(Size::Fixed(26.0));
1724 let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
1725 .height(Size::Fixed(32.0))
1726 .align(Align::Center)
1727 .gap(crate::tokens::SPACE_2);
1728
1729 let report = lint_one(root);
1730
1731 assert!(
1732 !report
1733 .findings
1734 .iter()
1735 .any(|finding| finding.kind == FindingKind::Spacing),
1736 "{}",
1737 report.text()
1738 );
1739 }
1740
1741 #[test]
1742 fn overlay_with_top_left_pinned_icon_suggests_center_alignment() {
1743 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1744 .fill(crate::tokens::MUTED)
1745 .width(Size::Fixed(26.0))
1746 .height(Size::Fixed(26.0));
1747 let root = crate::column([icon_slot]);
1748
1749 let report = lint_one(root);
1750
1751 assert!(
1752 report
1753 .findings
1754 .iter()
1755 .any(|finding| finding.kind == FindingKind::Alignment
1756 && finding.message.contains(".justify(Justify::Center)")),
1757 "{}",
1758 report.text()
1759 );
1760 }
1761
1762 #[test]
1763 fn centered_overlay_icon_satisfies_alignment_policy() {
1764 let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
1765 .align(Align::Center)
1766 .justify(Justify::Center)
1767 .fill(crate::tokens::MUTED)
1768 .width(Size::Fixed(26.0))
1769 .height(Size::Fixed(26.0));
1770 let root = crate::column([icon_slot]);
1771
1772 let report = lint_one(root);
1773
1774 assert!(
1775 !report
1776 .findings
1777 .iter()
1778 .any(|finding| finding.kind == FindingKind::Alignment),
1779 "{}",
1780 report.text()
1781 );
1782 }
1783
1784 #[test]
1785 fn overflow_findings_attribute_to_nearest_user_source_ancestor() {
1786 let user_source = Source {
1791 file: "src/screen.rs",
1792 line: 42,
1793 from_library: false,
1794 };
1795 let widget_source = Source {
1796 file: "src/widgets/tabs.rs",
1797 line: 200,
1798 from_library: true,
1799 };
1800
1801 let mut leaf = crate::text("A very long dashboard label")
1802 .width(Size::Fixed(40.0))
1803 .height(Size::Fixed(20.0));
1804 leaf.source = widget_source;
1805
1806 let mut root = crate::row([leaf])
1807 .width(Size::Fixed(160.0))
1808 .height(Size::Fixed(48.0));
1809 root.source = user_source;
1810
1811 let mut ui_state = UiState::new();
1812 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1813 let report = lint(&root, &ui_state);
1814
1815 let text_overflow = report
1816 .findings
1817 .iter()
1818 .find(|f| f.kind == FindingKind::TextOverflow)
1819 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1820 assert_eq!(text_overflow.source.file, user_source.file);
1821 assert_eq!(text_overflow.source.line, user_source.line);
1822 }
1823
1824 #[test]
1825 fn overflow_finding_self_attributes_when_node_is_already_user_source() {
1826 let mut node = crate::text("A very long dashboard label")
1827 .width(Size::Fixed(40.0))
1828 .height(Size::Fixed(20.0));
1829 let user_source = Source {
1830 file: "src/screen.rs",
1831 line: 99,
1832 from_library: false,
1833 };
1834 node.source = user_source;
1835
1836 let mut ui_state = UiState::new();
1837 layout::layout(&mut node, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
1838 let report = lint(&node, &ui_state);
1839
1840 let text_overflow = report
1841 .findings
1842 .iter()
1843 .find(|f| f.kind == FindingKind::TextOverflow)
1844 .unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
1845 assert_eq!(text_overflow.source.line, user_source.line);
1846 }
1847
1848 #[test]
1849 fn overflow_lint_fires_for_external_app_paths_issue_13() {
1850 let user_source = Source {
1857 file: "src/sidebar.rs",
1858 line: 17,
1859 from_library: false,
1860 };
1861 let mut child = crate::column(Vec::<El>::new())
1862 .width(Size::Fixed(32.0))
1863 .height(Size::Fixed(32.0));
1864 child.source = user_source;
1865
1866 let mut row = crate::row([child])
1867 .width(Size::Fixed(256.0))
1868 .height(Size::Fixed(28.0));
1869 row.source = user_source;
1870
1871 let mut ui_state = UiState::new();
1872 layout::layout(&mut row, &mut ui_state, Rect::new(0.0, 0.0, 256.0, 28.0));
1873 let report = lint(&row, &ui_state);
1874
1875 assert!(
1876 report
1877 .findings
1878 .iter()
1879 .any(|f| f.kind == FindingKind::Overflow),
1880 "expected an Overflow finding for the 32px child in a 28px row\n{}",
1881 report.text()
1882 );
1883 }
1884
1885 #[test]
1886 fn overflow_finding_suppressed_when_no_user_ancestor_exists() {
1887 let widget_source = Source {
1890 file: "src/widgets/tabs.rs",
1891 line: 200,
1892 from_library: true,
1893 };
1894 let mut leaf = crate::text("A very long dashboard label")
1895 .width(Size::Fixed(40.0))
1896 .height(Size::Fixed(20.0));
1897 leaf.source = widget_source;
1898
1899 let mut wrapper = crate::row([leaf])
1900 .width(Size::Fixed(160.0))
1901 .height(Size::Fixed(48.0));
1902 wrapper.source = widget_source;
1903
1904 let mut ui_state = UiState::new();
1905 layout::layout(
1906 &mut wrapper,
1907 &mut ui_state,
1908 Rect::new(0.0, 0.0, 160.0, 48.0),
1909 );
1910 let report = lint(&wrapper, &ui_state);
1911
1912 assert!(
1913 !report
1914 .findings
1915 .iter()
1916 .any(|f| f.kind == FindingKind::TextOverflow || f.kind == FindingKind::Overflow),
1917 "{}",
1918 report.text()
1919 );
1920 }
1921
1922 #[test]
1923 fn panel_role_without_fill_reports_missing_surface_fill() {
1924 let root = crate::column([crate::text("body")])
1925 .surface_role(SurfaceRole::Panel)
1926 .width(Size::Fixed(120.0))
1927 .height(Size::Fixed(40.0));
1928
1929 let report = lint_one(root);
1930
1931 assert!(
1932 report
1933 .findings
1934 .iter()
1935 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1936 "{}",
1937 report.text()
1938 );
1939 }
1940
1941 #[test]
1942 fn panel_role_with_fill_satisfies_surface_policy() {
1943 let root = crate::column([crate::text("body")])
1944 .surface_role(SurfaceRole::Panel)
1945 .fill(crate::tokens::CARD)
1946 .width(Size::Fixed(120.0))
1947 .height(Size::Fixed(40.0));
1948
1949 let report = lint_one(root);
1950
1951 assert!(
1952 !report
1953 .findings
1954 .iter()
1955 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1956 "{}",
1957 report.text()
1958 );
1959 }
1960
1961 #[test]
1962 fn card_widget_satisfies_surface_policy() {
1963 let root = crate::widgets::card::card([crate::text("body")])
1964 .width(Size::Fixed(120.0))
1965 .height(Size::Fixed(40.0));
1966
1967 let report = lint_one(root);
1968
1969 assert!(
1970 !report
1971 .findings
1972 .iter()
1973 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
1974 "{}",
1975 report.text()
1976 );
1977 }
1978
1979 #[test]
1980 fn handrolled_card_recipe_reports_reinvented_widget() {
1981 let root = crate::column([crate::text("body")])
1984 .fill(crate::tokens::CARD)
1985 .stroke(crate::tokens::BORDER)
1986 .radius(crate::tokens::RADIUS_LG)
1987 .width(Size::Fixed(160.0))
1988 .height(Size::Fixed(48.0));
1989
1990 let report = lint_one(root);
1991
1992 assert!(
1993 report
1994 .findings
1995 .iter()
1996 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("card(")),
1997 "{}",
1998 report.text()
1999 );
2000 }
2001
2002 #[test]
2003 fn real_card_widget_does_not_report_reinvented_widget() {
2004 let root = crate::widgets::card::card([crate::text("body")])
2007 .width(Size::Fixed(160.0))
2008 .height(Size::Fixed(48.0));
2009
2010 let report = lint_one(root);
2011
2012 assert!(
2013 !report
2014 .findings
2015 .iter()
2016 .any(|f| f.kind == FindingKind::ReinventedWidget),
2017 "{}",
2018 report.text()
2019 );
2020 }
2021
2022 #[test]
2023 fn handrolled_sidebar_recipe_reports_reinvented_widget() {
2024 let root = crate::column([crate::text("nav")])
2027 .fill(crate::tokens::CARD)
2028 .stroke(crate::tokens::BORDER)
2029 .width(Size::Fixed(crate::tokens::SIDEBAR_WIDTH))
2030 .height(Size::Fill(1.0));
2031
2032 let report = lint_one(root);
2033
2034 assert!(
2035 report
2036 .findings
2037 .iter()
2038 .any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("sidebar(")),
2039 "{}",
2040 report.text()
2041 );
2042 }
2043
2044 #[test]
2045 fn real_sidebar_widget_does_not_report_reinvented_widget() {
2046 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
2049
2050 let report = lint_one(root);
2051
2052 assert!(
2053 !report
2054 .findings
2055 .iter()
2056 .any(|f| f.kind == FindingKind::ReinventedWidget),
2057 "{}",
2058 report.text()
2059 );
2060 }
2061
2062 #[test]
2063 fn empty_visual_swatch_does_not_report_reinvented_widget() {
2064 let root = crate::column(Vec::<El>::new())
2068 .fill(crate::tokens::CARD)
2069 .stroke(crate::tokens::BORDER)
2070 .radius(crate::tokens::RADIUS_SM)
2071 .width(Size::Fixed(42.0))
2072 .height(Size::Fixed(34.0));
2073
2074 let report = lint_one(root);
2075
2076 assert!(
2077 !report
2078 .findings
2079 .iter()
2080 .any(|f| f.kind == FindingKind::ReinventedWidget),
2081 "{}",
2082 report.text()
2083 );
2084 }
2085
2086 #[test]
2087 fn plain_column_does_not_report_reinvented_widget() {
2088 let root = crate::column([crate::text("a"), crate::text("b")])
2090 .gap(crate::tokens::SPACE_2)
2091 .width(Size::Fixed(120.0))
2092 .height(Size::Fixed(40.0));
2093
2094 let report = lint_one(root);
2095
2096 assert!(
2097 !report
2098 .findings
2099 .iter()
2100 .any(|f| f.kind == FindingKind::ReinventedWidget),
2101 "{}",
2102 report.text()
2103 );
2104 }
2105
2106 #[test]
2107 fn fill_providing_roles_do_not_require_explicit_fill() {
2108 let root = crate::column([crate::text("body")])
2113 .surface_role(SurfaceRole::Sunken)
2114 .width(Size::Fixed(120.0))
2115 .height(Size::Fixed(40.0));
2116
2117 let report = lint_one(root);
2118
2119 assert!(
2120 !report
2121 .findings
2122 .iter()
2123 .any(|f| f.kind == FindingKind::MissingSurfaceFill),
2124 "{}",
2125 report.text()
2126 );
2127 }
2128
2129 #[test]
2130 fn focus_ring_lint_fires_when_input_clipped_on_scroll_cross_axis() {
2131 let selection = crate::selection::Selection::default();
2134 let mut root = crate::tree::scroll([crate::tree::column([
2135 crate::widgets::text_input::text_input("", &selection, "field"),
2136 ])])
2137 .width(Size::Fixed(300.0))
2138 .height(Size::Fixed(120.0));
2139 let mut state = UiState::new();
2140 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2141 let report = lint(&root, &state);
2142
2143 assert!(
2144 report.findings.iter().any(|f| {
2145 f.kind == FindingKind::FocusRingObscured
2146 && f.message.contains("clipped")
2147 && (f.message.contains("L=2") || f.message.contains("R=2"))
2148 }),
2149 "expected a FocusRingObscured clipping finding (L=2 or R=2)\n{}",
2150 report.text()
2151 );
2152 }
2153
2154 #[test]
2155 fn focus_ring_lint_assumes_every_focusable_has_a_ring_band() {
2156 let mut root = crate::tree::scroll([crate::tree::column([El::new(Kind::Custom(
2161 "raw_focusable",
2162 ))
2163 .key("raw")
2164 .focusable()
2165 .fill(crate::tokens::CARD)
2166 .width(Size::Fill(1.0))
2167 .height(Size::Fixed(40.0))])])
2168 .width(Size::Fixed(300.0))
2169 .height(Size::Fixed(120.0));
2170 let mut state = UiState::new();
2171 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2172 let report = lint(&root, &state);
2173
2174 assert!(
2175 report.findings.iter().any(|f| {
2176 f.kind == FindingKind::FocusRingObscured
2177 && f.message.contains("clipped")
2178 && (f.message.contains("L=2") || f.message.contains("R=2"))
2179 }),
2180 "expected a FocusRingObscured clipping finding for implicit focus ring band\n{}",
2181 report.text()
2182 );
2183 }
2184
2185 #[test]
2186 fn hit_overflow_collision_lint_fires_for_sibling_target_overlap() {
2187 let root = crate::tree::row([
2188 crate::button("A")
2189 .key("a")
2190 .hit_overflow(Sides::right(8.0))
2191 .width(Size::Fixed(40.0))
2192 .height(Size::Fixed(24.0)),
2193 crate::button("B")
2194 .key("b")
2195 .width(Size::Fixed(40.0))
2196 .height(Size::Fixed(24.0)),
2197 ])
2198 .gap(4.0);
2199
2200 let report = lint_one(root);
2201
2202 assert!(
2203 report.findings.iter().any(|f| {
2204 f.kind == FindingKind::HitOverflowCollision
2205 && f.message.contains("`a`")
2206 && f.message.contains("`b`")
2207 }),
2208 "expected HitOverflowCollision when a hit_overflow band reaches the next sibling\n{}",
2209 report.text()
2210 );
2211 }
2212
2213 #[test]
2214 fn hit_overflow_collision_lint_is_quiet_when_gap_clears_band() {
2215 let root = crate::tree::row([
2216 crate::button("A")
2217 .key("a")
2218 .hit_overflow(Sides::right(8.0))
2219 .width(Size::Fixed(40.0))
2220 .height(Size::Fixed(24.0)),
2221 crate::button("B")
2222 .key("b")
2223 .width(Size::Fixed(40.0))
2224 .height(Size::Fixed(24.0)),
2225 ])
2226 .gap(12.0);
2227
2228 let report = lint_one(root);
2229
2230 assert!(
2231 !report
2232 .findings
2233 .iter()
2234 .any(|f| f.kind == FindingKind::HitOverflowCollision),
2235 "{}",
2236 report.text()
2237 );
2238 }
2239
2240 #[test]
2241 fn hit_overflow_collision_lint_skips_overlay_stacks() {
2242 let root = crate::tree::stack([
2243 crate::button("A")
2244 .key("a")
2245 .hit_overflow(Sides::all(8.0))
2246 .width(Size::Fixed(40.0))
2247 .height(Size::Fixed(24.0)),
2248 crate::button("B")
2249 .key("b")
2250 .width(Size::Fixed(40.0))
2251 .height(Size::Fixed(24.0)),
2252 ]);
2253
2254 let report = lint_one(root);
2255
2256 assert!(
2257 !report
2258 .findings
2259 .iter()
2260 .any(|f| f.kind == FindingKind::HitOverflowCollision),
2261 "{}",
2262 report.text()
2263 );
2264 }
2265
2266 #[test]
2267 fn focus_ring_lint_silenced_when_scroll_supplies_horizontal_slack() {
2268 let selection = crate::selection::Selection::default();
2271 let mut root =
2272 crate::tree::scroll(
2273 [crate::tree::column([crate::widgets::text_input::text_input(
2274 "", &selection, "field",
2275 )])
2276 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))],
2277 )
2278 .width(Size::Fixed(300.0))
2279 .height(Size::Fixed(120.0));
2280 let mut state = UiState::new();
2281 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2282 let report = lint(&root, &state);
2283
2284 assert!(
2285 !report
2286 .findings
2287 .iter()
2288 .any(|f| f.kind == FindingKind::FocusRingObscured),
2289 "{}",
2290 report.text()
2291 );
2292 }
2293
2294 #[test]
2295 fn focus_ring_lint_skips_clipping_on_scroll_axis() {
2296 let selection = crate::selection::Selection::default();
2300 let mut root = crate::tree::scroll([crate::tree::column([
2301 crate::tree::column(Vec::<El>::new())
2303 .width(Size::Fill(1.0))
2304 .height(Size::Fixed(200.0)),
2305 crate::widgets::text_input::text_input("", &selection, "field"),
2306 ])
2307 .padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))])
2308 .width(Size::Fixed(300.0))
2309 .height(Size::Fixed(120.0));
2310 let mut state = UiState::new();
2311 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2312 let report = lint(&root, &state);
2313
2314 assert!(
2315 !report
2316 .findings
2317 .iter()
2318 .any(|f| f.kind == FindingKind::FocusRingObscured),
2319 "expected no FocusRingObscured finding for a row clipped on the scroll axis\n{}",
2320 report.text()
2321 );
2322 }
2323
2324 #[test]
2325 fn focus_ring_lint_fires_on_static_clip_in_any_direction() {
2326 let selection = crate::selection::Selection::default();
2329 let mut root = crate::tree::column([crate::widgets::text_input::text_input(
2330 "", &selection, "field",
2331 )])
2332 .clip()
2333 .width(Size::Fixed(300.0))
2334 .height(Size::Fixed(120.0));
2335 let mut state = UiState::new();
2336 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2337 let report = lint(&root, &state);
2338
2339 assert!(
2340 report.findings.iter().any(|f| {
2341 f.kind == FindingKind::FocusRingObscured && f.message.contains("clipped")
2342 }),
2343 "expected a static-clip FocusRingObscured finding\n{}",
2344 report.text()
2345 );
2346 }
2347
2348 #[test]
2349 fn focus_ring_lint_fires_on_painted_later_sibling_overlap() {
2350 let selection = crate::selection::Selection::default();
2354 let mut root = crate::tree::row([
2355 crate::widgets::text_input::text_input("", &selection, "field"),
2356 crate::tree::column([crate::text("neighbor")])
2357 .fill(crate::tokens::CARD)
2358 .stroke(crate::tokens::BORDER)
2359 .width(Size::Fixed(80.0))
2360 .height(Size::Fixed(32.0)),
2361 ])
2362 .gap(0.0)
2363 .width(Size::Fixed(400.0))
2364 .height(Size::Fixed(32.0));
2365 let mut state = UiState::new();
2366 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2367 let report = lint(&root, &state);
2368
2369 assert!(
2370 report.findings.iter().any(|f| {
2371 f.kind == FindingKind::FocusRingObscured
2372 && f.message.contains("occluded")
2373 && f.message.contains("right")
2374 }),
2375 "expected an occlusion finding on the right edge\n{}",
2376 report.text()
2377 );
2378 }
2379
2380 #[test]
2381 fn focus_ring_lint_allows_flush_inside_ring_menu_items() {
2382 let mut root = crate::tree::column([
2383 crate::menu_item("Checkout").key("checkout"),
2384 crate::menu_item("Merge").key("merge"),
2385 crate::menu_item("Delete").key("delete"),
2386 ])
2387 .gap(0.0)
2388 .width(Size::Fixed(180.0));
2389 let mut state = UiState::new();
2390 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 220.0, 140.0));
2391 let report = lint(&root, &state);
2392
2393 assert!(
2394 !report
2395 .findings
2396 .iter()
2397 .any(|f| f.kind == FindingKind::FocusRingObscured),
2398 "{}",
2399 report.text()
2400 );
2401 }
2402
2403 #[test]
2404 fn focus_ring_lint_ignores_unpainted_structural_sibling() {
2405 let selection = crate::selection::Selection::default();
2408 let mut root = crate::tree::row([
2409 crate::widgets::text_input::text_input("", &selection, "field"),
2410 crate::tree::column(Vec::<El>::new())
2411 .width(Size::Fixed(80.0))
2412 .height(Size::Fixed(32.0)),
2413 ])
2414 .gap(0.0)
2415 .width(Size::Fixed(400.0))
2416 .height(Size::Fixed(32.0));
2417 let mut state = UiState::new();
2418 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
2419 let report = lint(&root, &state);
2420
2421 assert!(
2422 !report
2423 .findings
2424 .iter()
2425 .any(|f| f.kind == FindingKind::FocusRingObscured),
2426 "{}",
2427 report.text()
2428 );
2429 }
2430
2431 #[test]
2432 fn scrollbar_overlap_lint_fires_when_thumb_covers_fill_child() {
2433 let body = crate::tree::column(
2437 (0..30)
2438 .map(|i| {
2439 crate::tree::row([
2440 crate::text(format!("Row {i}")),
2441 crate::tree::spacer(),
2442 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2443 ])
2444 .gap(crate::tokens::SPACE_2)
2445 .width(Size::Fill(1.0))
2446 })
2447 .collect::<Vec<_>>(),
2448 )
2449 .gap(crate::tokens::SPACE_2)
2450 .width(Size::Fill(1.0));
2451
2452 let mut root = crate::tree::scroll([body])
2453 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2454 .width(Size::Fixed(480.0))
2455 .height(Size::Fixed(320.0));
2456 let mut state = UiState::new();
2457 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2458 let report = lint(&root, &state);
2459
2460 assert!(
2461 report
2462 .findings
2463 .iter()
2464 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2465 "expected ScrollbarObscuresFocusable for a switch that reaches the scroll's inner.right()\n{}",
2466 report.text()
2467 );
2468 }
2469
2470 #[test]
2471 fn scrollbar_overlap_lint_silenced_when_padding_is_inside_scroll() {
2472 let body = crate::tree::column(
2476 (0..30)
2477 .map(|i| {
2478 crate::tree::row([
2479 crate::text(format!("Row {i}")),
2480 crate::tree::spacer(),
2481 crate::widgets::switch::switch(false).key(format!("row-{i}-toggle")),
2482 ])
2483 .gap(crate::tokens::SPACE_2)
2484 .width(Size::Fill(1.0))
2485 })
2486 .collect::<Vec<_>>(),
2487 )
2488 .gap(crate::tokens::SPACE_2)
2489 .width(Size::Fill(1.0));
2490
2491 let mut root = crate::tree::scroll([crate::tree::column([body])
2492 .padding(Sides::xy(crate::tokens::SPACE_3, 0.0))
2493 .width(Size::Fill(1.0))])
2494 .padding(Sides::xy(0.0, crate::tokens::SPACE_2))
2495 .width(Size::Fixed(480.0))
2496 .height(Size::Fixed(320.0));
2497 let mut state = UiState::new();
2498 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2499 let report = lint(&root, &state);
2500
2501 assert!(
2502 !report
2503 .findings
2504 .iter()
2505 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2506 "expected no ScrollbarObscuresFocusable when padding is inside the scroll\n{}",
2507 report.text()
2508 );
2509 }
2510
2511 #[test]
2512 fn scrollbar_overlap_lint_quiet_when_content_does_not_overflow() {
2513 let body = crate::tree::column([crate::tree::row([
2518 crate::text("only row"),
2519 crate::tree::spacer(),
2520 crate::widgets::switch::switch(false).key("only-toggle"),
2521 ])
2522 .gap(crate::tokens::SPACE_2)
2523 .width(Size::Fill(1.0))])
2524 .gap(crate::tokens::SPACE_2)
2525 .width(Size::Fill(1.0));
2526
2527 let mut root = crate::tree::scroll([body])
2528 .padding(Sides::xy(crate::tokens::SPACE_3, crate::tokens::SPACE_2))
2529 .width(Size::Fixed(480.0))
2530 .height(Size::Fixed(320.0));
2531 let mut state = UiState::new();
2532 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 480.0, 320.0));
2533 let report = lint(&root, &state);
2534
2535 assert!(
2536 !report
2537 .findings
2538 .iter()
2539 .any(|f| f.kind == FindingKind::ScrollbarObscuresFocusable),
2540 "expected no ScrollbarObscuresFocusable when content fits in the viewport (no thumb rendered)\n{}",
2541 report.text()
2542 );
2543 }
2544
2545 #[test]
2546 fn unkeyed_tooltip_reports_dead_tooltip() {
2547 let root = crate::text("abc1234").tooltip("commit sha");
2553
2554 let report = lint_one(root);
2555
2556 assert!(
2557 report
2558 .findings
2559 .iter()
2560 .any(|f| f.kind == FindingKind::DeadTooltip),
2561 "expected DeadTooltip on unkeyed tooltipped text\n{}",
2562 report.text()
2563 );
2564 }
2565
2566 #[test]
2567 fn keyed_tooltip_satisfies_dead_tooltip_policy() {
2568 let root = crate::text("abc1234").key("sha").tooltip("commit sha");
2571
2572 let report = lint_one(root);
2573
2574 assert!(
2575 !report
2576 .findings
2577 .iter()
2578 .any(|f| f.kind == FindingKind::DeadTooltip),
2579 "{}",
2580 report.text()
2581 );
2582 }
2583
2584 #[test]
2585 fn unkeyed_tooltip_inside_keyed_ancestor_still_reports_dead_tooltip() {
2586 let root =
2592 crate::row([crate::text("inner detail").tooltip("never shown")]).key("outer-row");
2593
2594 let report = lint_one(root);
2595
2596 assert!(
2597 report
2598 .findings
2599 .iter()
2600 .any(|f| f.kind == FindingKind::DeadTooltip),
2601 "expected DeadTooltip on unkeyed leaf even with keyed ancestor\n{}",
2602 report.text()
2603 );
2604 }
2605
2606 #[test]
2607 fn focus_ring_lint_is_quiet_inside_form_after_padding_fix() {
2608 let selection = crate::selection::Selection::default();
2612 let mut root = crate::tree::scroll([crate::widgets::form::form([
2613 crate::widgets::form::form_item([crate::widgets::form::form_control(
2614 crate::widgets::text_input::text_input("", &selection, "field"),
2615 )]),
2616 ])])
2617 .width(Size::Fixed(300.0))
2618 .height(Size::Fixed(120.0));
2619 let mut state = UiState::new();
2620 layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2621 let report = lint(&root, &state);
2622
2623 assert!(
2624 !report
2625 .findings
2626 .iter()
2627 .any(|f| f.kind == FindingKind::FocusRingObscured),
2628 "{}",
2629 report.text()
2630 );
2631 }
2632
2633 fn lint_one_with_metrics(mut root: El) -> LintReport {
2638 crate::metrics::ThemeMetrics::default().apply_to_tree(&mut root);
2639 let mut ui_state = UiState::new();
2640 layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 120.0));
2641 lint(&root, &ui_state)
2642 }
2643
2644 #[test]
2645 fn handrolled_rounded_container_with_flat_filled_header_reports_corner_stackup() {
2646 let parent = crate::column([
2650 crate::row([crate::text("Header")])
2651 .fill(crate::tokens::MUTED)
2652 .width(Size::Fill(1.0))
2653 .height(Size::Fixed(24.0)),
2654 crate::row([crate::text("Body")])
2655 .width(Size::Fill(1.0))
2656 .height(Size::Fixed(60.0)),
2657 ])
2658 .fill(crate::tokens::CARD)
2659 .stroke(crate::tokens::BORDER)
2660 .radius(crate::tokens::RADIUS_LG)
2661 .width(Size::Fixed(160.0))
2662 .height(Size::Fixed(96.0));
2663
2664 let report = lint_one(parent);
2665
2666 let found = report
2667 .findings
2668 .iter()
2669 .find(|f| f.kind == FindingKind::CornerStackup);
2670 let found =
2671 found.unwrap_or_else(|| panic!("expected CornerStackup, got:\n{}", report.text()));
2672 assert!(
2673 found.message.contains("Corners::top"),
2674 "top-strip leak should suggest Corners::top, got: {}",
2675 found.message
2676 );
2677 }
2678
2679 #[test]
2680 fn handrolled_rounded_container_with_inset_child_does_not_report_corner_stackup() {
2681 let parent = crate::column([crate::row([crate::text("Header")])
2683 .fill(crate::tokens::MUTED)
2684 .width(Size::Fill(1.0))
2685 .height(Size::Fixed(24.0))])
2686 .fill(crate::tokens::CARD)
2687 .stroke(crate::tokens::BORDER)
2688 .radius(crate::tokens::RADIUS_LG)
2689 .padding(Sides::all(crate::tokens::RADIUS_LG))
2690 .width(Size::Fixed(160.0))
2691 .height(Size::Fixed(96.0));
2692
2693 let report = lint_one(parent);
2694 assert!(
2695 !report
2696 .findings
2697 .iter()
2698 .any(|f| f.kind == FindingKind::CornerStackup),
2699 "inset child should not trip the lint, got:\n{}",
2700 report.text()
2701 );
2702 }
2703
2704 #[test]
2705 fn handrolled_rounded_container_with_matching_corners_does_not_report_corner_stackup() {
2706 let parent = crate::column([crate::row([crate::text("Header")])
2707 .fill(crate::tokens::MUTED)
2708 .radius(Corners::top(crate::tokens::RADIUS_LG))
2709 .width(Size::Fill(1.0))
2710 .height(Size::Fixed(24.0))])
2711 .fill(crate::tokens::CARD)
2712 .stroke(crate::tokens::BORDER)
2713 .radius(crate::tokens::RADIUS_LG)
2714 .width(Size::Fixed(160.0))
2715 .height(Size::Fixed(96.0));
2716
2717 let report = lint_one(parent);
2718 assert!(
2719 !report
2720 .findings
2721 .iter()
2722 .any(|f| f.kind == FindingKind::CornerStackup),
2723 "matching corners should not trip the lint, got:\n{}",
2724 report.text()
2725 );
2726 }
2727
2728 #[test]
2729 fn canonical_card_recipe_does_not_report_corner_stackup_after_metrics() {
2730 let root = crate::widgets::card::card([
2733 crate::widgets::card::card_header([crate::text("Header")]).fill(crate::tokens::MUTED),
2734 crate::widgets::card::card_content([crate::text("Body")]),
2735 ])
2736 .width(Size::Fixed(180.0))
2737 .height(Size::Fixed(110.0));
2738
2739 let report = lint_one_with_metrics(root);
2740 assert!(
2741 !report
2742 .findings
2743 .iter()
2744 .any(|f| f.kind == FindingKind::CornerStackup),
2745 "canonical card_header(...).fill(...) recipe should be quiet after metrics pass, got:\n{}",
2746 report.text()
2747 );
2748 }
2749
2750 #[test]
2751 fn bare_card_with_flush_content_reports_unpadded_surface_panel_issue_24() {
2752 let root = crate::widgets::card::card([crate::row([
2757 crate::text("some title").bold(),
2758 crate::text("description line").muted(),
2759 ])
2760 .gap(crate::tokens::SPACE_2)
2761 .width(Size::Fill(1.0))])
2762 .width(Size::Fixed(200.0))
2763 .height(Size::Fixed(80.0));
2764
2765 let report = lint_one(root);
2766 let f = report
2767 .findings
2768 .iter()
2769 .find(|f| f.kind == FindingKind::UnpaddedSurfacePanel)
2770 .unwrap_or_else(|| {
2771 panic!(
2772 "expected UnpaddedSurfacePanel finding, got:\n{}",
2773 report.text()
2774 )
2775 });
2776 assert!(
2777 f.message.contains("top"),
2778 "expected the flushing-side list to call out `top`, got: {}",
2779 f.message
2780 );
2781 }
2782
2783 #[test]
2784 fn card_with_explicit_padding_does_not_report_unpadded_surface_panel() {
2785 let root = crate::widgets::card::card([
2788 crate::row([crate::text("title").bold()]).width(Size::Fill(1.0))
2789 ])
2790 .padding(Sides::all(crate::tokens::SPACE_4))
2791 .width(Size::Fixed(200.0))
2792 .height(Size::Fixed(60.0));
2793
2794 let report = lint_one(root);
2795 assert!(
2796 !report
2797 .findings
2798 .iter()
2799 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2800 "{}",
2801 report.text()
2802 );
2803 }
2804
2805 #[test]
2806 fn canonical_card_anatomy_does_not_report_unpadded_surface_panel() {
2807 let root = crate::widgets::card::card([
2811 crate::widgets::card::card_header([crate::widgets::card::card_title("Header")]),
2812 crate::widgets::card::card_content([crate::text("Body")]),
2813 crate::widgets::card::card_footer([crate::text("footer")]),
2814 ])
2815 .width(Size::Fixed(220.0))
2816 .height(Size::Fixed(160.0));
2817
2818 let report = lint_one(root);
2819 assert!(
2820 !report
2821 .findings
2822 .iter()
2823 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2824 "canonical slot anatomy should be quiet, got:\n{}",
2825 report.text()
2826 );
2827 }
2828
2829 #[test]
2830 fn sidebar_widget_does_not_report_unpadded_surface_panel() {
2831 let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
2834
2835 let report = lint_one(root);
2836 assert!(
2837 !report
2838 .findings
2839 .iter()
2840 .any(|f| f.kind == FindingKind::UnpaddedSurfacePanel),
2841 "{}",
2842 report.text()
2843 );
2844 }
2845}