plumb_core/rules/spacing/grid_conformance.rs
1//! `spacing/grid-conformance` — flag spacing values that aren't on the
2//! configured grid.
3//!
4//! Iterates the physical-longhand spacing properties (margin / padding
5//! per side, plus `gap` / `row-gap` / `column-gap`) and emits one
6//! violation per offending property when the parsed pixel value isn't
7//! a multiple of `config.spacing.base_unit`.
8//!
9//! The rule defers to the configured spacing scale. When
10//! `config.spacing.scale` is non-empty and the parsed value sits within
11//! `GRID_TOLERANCE_PX` of one of its members, the value is treated as
12//! on the design system and skipped — even when it's off the base-unit
13//! grid. This matters for Tailwind, whose scale includes 2px half-steps
14//! (6/10/14px, …) that a pure "multiple of `base_unit`" test would flag.
15//! An empty scale (the default config) restores the plain base-unit
16//! grid behavior.
17
18use indexmap::IndexMap;
19
20use crate::config::Config;
21use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
22use crate::rules::Rule;
23use crate::rules::spacing::SPACING_PROPERTIES;
24use crate::rules::util::{nearest_in_scale, nearest_multiple, parse_px};
25use crate::snapshot::SnapshotCtx;
26
27/// Tolerance for the off-grid test, in CSS pixels. A value within this
28/// absolute band of the nearest grid multiple is treated as on-grid.
29/// `0.5px` absorbs subpixel rounding and UA-stylesheet `em`-derived
30/// residue (e.g. a `16.08px` default `<h1>` margin snaps to `16`) while
31/// still catching honest off-grid values like `13px` or `10px`.
32const GRID_TOLERANCE_PX: f64 = 0.5;
33
34/// Flags spacing values that aren't multiples of `spacing.base_unit`.
35///
36/// Values explicitly listed in `config.spacing.scale` are exempt: when
37/// the parsed value is within `GRID_TOLERANCE_PX` of a scale member it
38/// is treated as on the design system and never flagged, even if it
39/// falls off the base-unit grid. When the scale is empty the rule checks
40/// against the base unit alone.
41#[derive(Debug, Clone, Copy)]
42pub struct GridConformance;
43
44impl Rule for GridConformance {
45 fn id(&self) -> &'static str {
46 "spacing/grid-conformance"
47 }
48
49 fn default_severity(&self) -> Severity {
50 Severity::Warning
51 }
52
53 fn summary(&self) -> &'static str {
54 "Flags spacing values that aren't multiples of `spacing.base_unit`."
55 }
56
57 fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
58 let base_unit = config.spacing.base_unit;
59 if base_unit == 0 {
60 // Defensive: a zero base_unit makes the check meaningless and
61 // would force a div-by-zero. Skip the rule entirely.
62 return;
63 }
64
65 for node in ctx.nodes() {
66 for prop in SPACING_PROPERTIES {
67 let Some(raw) = node.computed_styles.get(*prop) else {
68 continue;
69 };
70 let Some(value) = parse_px(raw) else { continue };
71 // Defer to the configured spacing scale. When the design
72 // system explicitly lists a value — Tailwind populates
73 // `spacing.scale` with its tokens, including 2px
74 // half-steps like 6/10/14px — that value belongs even
75 // though it's off the `base_unit` grid. Skip the off-grid
76 // test when `value` is within `GRID_TOLERANCE_PX` of its
77 // nearest scale member. An empty scale (default config)
78 // yields `None` here, preserving the pure base-unit grid.
79 if let Some(on_scale) = nearest_in_scale(value, &config.spacing.scale)
80 && (value.abs() - f64::from(on_scale)).abs() <= GRID_TOLERANCE_PX
81 {
82 continue;
83 }
84 let suggested = nearest_multiple(value, base_unit);
85 #[allow(clippy::cast_precision_loss)]
86 let nearest = suggested as f64;
87 if (value - nearest).abs() <= GRID_TOLERANCE_PX {
88 continue;
89 }
90 let to = if suggested == 0 {
91 "0".to_owned()
92 } else {
93 format!("{suggested}px")
94 };
95 sink.push(Violation {
96 rule_id: self.id().to_owned(),
97 severity: self.default_severity(),
98 message: format!(
99 "`{selector}` has off-grid {prop} {raw}; expected a multiple of {base_unit}px.",
100 selector = node.selector,
101 ),
102 selector: node.selector.clone(),
103 viewport: ctx.snapshot().viewport.clone(),
104 rect: ctx.rect_for(node.dom_order),
105 dom_order: node.dom_order,
106 fix: Some(Fix {
107 kind: FixKind::CssPropertyReplace {
108 property: (*prop).to_owned(),
109 from: raw.clone(),
110 to: to.clone(),
111 },
112 description: format!(
113 "Snap `{prop}` to the nearest spacing-grid value ({to}).",
114 ),
115 confidence: Confidence::Medium,
116 }),
117 doc_url: "https://plumb.aramhammoudeh.com/rules/spacing-grid-conformance"
118 .to_owned(),
119 metadata: IndexMap::new(),
120 });
121 }
122 }
123 }
124}