Skip to main content

plumb_core/rules/spacing/
scale_conformance.rs

1//! `spacing/scale-conformance` — flag spacing values that are not
2//! members of the configured discrete scale.
3//!
4//! Iterates the same physical-longhand spacing properties as
5//! [`super::grid_conformance`] but compares each parsed pixel value
6//! against `config.spacing.scale`. Subpixel values are tolerated to
7//! within `0.5px` so a computed `12.4px` matches scale element `12`.
8
9use indexmap::IndexMap;
10
11use crate::config::Config;
12use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
13use crate::rules::Rule;
14use crate::rules::spacing::SPACING_PROPERTIES;
15use crate::rules::util::{nearest_in_scale, parse_px};
16use crate::snapshot::SnapshotCtx;
17
18/// Tolerance for the off-scale comparison. `0.5` keeps the rule
19/// resilient against subpixel rounding from `getComputedStyle` while
20/// still catching genuinely off-scale values like `15px` against an
21/// `[16, 24]` scale.
22const SCALE_TOLERANCE: f64 = 0.5;
23
24/// Flags spacing values that aren't members of `spacing.scale`.
25#[derive(Debug, Clone, Copy)]
26pub struct ScaleConformance;
27
28impl Rule for ScaleConformance {
29    fn id(&self) -> &'static str {
30        "spacing/scale-conformance"
31    }
32
33    fn default_severity(&self) -> Severity {
34        Severity::Warning
35    }
36
37    fn summary(&self) -> &'static str {
38        "Flags spacing values that aren't members of `spacing.scale`."
39    }
40
41    fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
42        let scale = &config.spacing.scale;
43        if scale.is_empty() {
44            // No scale configured — the rule is a no-op rather than
45            // flagging every pixel value as out of bounds.
46            return;
47        }
48
49        for node in ctx.nodes() {
50            for prop in SPACING_PROPERTIES {
51                let Some(raw) = node.computed_styles.get(*prop) else {
52                    continue;
53                };
54                let Some(value) = parse_px(raw) else { continue };
55                let abs = value.abs();
56                if scale
57                    .iter()
58                    .any(|&elem| (abs - f64::from(elem)).abs() < SCALE_TOLERANCE)
59                {
60                    continue;
61                }
62                let Some(suggested) = nearest_in_scale(value, scale) else {
63                    // Unreachable in practice — `scale.is_empty()` is
64                    // checked above. Skip rather than emit a misleading
65                    // violation if the invariant ever changes.
66                    continue;
67                };
68                let to = if suggested == 0 {
69                    "0".to_owned()
70                } else {
71                    format!("{suggested}px")
72                };
73                sink.push(Violation {
74                    rule_id: self.id().to_owned(),
75                    severity: self.default_severity(),
76                    message: format!(
77                        "`{selector}` has off-scale {prop} {raw}; expected a value from spacing.scale.",
78                        selector = node.selector,
79                    ),
80                    selector: node.selector.clone(),
81                    viewport: ctx.snapshot().viewport.clone(),
82                    rect: ctx.rect_for(node.dom_order),
83                    dom_order: node.dom_order,
84                    fix: Some(Fix {
85                        kind: FixKind::CssPropertyReplace {
86                            property: (*prop).to_owned(),
87                            from: raw.clone(),
88                            to: to.clone(),
89                        },
90                        description: format!(
91                            "Snap `{prop}` to the nearest spacing-scale value ({to}).",
92                        ),
93                        confidence: Confidence::Medium,
94                    }),
95                    doc_url: "https://plumb.aramhammoudeh.com/rules/spacing-scale-conformance"
96                        .to_owned(),
97                    metadata: IndexMap::new(),
98                });
99            }
100        }
101    }
102}