Skip to main content

plumb_core/rules/opacity/
scale_conformance.rs

1//! `opacity/scale-conformance` — flag `opacity` values that aren't in
2//! `opacity.scale`.
3
4use indexmap::IndexMap;
5
6use crate::config::Config;
7use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
8use crate::rules::Rule;
9use crate::snapshot::SnapshotCtx;
10
11/// The single property this rule inspects.
12const OPACITY: &str = "opacity";
13
14/// Tolerance for matching against scale values.
15const OPACITY_TOLERANCE: f64 = 0.005;
16
17/// Flags `opacity` values that aren't in `opacity.scale`.
18#[derive(Debug, Clone, Copy)]
19pub struct ScaleConformance;
20
21impl Rule for ScaleConformance {
22    fn id(&self) -> &'static str {
23        "opacity/scale-conformance"
24    }
25
26    fn default_severity(&self) -> Severity {
27        Severity::Warning
28    }
29
30    fn summary(&self) -> &'static str {
31        "Flags `opacity` values that aren't in `opacity.scale`."
32    }
33
34    fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
35        let scale = &config.opacity.scale;
36        if scale.is_empty() {
37            return;
38        }
39
40        for node in ctx.nodes() {
41            let Some(raw) = node.computed_styles.get(OPACITY) else {
42                continue;
43            };
44            let Ok(value) = raw.trim().parse::<f64>() else {
45                continue;
46            };
47
48            let matches = scale
49                .iter()
50                .any(|&s| (value - f64::from(s)).abs() < OPACITY_TOLERANCE);
51            if matches {
52                continue;
53            }
54
55            // The early `scale.is_empty()` guard above ensures
56            // `nearest_opacity` always returns `Some`. The `?` keeps
57            // the rule total even if a future refactor relaxes the
58            // outer guard.
59            let Some(nearest) = nearest_opacity(value, scale) else {
60                continue;
61            };
62
63            let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
64            metadata.insert("opacity".to_owned(), serde_json::Value::from(value));
65            metadata.insert(
66                "nearest".to_owned(),
67                serde_json::Value::from(f64::from(nearest)),
68            );
69
70            sink.push(Violation {
71                rule_id: self.id().to_owned(),
72                severity: self.default_severity(),
73                message: format!(
74                    "`{selector}` has off-scale opacity {value}; expected a value from opacity.scale.",
75                    selector = node.selector,
76                ),
77                selector: node.selector.clone(),
78                viewport: ctx.snapshot().viewport.clone(),
79                rect: ctx.rect_for(node.dom_order),
80                dom_order: node.dom_order,
81                fix: Some(Fix {
82                    kind: FixKind::CssPropertyReplace {
83                        property: OPACITY.to_owned(),
84                        from: raw.clone(),
85                        to: format!("{nearest}"),
86                    },
87                    description: format!(
88                        "Snap `opacity` to the nearest scale value ({nearest}).",
89                    ),
90                    confidence: Confidence::Medium,
91                }),
92                doc_url: "https://plumb.aramhammoudeh.com/rules/opacity-scale-conformance"
93                    .to_owned(),
94                metadata,
95            });
96        }
97    }
98}
99
100/// Find the nearest opacity in the scale. Ties: lower value wins.
101///
102/// Returns `None` only when `scale` is empty. Encoding the empty-scale
103/// case in the return type keeps the helper total: callers do not
104/// have to repeat an `is_empty()` precondition to avoid a panic, and
105/// the rule stays sound if a future caller forgets the outer guard.
106#[allow(clippy::float_cmp)]
107fn nearest_opacity(value: f64, scale: &[f32]) -> Option<f32> {
108    scale.iter().copied().fold(None, |best, candidate| {
109        let candidate_delta = (value - f64::from(candidate)).abs();
110        match best {
111            None => Some(candidate),
112            Some(current) => {
113                let current_delta = (value - f64::from(current)).abs();
114                // Equality on `f64` deltas is intentional: each delta
115                // is computed the same way for every candidate, so a
116                // true tie is exactly representable in `f64`.
117                if candidate_delta < current_delta
118                    || (candidate_delta == current_delta && candidate < current)
119                {
120                    Some(candidate)
121                } else {
122                    Some(current)
123                }
124            }
125        }
126    })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::nearest_opacity;
132
133    #[test]
134    fn empty_scale_returns_none() {
135        assert_eq!(nearest_opacity(0.5, &[]), None);
136    }
137
138    #[test]
139    fn picks_closest_opacity_in_scale() {
140        let scale: [f32; 3] = [0.0, 0.5, 1.0];
141        assert_eq!(nearest_opacity(0.4, &scale), Some(0.5));
142        assert_eq!(nearest_opacity(0.1, &scale), Some(0.0));
143        assert_eq!(nearest_opacity(0.9, &scale), Some(1.0));
144    }
145}