Skip to main content

plumb_core/rules/radius/
scale_conformance.rs

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