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