Skip to main content

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}