plumb_core/rules/sibling/
height_consistency.rs1use 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
31const ROW_TOP_TOLERANCE_PX: i32 = 2;
34
35const HEIGHT_DEVIATION_PX: u32 = 4;
38
39const MIN_HORIZONTAL_OVERLAP: f64 = 0.5;
42
43#[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 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#[derive(Debug, Clone, Copy)]
90struct SiblingEntry<'a> {
91 node: &'a SnapshotNode,
92 rect: Rect,
93}
94
95fn 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 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 vec![siblings.to_vec()]
132 }
133}
134
135fn 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
143fn 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
160fn 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
214fn 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 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 assert_eq!(median_height(&row), 20);
284 }
285
286 #[test]
287 fn cluster_groups_siblings_with_close_tops() {
288 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 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 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 assert_eq!(ROW_TOP_TOLERANCE_PX, 2);
342 assert_eq!(HEIGHT_DEVIATION_PX, 4);
343 }
344}