plumb_core/rules/sibling/
height_consistency.rs1use 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
35const ROW_TOP_TOLERANCE_PX: i32 = 2;
38
39const HEIGHT_DEVIATION_PX: u32 = 4;
42
43const MIN_HORIZONTAL_OVERLAP: f64 = 0.5;
46
47#[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 let mut groups: IndexMap<u64, Vec<SiblingEntry<'_>>> = IndexMap::new();
69 for node in ctx.nodes() {
70 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#[derive(Debug, Clone, Copy)]
101struct SiblingEntry<'a> {
102 node: &'a SnapshotNode,
103 rect: Rect,
104}
105
106fn 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 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 vec![siblings.to_vec()]
143 }
144}
145
146fn 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
154fn 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
171fn 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
225fn 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 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 assert_eq!(median_height(&row), 20);
295 }
296
297 #[test]
298 fn cluster_groups_siblings_with_close_tops() {
299 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 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 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 assert_eq!(ROW_TOP_TOLERANCE_PX, 2);
353 assert_eq!(HEIGHT_DEVIATION_PX, 4);
354 }
355}