Skip to main content

plumb_core/rules/sibling/
height_consistency.rs

1//! `sibling/height-consistency` — flag sibling elements whose height
2//! drifts from the row's median.
3//!
4//! ## Heuristic
5//!
6//! 1. Group nodes by `parent` `dom_order`.
7//! 2. Within each parent, cluster the siblings into "visual rows":
8//!    two siblings share a row when their `top` edges are within
9//!    `ROW_TOP_TOLERANCE_PX` AND their bounding rects overlap
10//!    horizontally by at least 50% of the smaller width.
11//!    The clustering walks siblings in DOM order and assigns each one
12//!    to the first row it fits, opening a new row otherwise.
13//! 3. If every sibling ends up in its own row, fall back to a single
14//!    DOM-sibling group — this catches cases like absolutely-positioned
15//!    cards where the row geometry doesn't pan out.
16//! 4. For each row of size ≥ 2, compute the median height. Any element
17//!    whose height deviates from the median by more than
18//!    `HEIGHT_DEVIATION_PX` fires a violation.
19//!
20//! Sibling iteration uses `parent` `dom_order` rather than the full
21//! DOM tree, so the rule only ever fires once per offending node per
22//! viewport.
23
24use indexmap::IndexMap;
25
26use crate::config::Config;
27use crate::report::{Confidence, Fix, FixKind, Rect, Severity, Violation, ViolationSink};
28use crate::rules::Rule;
29use crate::snapshot::{SnapshotCtx, SnapshotNode};
30
31/// Maximum vertical offset (in CSS pixels) between two sibling tops
32/// that still counts as the "same row".
33const ROW_TOP_TOLERANCE_PX: i32 = 2;
34
35/// Heights this far from the row median (in CSS pixels) trigger a
36/// violation. Smaller drift is treated as subpixel noise.
37const HEIGHT_DEVIATION_PX: u32 = 4;
38
39/// Minimum horizontal overlap (as a fraction of the smaller width) for
40/// two siblings to share a row.
41const MIN_HORIZONTAL_OVERLAP: f64 = 0.5;
42
43/// Flags sibling elements in the same visual row whose heights drift
44/// from the row's median.
45#[derive(Debug, Clone, Copy)]
46pub struct HeightConsistency;
47
48impl Rule for HeightConsistency {
49    fn id(&self) -> &'static str {
50        "sibling/height-consistency"
51    }
52
53    fn default_severity(&self) -> Severity {
54        Severity::Info
55    }
56
57    fn summary(&self) -> &'static str {
58        "Flags sibling elements in the same visual row whose heights drift from the row's median."
59    }
60
61    fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
62        // Group siblings by `parent`. Siblings with no rect are skipped
63        // — height clustering needs geometry.
64        let mut groups: IndexMap<u64, Vec<SiblingEntry<'_>>> = IndexMap::new();
65        for node in ctx.nodes() {
66            let Some(parent) = node.parent else { continue };
67            let Some(rect) = ctx.rect_for(node.dom_order) else {
68                continue;
69            };
70            groups
71                .entry(parent)
72                .or_default()
73                .push(SiblingEntry { node, rect });
74        }
75
76        for siblings in groups.values() {
77            if siblings.len() < 2 {
78                continue;
79            }
80            let rows = cluster_into_rows(siblings);
81            for row in &rows {
82                emit_for_row(self.id(), self.default_severity(), ctx, row, sink);
83            }
84        }
85    }
86}
87
88/// One sibling, paired with its rect for cheap geometry math.
89#[derive(Debug, Clone, Copy)]
90struct SiblingEntry<'a> {
91    node: &'a SnapshotNode,
92    rect: Rect,
93}
94
95/// Cluster siblings into visual rows.
96///
97/// Walks in DOM order; each entry joins the first existing row whose
98/// representative shares its top (within tolerance) and overlaps it
99/// horizontally by ≥ [`MIN_HORIZONTAL_OVERLAP`]. Otherwise a new row
100/// opens.
101///
102/// If clustering produces only singleton rows, fall back to a single
103/// DOM-sibling group. The fallback keeps the rule useful for layouts
104/// where row geometry is unreliable (absolute positioning, transforms)
105/// while the median-deviation check still rejects single-element groups.
106fn cluster_into_rows<'a>(siblings: &[SiblingEntry<'a>]) -> Vec<Vec<SiblingEntry<'a>>> {
107    let mut rows: Vec<Vec<SiblingEntry<'a>>> = Vec::new();
108    for entry in siblings {
109        let mut placed = false;
110        for row in &mut rows {
111            // Compare against the row's first member — a stable
112            // representative since rows grow in DOM order.
113            if let Some(first) = row.first()
114                && shares_row(first, entry)
115            {
116                row.push(*entry);
117                placed = true;
118                break;
119            }
120        }
121        if !placed {
122            rows.push(vec![*entry]);
123        }
124    }
125
126    let any_multi = rows.iter().any(|row| row.len() >= 2);
127    if any_multi {
128        rows
129    } else {
130        // Fallback: treat every sibling as one DOM group.
131        vec![siblings.to_vec()]
132    }
133}
134
135/// Whether two siblings share a row.
136fn shares_row(a: &SiblingEntry<'_>, b: &SiblingEntry<'_>) -> bool {
137    if (a.rect.y - b.rect.y).abs() > ROW_TOP_TOLERANCE_PX {
138        return false;
139    }
140    horizontal_overlap_fraction(&a.rect, &b.rect) >= MIN_HORIZONTAL_OVERLAP
141}
142
143/// Fraction of the smaller width covered by the horizontal intersection.
144fn horizontal_overlap_fraction(a: &Rect, b: &Rect) -> f64 {
145    let a_left = a.x;
146    let b_left = b.x;
147    let a_right = a.x.saturating_add_unsigned(a.width);
148    let b_right = b.x.saturating_add_unsigned(b.width);
149
150    let overlap_left = a_left.max(b_left);
151    let overlap_right = a_right.min(b_right);
152    let overlap = (overlap_right - overlap_left).max(0);
153    let smaller_width = a.width.min(b.width);
154    if smaller_width == 0 {
155        return 0.0;
156    }
157    f64::from(overlap) / f64::from(smaller_width)
158}
159
160/// Emit a violation for every member of `row` whose height deviates
161/// from the row's median by more than `HEIGHT_DEVIATION_PX`.
162fn emit_for_row(
163    rule_id: &str,
164    severity: Severity,
165    ctx: &SnapshotCtx<'_>,
166    row: &[SiblingEntry<'_>],
167    sink: &mut ViolationSink<'_>,
168) {
169    if row.len() < 2 {
170        return;
171    }
172    let median = median_height(row);
173    for entry in row {
174        let dev = entry.rect.height.abs_diff(median);
175        if dev <= HEIGHT_DEVIATION_PX {
176            continue;
177        }
178        let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
179        metadata.insert("rendered_height_px".to_owned(), entry.rect.height.into());
180        metadata.insert("row_median_height_px".to_owned(), median.into());
181        metadata.insert("row_size".to_owned(), row.len().into());
182        metadata.insert("deviation_px".to_owned(), dev.into());
183
184        sink.push(Violation {
185            rule_id: rule_id.to_owned(),
186            severity,
187            message: format!(
188                "`{selector}` is {h}px tall; its row median is {median}px ({dev}px drift).",
189                selector = entry.node.selector,
190                h = entry.rect.height,
191            ),
192            selector: entry.node.selector.clone(),
193            viewport: ctx.snapshot().viewport.clone(),
194            rect: Some(entry.rect),
195            dom_order: entry.node.dom_order,
196            fix: Some(Fix {
197                kind: FixKind::Description {
198                    text: format!(
199                        "Match the row's height ({median}px) by adjusting `height` / `min-height` or aligning the inner content. Drift: {dev}px."
200                    ),
201                },
202                description: format!(
203                    "Bring `{selector}` in line with its row's height ({median}px).",
204                    selector = entry.node.selector,
205                ),
206                confidence: Confidence::Low,
207            }),
208            doc_url: "https://plumb.aramhammoudeh.com/rules/sibling-height-consistency".to_owned(),
209            metadata,
210        });
211    }
212}
213
214/// Median height across a row's entries.
215///
216/// `row` is non-empty by construction (caller guards with `len < 2`).
217/// For an even count, the lower of the two middle values wins — a
218/// deterministic, integer-only choice that matches "snap toward the
219/// shorter neighbour" rather than introducing floating-point math.
220fn median_height(row: &[SiblingEntry<'_>]) -> u32 {
221    let mut heights: Vec<u32> = row.iter().map(|e| e.rect.height).collect();
222    heights.sort_unstable();
223    let mid = heights.len() / 2;
224    if heights.len().is_multiple_of(2) {
225        heights[mid - 1]
226    } else {
227        heights[mid]
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{
234        HEIGHT_DEVIATION_PX, ROW_TOP_TOLERANCE_PX, SiblingEntry, cluster_into_rows,
235        horizontal_overlap_fraction, median_height,
236    };
237    use crate::report::Rect;
238    use crate::snapshot::SnapshotNode;
239    use indexmap::IndexMap;
240
241    fn make_node(dom_order: u64) -> SnapshotNode {
242        SnapshotNode {
243            dom_order,
244            selector: format!("n{dom_order}"),
245            tag: "div".to_owned(),
246            attrs: IndexMap::new(),
247            computed_styles: IndexMap::new(),
248            rect: None,
249            parent: Some(0),
250            children: Vec::new(),
251        }
252    }
253
254    fn rect_at(x: i32, y: i32, width: u32, height: u32) -> Rect {
255        Rect {
256            x,
257            y,
258            width,
259            height,
260        }
261    }
262
263    #[test]
264    fn horizontal_overlap_smoke() {
265        let a = rect_at(0, 0, 100, 10);
266        let b = rect_at(50, 0, 100, 10);
267        // 50px overlap / min(100, 100) = 0.5 → exactly the threshold.
268        assert!((horizontal_overlap_fraction(&a, &b) - 0.5).abs() < 1e-9);
269    }
270
271    #[test]
272    fn median_picks_lower_middle_for_even_count() {
273        let nodes: Vec<SnapshotNode> = (0..4).map(make_node).collect();
274        let row: Vec<SiblingEntry<'_>> = nodes
275            .iter()
276            .zip([10_u32, 20, 30, 40])
277            .map(|(node, h)| SiblingEntry {
278                node,
279                rect: rect_at(0, 0, 10, h),
280            })
281            .collect();
282        // Sorted heights are [10, 20, 30, 40]; lower-middle = 20.
283        assert_eq!(median_height(&row), 20);
284    }
285
286    #[test]
287    fn cluster_groups_siblings_with_close_tops() {
288        // Two entries sit in row 1 (y=0 / y=1, with full horizontal
289        // overlap on a stacked-width column). A third entry drops to
290        // y=100 and forms its own row. The clusterer should produce
291        // two rows of sizes 2 and 1.
292        let nodes: Vec<SnapshotNode> = (1_u64..=3).map(make_node).collect();
293        let entries: Vec<SiblingEntry<'_>> = vec![
294            SiblingEntry {
295                node: &nodes[0],
296                rect: rect_at(0, 0, 100, 30),
297            },
298            SiblingEntry {
299                node: &nodes[1],
300                rect: rect_at(20, 1, 100, 40),
301            },
302            SiblingEntry {
303                node: &nodes[2],
304                rect: rect_at(0, 100, 100, 30),
305            },
306        ];
307        let clusters = cluster_into_rows(&entries);
308        assert_eq!(clusters.len(), 2);
309        assert_eq!(clusters[0].len(), 2);
310        assert_eq!(clusters[1].len(), 1);
311    }
312
313    #[test]
314    fn cluster_falls_back_when_no_row_pairs() {
315        // Three siblings stacked vertically — no two share a row.
316        let nodes: Vec<SnapshotNode> = (1_u64..=3).map(make_node).collect();
317        let entries: Vec<SiblingEntry<'_>> = vec![
318            SiblingEntry {
319                node: &nodes[0],
320                rect: rect_at(0, 0, 100, 30),
321            },
322            SiblingEntry {
323                node: &nodes[1],
324                rect: rect_at(0, 100, 100, 40),
325            },
326            SiblingEntry {
327                node: &nodes[2],
328                rect: rect_at(0, 200, 100, 30),
329            },
330        ];
331        let clusters = cluster_into_rows(&entries);
332        // Fallback: a single DOM-sibling group.
333        assert_eq!(clusters.len(), 1);
334        assert_eq!(clusters[0].len(), 3);
335    }
336
337    #[test]
338    fn constants_are_what_the_docs_say() {
339        // Pin the documented thresholds so doc drift is caught at
340        // compile time.
341        assert_eq!(ROW_TOP_TOLERANCE_PX, 2);
342        assert_eq!(HEIGHT_DEVIATION_PX, 4);
343    }
344}