Skip to main content

plumb_core/rules/sibling/
padding_consistency.rs

1//! `sibling/padding-consistency` — flag sibling elements with
2//! inconsistent padding.
3
4use indexmap::IndexMap;
5
6use crate::config::Config;
7use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
8use crate::rules::Rule;
9use crate::rules::util::parse_px;
10use crate::snapshot::SnapshotCtx;
11
12/// The padding longhands checked for consistency.
13const PADDING_PROPERTIES: &[&str] = &[
14    "padding-top",
15    "padding-right",
16    "padding-bottom",
17    "padding-left",
18];
19
20/// Padding this far from the sibling median (in CSS pixels) triggers a
21/// violation.
22const PADDING_DEVIATION_PX: u32 = 4;
23
24/// Flags sibling elements with inconsistent padding.
25#[derive(Debug, Clone, Copy)]
26pub struct PaddingConsistency;
27
28impl Rule for PaddingConsistency {
29    fn id(&self) -> &'static str {
30        "sibling/padding-consistency"
31    }
32
33    fn default_severity(&self) -> Severity {
34        Severity::Info
35    }
36
37    fn summary(&self) -> &'static str {
38        "Flags sibling elements with inconsistent padding."
39    }
40
41    fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
42        // Group nodes by `(parent, tag)` so padding is only compared
43        // across same-tag component peers. Comparing a `<p>`, a
44        // `<section>`, and a `<button>` that share a parent is noise —
45        // those elements have unrelated padding budgets.
46        let mut groups: IndexMap<(u64, String), Vec<usize>> = IndexMap::new();
47        for (idx, node) in ctx.snapshot().nodes.iter().enumerate() {
48            let Some(parent) = node.parent else { continue };
49            // Skip invisible nodes: no rect, or a zero-area rect paints
50            // no box, so its padding can't drift visibly.
51            let Some(rect) = ctx.rect_for(node.dom_order) else {
52                continue;
53            };
54            if rect.width == 0 || rect.height == 0 {
55                continue;
56            }
57            groups
58                .entry((parent, node.tag.clone()))
59                .or_default()
60                .push(idx);
61        }
62
63        let nodes = &ctx.snapshot().nodes;
64
65        for siblings in groups.values() {
66            if siblings.len() < 2 {
67                continue;
68            }
69
70            for prop in PADDING_PROPERTIES {
71                // Collect (index, parsed px value) for siblings that have
72                // the property and it parses.
73                let parsed: Vec<(usize, f64)> = siblings
74                    .iter()
75                    .filter_map(|&idx| {
76                        let raw = nodes[idx].computed_styles.get(*prop)?;
77                        let val = parse_px(raw)?;
78                        Some((idx, val))
79                    })
80                    .collect();
81
82                if parsed.len() < 2 {
83                    continue;
84                }
85
86                let median = median_f64(&parsed.iter().map(|(_, v)| *v).collect::<Vec<_>>());
87
88                for &(idx, val) in &parsed {
89                    let dev = (val - median).abs();
90                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
91                    let dev_u32 = dev.round() as u32;
92                    if dev_u32 <= PADDING_DEVIATION_PX {
93                        continue;
94                    }
95
96                    let node = &nodes[idx];
97                    let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
98                    metadata.insert(
99                        "property".to_owned(),
100                        serde_json::Value::String((*prop).to_owned()),
101                    );
102                    metadata.insert(
103                        "rendered_padding_px".to_owned(),
104                        serde_json::Value::from(val),
105                    );
106                    metadata.insert(
107                        "sibling_median_px".to_owned(),
108                        serde_json::Value::from(median),
109                    );
110                    metadata.insert("deviation_px".to_owned(), serde_json::Value::from(dev_u32));
111
112                    sink.push(Violation {
113                        rule_id: self.id().to_owned(),
114                        severity: self.default_severity(),
115                        message: format!(
116                            "`{selector}` has {prop} {val}px; sibling median is {median}px ({dev_u32}px drift).",
117                            selector = node.selector,
118                        ),
119                        selector: node.selector.clone(),
120                        viewport: ctx.snapshot().viewport.clone(),
121                        rect: ctx.rect_for(node.dom_order),
122                        dom_order: node.dom_order,
123                        fix: Some(Fix {
124                            kind: FixKind::Description {
125                                text: format!(
126                                    "Match sibling {prop} ({median}px) to keep padding consistent. Drift: {dev_u32}px.",
127                                ),
128                            },
129                            description: format!(
130                                "Bring `{selector}` {prop} in line with its siblings ({median}px).",
131                                selector = node.selector,
132                            ),
133                            confidence: Confidence::Low,
134                        }),
135                        doc_url: "https://plumb.aramhammoudeh.com/rules/sibling-padding-consistency"
136                            .to_owned(),
137                        metadata,
138                    });
139                }
140            }
141        }
142    }
143}
144
145/// Median of a slice of f64 values.
146///
147/// For even counts, the lower of the two middle values wins (same
148/// deterministic tie-break as `sibling/height-consistency`).
149///
150/// Uses [`f64::total_cmp`] for the sort key — `partial_cmp` returns
151/// `None` on `NaN`, which would force a fallback comparator and risk
152/// nondeterministic ordering. `total_cmp` defines a total order over
153/// all `f64` bit patterns (including `NaN`s), which keeps the median
154/// reproducible regardless of the input distribution.
155fn median_f64(values: &[f64]) -> f64 {
156    let mut sorted: Vec<f64> = values.to_vec();
157    sorted.sort_by(f64::total_cmp);
158    let mid = sorted.len() / 2;
159    if sorted.len().is_multiple_of(2) {
160        sorted[mid - 1]
161    } else {
162        sorted[mid]
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::median_f64;
169
170    #[test]
171    fn median_odd_count_picks_middle() {
172        assert!((median_f64(&[1.0, 5.0, 3.0]) - 3.0).abs() < f64::EPSILON);
173    }
174
175    #[test]
176    fn median_even_count_picks_lower_middle() {
177        // Sorted: [1.0, 3.0, 5.0, 7.0]. Lower of the two middle values
178        // wins, which is 3.0.
179        assert!((median_f64(&[1.0, 7.0, 3.0, 5.0]) - 3.0).abs() < f64::EPSILON);
180    }
181
182    #[test]
183    fn median_with_nan_is_total_and_does_not_panic() {
184        // `f64::total_cmp` defines a total order over NaN bit patterns,
185        // so the sort is well-defined and deterministic. Without
186        // `total_cmp`, `partial_cmp` would return `None` and the
187        // fallback comparator would risk a nondeterministic median.
188        // total_cmp orders NaN after positive infinity, so sorted
189        // looks like [1.0, 2.0, 3.0, NaN]; the lower middle is 2.0.
190        let values = [1.0_f64, f64::NAN, 3.0, 2.0];
191        let result = median_f64(&values);
192        assert!((result - 2.0).abs() < f64::EPSILON);
193    }
194}