use indexmap::IndexMap;
use crate::config::Config;
use crate::report::{Confidence, Fix, FixKind, Rect, Severity, Violation, ViolationSink};
use crate::rules::Rule;
use crate::snapshot::{SnapshotCtx, SnapshotNode};
const ROW_TOP_TOLERANCE_PX: i32 = 2;
const HEIGHT_DEVIATION_PX: u32 = 4;
const MIN_HORIZONTAL_OVERLAP: f64 = 0.5;
#[derive(Debug, Clone, Copy)]
pub struct HeightConsistency;
impl Rule for HeightConsistency {
fn id(&self) -> &'static str {
"sibling/height-consistency"
}
fn default_severity(&self) -> Severity {
Severity::Info
}
fn summary(&self) -> &'static str {
"Flags sibling elements in the same visual row whose heights drift from the row's median."
}
fn check(&self, ctx: &SnapshotCtx<'_>, _config: &Config, sink: &mut ViolationSink<'_>) {
let mut groups: IndexMap<u64, Vec<SiblingEntry<'_>>> = IndexMap::new();
for node in ctx.nodes() {
let Some(parent) = node.parent else { continue };
let Some(rect) = ctx.rect_for(node.dom_order) else {
continue;
};
groups
.entry(parent)
.or_default()
.push(SiblingEntry { node, rect });
}
for siblings in groups.values() {
if siblings.len() < 2 {
continue;
}
let rows = cluster_into_rows(siblings);
for row in &rows {
emit_for_row(self.id(), self.default_severity(), ctx, row, sink);
}
}
}
}
#[derive(Debug, Clone, Copy)]
struct SiblingEntry<'a> {
node: &'a SnapshotNode,
rect: Rect,
}
fn cluster_into_rows<'a>(siblings: &[SiblingEntry<'a>]) -> Vec<Vec<SiblingEntry<'a>>> {
let mut rows: Vec<Vec<SiblingEntry<'a>>> = Vec::new();
for entry in siblings {
let mut placed = false;
for row in &mut rows {
if let Some(first) = row.first()
&& shares_row(first, entry)
{
row.push(*entry);
placed = true;
break;
}
}
if !placed {
rows.push(vec![*entry]);
}
}
let any_multi = rows.iter().any(|row| row.len() >= 2);
if any_multi {
rows
} else {
vec![siblings.to_vec()]
}
}
fn shares_row(a: &SiblingEntry<'_>, b: &SiblingEntry<'_>) -> bool {
if (a.rect.y - b.rect.y).abs() > ROW_TOP_TOLERANCE_PX {
return false;
}
horizontal_overlap_fraction(&a.rect, &b.rect) >= MIN_HORIZONTAL_OVERLAP
}
fn horizontal_overlap_fraction(a: &Rect, b: &Rect) -> f64 {
let a_left = a.x;
let b_left = b.x;
let a_right = a.x.saturating_add_unsigned(a.width);
let b_right = b.x.saturating_add_unsigned(b.width);
let overlap_left = a_left.max(b_left);
let overlap_right = a_right.min(b_right);
let overlap = (overlap_right - overlap_left).max(0);
let smaller_width = a.width.min(b.width);
if smaller_width == 0 {
return 0.0;
}
f64::from(overlap) / f64::from(smaller_width)
}
fn emit_for_row(
rule_id: &str,
severity: Severity,
ctx: &SnapshotCtx<'_>,
row: &[SiblingEntry<'_>],
sink: &mut ViolationSink<'_>,
) {
if row.len() < 2 {
return;
}
let median = median_height(row);
for entry in row {
let dev = entry.rect.height.abs_diff(median);
if dev <= HEIGHT_DEVIATION_PX {
continue;
}
let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
metadata.insert("rendered_height_px".to_owned(), entry.rect.height.into());
metadata.insert("row_median_height_px".to_owned(), median.into());
metadata.insert("row_size".to_owned(), row.len().into());
metadata.insert("deviation_px".to_owned(), dev.into());
sink.push(Violation {
rule_id: rule_id.to_owned(),
severity,
message: format!(
"`{selector}` is {h}px tall; its row median is {median}px ({dev}px drift).",
selector = entry.node.selector,
h = entry.rect.height,
),
selector: entry.node.selector.clone(),
viewport: ctx.snapshot().viewport.clone(),
rect: Some(entry.rect),
dom_order: entry.node.dom_order,
fix: Some(Fix {
kind: FixKind::Description {
text: format!(
"Match the row's height ({median}px) by adjusting `height` / `min-height` or aligning the inner content. Drift: {dev}px."
),
},
description: format!(
"Bring `{selector}` in line with its row's height ({median}px).",
selector = entry.node.selector,
),
confidence: Confidence::Low,
}),
doc_url: "https://plumb.aramhammoudeh.com/rules/sibling-height-consistency".to_owned(),
metadata,
});
}
}
fn median_height(row: &[SiblingEntry<'_>]) -> u32 {
let mut heights: Vec<u32> = row.iter().map(|e| e.rect.height).collect();
heights.sort_unstable();
let mid = heights.len() / 2;
if heights.len().is_multiple_of(2) {
heights[mid - 1]
} else {
heights[mid]
}
}
#[cfg(test)]
mod tests {
use super::{
HEIGHT_DEVIATION_PX, ROW_TOP_TOLERANCE_PX, SiblingEntry, cluster_into_rows,
horizontal_overlap_fraction, median_height,
};
use crate::report::Rect;
use crate::snapshot::SnapshotNode;
use indexmap::IndexMap;
fn make_node(dom_order: u64) -> SnapshotNode {
SnapshotNode {
dom_order,
selector: format!("n{dom_order}"),
tag: "div".to_owned(),
attrs: IndexMap::new(),
computed_styles: IndexMap::new(),
rect: None,
parent: Some(0),
children: Vec::new(),
}
}
fn rect_at(x: i32, y: i32, width: u32, height: u32) -> Rect {
Rect {
x,
y,
width,
height,
}
}
#[test]
fn horizontal_overlap_smoke() {
let a = rect_at(0, 0, 100, 10);
let b = rect_at(50, 0, 100, 10);
assert!((horizontal_overlap_fraction(&a, &b) - 0.5).abs() < 1e-9);
}
#[test]
fn median_picks_lower_middle_for_even_count() {
let nodes: Vec<SnapshotNode> = (0..4).map(make_node).collect();
let row: Vec<SiblingEntry<'_>> = nodes
.iter()
.zip([10_u32, 20, 30, 40])
.map(|(node, h)| SiblingEntry {
node,
rect: rect_at(0, 0, 10, h),
})
.collect();
assert_eq!(median_height(&row), 20);
}
#[test]
fn cluster_groups_siblings_with_close_tops() {
let nodes: Vec<SnapshotNode> = (1_u64..=3).map(make_node).collect();
let entries: Vec<SiblingEntry<'_>> = vec![
SiblingEntry {
node: &nodes[0],
rect: rect_at(0, 0, 100, 30),
},
SiblingEntry {
node: &nodes[1],
rect: rect_at(20, 1, 100, 40),
},
SiblingEntry {
node: &nodes[2],
rect: rect_at(0, 100, 100, 30),
},
];
let clusters = cluster_into_rows(&entries);
assert_eq!(clusters.len(), 2);
assert_eq!(clusters[0].len(), 2);
assert_eq!(clusters[1].len(), 1);
}
#[test]
fn cluster_falls_back_when_no_row_pairs() {
let nodes: Vec<SnapshotNode> = (1_u64..=3).map(make_node).collect();
let entries: Vec<SiblingEntry<'_>> = vec![
SiblingEntry {
node: &nodes[0],
rect: rect_at(0, 0, 100, 30),
},
SiblingEntry {
node: &nodes[1],
rect: rect_at(0, 100, 100, 40),
},
SiblingEntry {
node: &nodes[2],
rect: rect_at(0, 200, 100, 30),
},
];
let clusters = cluster_into_rows(&entries);
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].len(), 3);
}
#[test]
fn constants_are_what_the_docs_say() {
assert_eq!(ROW_TOP_TOLERANCE_PX, 2);
assert_eq!(HEIGHT_DEVIATION_PX, 4);
}
}