plumb_core/rules/opacity/
scale_conformance.rs1use 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
11const OPACITY: &str = "opacity";
13
14const OPACITY_TOLERANCE: f64 = 0.005;
16
17#[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 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#[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 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}