From a5df2b0826c5c4938e9cb92c54ce25076c7e8da1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 18 Sep 2026 14:33:47 -0700 Subject: [PATCH] The rendered rules measure what rendered: the line instead of the box that holds it, the space around the text instead of the declared padding, gray by chroma at its own lightness, the AI palette by two tell hues rather than one accent, and the eyebrow named as the element its own design document declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five browser rules were reporting something other than what the reader sees, and a review that charges those numbers is charging noise. - `line-length` measured `rect.width / (fontSize * 0.5)`, the box's capacity. A paragraph in a 1022px column whose text stops at 571px was charged 142 characters a line it never rendered. The probe now hands back the client rects of the direct text one per line box (`direct_text_line_rects`), the characters divide between the lines in proportion to the ink each carries, and the charge needs more than one long line: the harm named is the eye tracking back to the start of the next line, which takes a column to do. - `cramped-padding` read the declared padding. A 44px control with `padding: 0 16px` and a flex-centred label has 12px of air above the label and was charged "0px vertical padding"; the measurement is now the inset between the rendered text and the inside of the border box. Its wrapper half read a text-bearing child's border box the same way, so a `` that fills its table and insets its own text counted as flush; it reads the text now. - `gray-on-color` called anything under 0.85 relative luminance gray, which takes in every off-white: `#e8edf2` measures 0.84 there and 0.93 as lightness. Gray is now low chroma at the lightness the ink actually sits at (saturation, which is chroma normalized for lightness) and neither of the two neutral inks a coloured surface carries. The contrast check beside it is untouched, and the recorded vectors still pass. - `ai-color-palette` charged every hue between 160° and 200° on a dark ground as neon, which lit one ordinary teal accent 18 places on a page with nothing wrong with it. A gradient in a tell hue is still the pattern on its own; flat neon ink on near-black waits for a second tell hue to turn up somewhere on the page, because one saturated accent on a dark system is an accent. - `kicker-above-heading` reported against `body`, so a charged row had nothing to point at, and it fired on eyebrows a design document documents. It names the eyebrow element now, and stands down where the repository's DESIGN.md declares the class by name — the prose's backticked class selectors travel on the design-system config the colour and radius rules already read. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL --- browser-bundle/10-probe.js | 39 ++- crates/browser/src/lib.rs | 1 + crates/core/src/browser/driver.rs | 108 +++++- crates/core/src/browser/element_checks.rs | 94 ++++-- crates/core/src/browser/quality.rs | 314 ++++++++++++++++-- crates/core/src/browser/text_collectors.rs | 95 +++++- crates/core/src/checks/rules.rs | 45 ++- crates/detect/src/design_system.rs | 60 +++- crates/foundation/src/browser/dom.rs | 11 + crates/foundation/src/browser/fake_dom.rs | 32 ++ crates/foundation/src/color.rs | 34 ++ crates/foundation/src/registry.rs | 6 +- crates/wasm/src/js_dom.rs | 9 + ...-json-clipped-overflow-container-html.json | 2 +- .../detect-fixture-json-color-html.json | 2 +- ...re-json-css-in-prose-should-flag-html.json | 2 +- ...ct-fixture-json-edge-flush-cards-html.json | 2 +- ...ixture-json-flush-against-border-html.json | 2 +- ...-fixture-json-framework-next-tailwind.json | 2 +- ...tect-fixture-json-heading-rhythm-html.json | 2 +- ...ect-fixture-json-icon-tile-stack-html.json | 2 +- ...tect-fixture-json-jsx-should-flag-jsx.json | 2 +- .../golden/detect-fixture-json-multifile.json | 2 +- ...fixture-json-overlay-positioning-html.json | 2 +- ...ixture-json-svelte-should-flag-svelte.json | 2 +- ...tect-fixture-json-vue-should-flag-vue.json | 2 +- ...-text-clipped-overflow-container-html.json | 2 +- .../detect-fixture-text-color-html.json | 2 +- ...re-text-css-in-prose-should-flag-html.json | 2 +- ...ct-fixture-text-edge-flush-cards-html.json | 2 +- ...ixture-text-flush-against-border-html.json | 2 +- ...-fixture-text-framework-next-tailwind.json | 2 +- ...tect-fixture-text-heading-rhythm-html.json | 2 +- ...ect-fixture-text-icon-tile-stack-html.json | 2 +- ...tect-fixture-text-jsx-should-flag-jsx.json | 2 +- .../golden/detect-fixture-text-multifile.json | 2 +- ...fixture-text-overlay-positioning-html.json | 2 +- ...ixture-text-svelte-should-flag-svelte.json | 2 +- ...tect-fixture-text-vue-should-flag-vue.json | 2 +- .../detect-framework-next-tailwind-json.json | 2 +- .../oracle/golden/detect-multifile-json.json | 2 +- .../oracle/golden/detect-multifile-text.json | 2 +- tests/oracle/golden/detect-scope-both.json | 2 +- .../golden/detect-scope-layout-text.json | 2 +- 44 files changed, 779 insertions(+), 131 deletions(-) diff --git a/browser-bundle/10-probe.js b/browser-bundle/10-probe.js index 890bac3a4..0664d1c2e 100644 --- a/browser-bundle/10-probe.js +++ b/browser-bundle/10-probe.js @@ -46,6 +46,24 @@ function __rectArray(r) { return [r.x, r.y, r.width, r.height, r.top, r.right, r.bottom, r.left]; } +// The client rects of an element's non-blank direct text nodes, one per line +// box, in document order. Both text-rect probes read the page through this: +// the union one merges them, the line one hands them over as they are. +function __textLineRects(el) { + const node = __el(el); + const rects = []; + for (const child of node.childNodes) { + if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue; + const range = document.createRange(); + range.selectNodeContents(child); + for (const rect of range.getClientRects()) { + if (rect.width >= 1 && rect.height >= 1) rects.push(rect); + } + range.detach?.(); + } + return rects; +} + const __impeccableDom = { document_element() { return __intern(document.documentElement); }, body() { return __intern(document.body); }, @@ -181,17 +199,7 @@ const __impeccableDom = { // getDirectTextRect(el) from the JS driver: union of the client rects of // the element's non-blank direct text nodes. direct_text_rect(el) { - const node = __el(el); - const rects = []; - for (const child of node.childNodes) { - if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue; - const range = document.createRange(); - range.selectNodeContents(child); - for (const rect of range.getClientRects()) { - if (rect.width >= 1 && rect.height >= 1) rects.push(rect); - } - range.detach?.(); - } + const rects = __textLineRects(el); if (rects.length === 0) return []; const left = Math.min(...rects.map(r => r.left)); const top = Math.min(...rects.map(r => r.top)); @@ -199,4 +207,13 @@ const __impeccableDom = { const bottom = Math.max(...rects.map(r => r.bottom)); return [left, top, right - left, bottom - top, top, right, bottom, left]; }, + // The same rects, unmerged: getClientRects() returns one per line box, so + // this is the element's text line by line, flattened into eights. + direct_text_line_rects(el) { + const out = []; + for (const r of __textLineRects(el)) { + out.push(r.left, r.top, r.width, r.height, r.top, r.right, r.bottom, r.left); + } + return out; + }, }; diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index 98d44b34b..1d378bd97 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -224,6 +224,7 @@ pub fn serialize_design_system_for_browser(ds: Option<&DesignSystem>) -> Value { "hasRadii": ds.has_radii, "allowedRadii": radii, "hasPillRadius": ds.has_pill_radius, + "declaredSelectors": ds.declared_selectors, }) } diff --git a/crates/core/src/browser/driver.rs b/crates/core/src/browser/driver.rs index fb37a40ef..14c42c0c9 100644 --- a/crates/core/src/browser/driver.rs +++ b/crates/core/src/browser/driver.rs @@ -78,6 +78,10 @@ pub struct DesignSeen { /// `None` when `!raw?.present`. #[derive(Debug, Clone, Default)] pub struct DesignSystemConfig { + /// Selectors the repository's design document names as its own, e.g. + /// `.eyebrow` written into DESIGN.md. A rule that would charge one of + /// these is reviewing the design system rather than the change (REN-406). + pub declared_selectors: Vec, pub has_fonts: bool, pub allowed_fonts: Vec, pub has_colors: bool, @@ -217,8 +221,15 @@ pub fn browser_design_system_config(config: &BrowserConfig) -> Option = arr("declaredSelectors") + .iter() + .map(js_string_or_empty) + .map(|s| crate::js::trim(&s).to_string()) + .filter(|s| !s.is_empty()) + .collect(); let is_true = |k: &str| matches!(obj.get(k), Some(serde_json::Value::Bool(true))); Some(DesignSystemConfig { + declared_selectors, has_fonts: is_true("hasFonts") && !allowed_fonts.is_empty(), allowed_fonts, has_colors: is_true("hasColors") && !allowed_colors.is_empty(), @@ -1318,6 +1329,11 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec let rule_ok = |id: &str| disabled.is_empty() || !disabled.iter().any(|d| d == id); let design_system = browser_design_system_config(config); let mut design_seen = DesignSeen::default(); + // The AI palette is read over the whole page: neon ink on a near-black + // ground waits here until a second tell hue turns up somewhere, so one + // deliberate accent stays an accent (REN-405). + let mut palette_tells: Vec = Vec::new(); + let mut palette_ink: Vec<(ElId, BrowserFinding)> = Vec::new(); let body = dom.body(); let root = dom.document_element(); // JS `document.body` may be null on a bare document; every @@ -1353,7 +1369,12 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec findings.extend(hits(ec::check_element_colors_dom(dom, el))); findings.extend(hits(ec::check_element_motion_dom(dom, el))); findings.extend(hits(ec::check_element_glow_dom(dom, el))); - findings.extend(hits(ec::check_element_ai_palette_dom(dom, el))); + let palette = ec::check_element_ai_palette_dom(dom, el); + palette_tells.extend(palette.tells.iter().copied()); + if let Some(ink) = palette.ink { + palette_ink.push((el, BrowserFinding::new(ink.id, ink.snippet))); + } + findings.extend(hits(palette.hits)); findings.extend(hits(ec::check_element_radial_spotlight_dom(dom, el))); findings.extend(hits(ec::check_element_icon_tile_dom(dom, el))); findings.extend(hits(ec::check_element_italic_serif_dom(dom, el))); @@ -1390,6 +1411,17 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec } } + // Two different tell hues on one page is the palette; one is an accent. + if palette_tells.iter().any(|t| *t == ec::TellHue::Cyan) + && palette_tells.iter().any(|t| *t == ec::TellHue::Purple) + { + for (el, finding) in palette_ink { + if rule_ok(&finding.type_) { + add_browser_findings(dom, &mut groups, el, vec![finding]); + } + } + } + let page_pass = |groups: &mut Vec, page_level: &mut Vec, list: Vec| { let list: Vec = list.into_iter().filter(|f| rule_ok(&f.type_)).collect(); if !list.is_empty() { @@ -1398,17 +1430,6 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec } }; - page_pass( - &mut groups, - &mut page_level, - check_browser_design_system_sources(dom, design_system.as_ref(), &mut design_seen), - ); - page_pass(&mut groups, &mut page_level, pc::check_typography(dom)); - page_pass(&mut groups, &mut page_level, hits(tc::check_kicker_above_heading_dom(dom))); - page_pass(&mut groups, &mut page_level, hits(tc::check_numbered_section_labels_dom(dom))); - page_pass(&mut groups, &mut page_level, hits(tc::check_repeated_container_text_dom(dom))); - page_pass(&mut groups, &mut page_level, hits(tc::check_em_dash_overuse_dom(dom))); - let el_pass = |groups: &mut Vec, list: Vec| { for f in list { if !rule_ok(&f.finding.type_) { @@ -1423,6 +1444,18 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec ); } }; + + page_pass( + &mut groups, + &mut page_level, + check_browser_design_system_sources(dom, design_system.as_ref(), &mut design_seen), + ); + page_pass(&mut groups, &mut page_level, pc::check_typography(dom)); + el_pass(&mut groups, tc::check_kicker_above_heading_dom(dom, design_system.as_ref())); + page_pass(&mut groups, &mut page_level, hits(tc::check_numbered_section_labels_dom(dom))); + page_pass(&mut groups, &mut page_level, hits(tc::check_repeated_container_text_dom(dom))); + page_pass(&mut groups, &mut page_level, hits(tc::check_em_dash_overuse_dom(dom))); + el_pass(&mut groups, pc::check_layout(dom)); el_pass(&mut groups, pc::check_heading_rhythm_dom(dom)); el_pass(&mut groups, pc::check_edge_flush_cards_dom(dom)); @@ -1638,6 +1671,57 @@ mod tests { assert!(!is_likely_hashed_class("abcdefg")); } + /// REN-405. Northwind's Slate system: near-black ground, light ink, one + /// teal accent. The accent lit 18 places on a page with nothing wrong with + /// it. It stays quiet until the page shows the other half of the palette. + #[test] + fn one_accent_hue_on_dark_is_not_the_ai_palette() { + let build = |gradient: bool| { + let mut d = FakeDom::new(); + let (_html, body) = d.with_page(); + d.set_style(body, "backgroundColor", "rgb(15, 18, 17)"); + d.set_rect(body, 0.0, 0.0, 1440.0, 900.0); + for i in 0..3 { + let a = d.add(Some(body), "a"); + d.add_text(a, "Open the ledger"); + d.set_rect(a, 40.0, 40.0 + 30.0 * (i as f64), 160.0, 20.0); + d.set_styles(a, &[("color", "rgb(47, 184, 166)")]); + } + if gradient { + let hero = d.add(Some(body), "div"); + d.set_rect(hero, 0.0, 200.0, 1440.0, 320.0); + d.set_style( + hero, + "backgroundImage", + "linear-gradient(135deg, rgb(124, 58, 237) 0%, rgb(168, 85, 247) 100%)", + ); + } + d + }; + let ids = |d: &FakeDom| { + collect_browser_findings(d, &BrowserConfig::default()) + .groups + .iter() + .flat_map(|g| g.findings.iter()) + .filter(|f| f.type_ == "ai-color-palette") + .map(|f| f.detail.clone()) + .collect::>() + }; + // One teal accent on near-black: an accent. + assert_eq!(ids(&build(false)), Vec::::new()); + // The same accent beside a purple gradient: the palette, and every + // place it shows is named. + assert_eq!( + ids(&build(true)), + vec![ + "Purple/violet gradient background".to_string(), + "Cyan neon text on dark background".to_string(), + "Cyan neon text on dark background".to_string(), + "Cyan neon text on dark background".to_string(), + ] + ); + } + #[test] fn skip_scan_empties_the_collect_pass() { // JS: index.mjs#skipScanActive() — an ignoreFiles-waived page answers diff --git a/crates/core/src/browser/element_checks.rs b/crates/core/src/browser/element_checks.rs index 44b23a78f..f2d135c0b 100644 --- a/crates/core/src/browser/element_checks.rs +++ b/crates/core/src/browser/element_checks.rs @@ -716,24 +716,67 @@ pub fn check_element_glow_dom(dom: &dyn Dom, el: ElId) -> Vec { }) } +/// The two hues the AI palette is built out of. A page that uses one of them +/// has an accent; a page that uses both has the palette. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TellHue { + Cyan, + Purple, +} + +impl TellHue { + /// The band a colour falls in, `None` outside both. + fn of(hue: f64) -> Option { + if (160.0..=200.0).contains(&hue) { + Some(TellHue::Cyan) + } else if (260.0..=310.0).contains(&hue) { + Some(TellHue::Purple) + } else { + None + } + } + fn label(self) -> &'static str { + match self { + TellHue::Cyan => "Cyan", + TellHue::Purple => "Purple/violet", + } + } +} + +/// What one element contributes to the AI-palette reading. +#[derive(Debug, Clone, Default)] +pub struct AiPaletteReading { + /// Charged where they are found: a saturated cyan or purple *gradient* is + /// the pattern by itself, whatever else the page does. + pub hits: Vec, + /// Neon ink on a near-black ground, held until a second tell hue shows up + /// somewhere on the page (REN-405). + pub ink: Option, + /// The tell hues this element showed, gradient and ink alike. + pub tells: Vec, +} + /// JS: checks.mjs#checkElementAIPaletteDOM(el) -pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec { - let mut findings = Vec::new(); +/// +/// One element's reading. The gradient half answers on its own; the ink half +/// is held for the page pass, because a single saturated hue on a dark ground +/// is how a great many ordinary systems draw their one accent — a teal +/// `#2fb8a6` on near-black lit 18 places on the bench's base, and every one of +/// them was the same deliberate accent (REN-405). Two different tell hues on +/// one page is the palette the rule is named for. +pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> AiPaletteReading { + let mut reading = AiPaletteReading::default(); let bg_image = dom.style(el, "backgroundImage"); for c in parse_gradient_colors(Some(&bg_image)) { if has_chroma(Some(&c), Some(50.0)) { - let hue = get_hue(Some(&c)); - if hue >= 260.0 && hue <= 310.0 { - findings.push(RuleHit::new( + if let Some(tell) = TellHue::of(get_hue(Some(&c))) { + reading.tells.push(tell); + reading.hits.push(RuleHit::new( "ai-color-palette", - "Purple/violet gradient background".to_string(), - )); - break; - } - if hue >= 160.0 && hue <= 200.0 { - findings.push(RuleHit::new( - "ai-color-palette", - "Cyan gradient background".to_string(), + match tell { + TellHue::Purple => "Purple/violet gradient background".to_string(), + TellHue::Cyan => "Cyan gradient background".to_string(), + }, )); break; } @@ -742,10 +785,7 @@ pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec { let text_color = parse_rgb_or_any(&dom.style(el, "color")); if let Some(tc) = text_color { if has_chroma(Some(&tc), Some(80.0)) { - let hue = get_hue(Some(&tc)); - let is_ai_palette = - (hue >= 160.0 && hue <= 200.0) || (hue >= 260.0 && hue <= 310.0); - if is_ai_palette { + if let Some(tell) = TellHue::of(get_hue(Some(&tc))) { let parent = dom.parent(el); let parent_bg_info = match parent { Some(p) => resolve_background_info(dom, p), @@ -760,21 +800,17 @@ pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec { } if let Some(bg) = effective_bg { if relative_luminance(&bg) < 0.1 { - let label = if hue >= 260.0 { - "Purple/violet" - } else { - "Cyan" - }; - findings.push(RuleHit::new( + reading.tells.push(tell); + reading.ink = Some(RuleHit::new( "ai-color-palette", - format!("{label} neon text on dark background"), + format!("{} neon text on dark background", tell.label()), )); } } } } } - findings + reading } // ── radial spotlight ────────────────────────────────────────────────────── @@ -1534,9 +1570,11 @@ mod tests { "linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))", ); d.set_style(hero, "color", "rgb(0, 0, 0)"); - let hits = check_element_ai_palette_dom(&d, hero); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].snippet, "Purple/violet gradient background"); + let reading = check_element_ai_palette_dom(&d, hero); + assert_eq!(reading.hits.len(), 1); + assert_eq!(reading.hits[0].snippet, "Purple/violet gradient background"); + assert!(reading.ink.is_none()); + assert_eq!(reading.tells, vec![TellHue::Purple]); } #[test] diff --git a/crates/core/src/browser/quality.rs b/crates/core/src/browser/quality.rs index 7ed7039f7..d769d9da9 100644 --- a/crates/core/src/browser/quality.rs +++ b/crates/core/src/browser/quality.rs @@ -92,7 +92,41 @@ pub fn has_meaningful_direct_text(dom: &dyn Dom, el: ElId) -> bool { has_direct_text_longer_than(dom, el, 4) } +/// The width of every line the element's own text rendered on. +/// +/// `Range.getClientRects()` gives one rect per line box, so the browser probe +/// hands the lines over as they are. A probe that can only merge them (a +/// captured snapshot) hands over the union, and the union is divided by the +/// line box to get its line count back — either way the caller reads lines +/// and never a box. +fn rendered_line_widths(dom: &dyn Dom, el: ElId, line_box: f64) -> Vec { + let mut widths: Vec = Vec::new(); + for r in dom.direct_text_line_rects(el) { + if r.width <= 0.0 || r.height <= 0.0 { + continue; + } + // Capped: a line box a stylesheet has shrunk to a fraction of the + // glyphs would otherwise turn one paragraph into thousands of lines, + // and no measure is read off a number that large anyway. + let lines = if line_box > 0.0 { + js::math_min(500.0, js::math_max(1.0, math_round(r.height / line_box))) + } else { + 1.0 + }; + for _ in 0..(lines as usize) { + widths.push(r.width); + } + } + widths +} + /// JS: checks.mjs#textDescendantsFlushSides(el, rect) → [top, right, bottom, left] +/// +/// The side is flush when the *text* lands on it, not when a text-bearing box +/// does. A `` fills its table edge to edge and insets its own text by the +/// cell padding; reading the cell's border box called that flush and charged a +/// framed table for having no inset, when the reader sees the padding the +/// cells declare (REN-403). pub fn text_descendants_flush_sides(dom: &dyn Dom, el: ElId, rect: &Rect) -> [bool; 4] { let mut flush = [false; 4]; const TEXT_EDGE_THRESHOLD: f64 = 4.0; @@ -102,7 +136,7 @@ pub fn text_descendants_flush_sides(dom: &dyn Dom, el: ElId, rect: &Rect) -> [bo if !TEXT_EDGE_TAGS.contains(&tag_name.as_str()) || !has_meaningful_direct_text(dom, node) { continue; } - let nr = dom.rect(node); + let nr = dom.direct_text_rect(node).unwrap_or_else(|| dom.rect(node)); if nr.width <= 0.0 || nr.height <= 0.0 { continue; } @@ -245,21 +279,48 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec { } // --- Line length too long --- + // + // The measure is the line that rendered, not the box that could have held + // it. `rect.width / (fontSize * 0.5)` is the box's capacity: a paragraph + // sitting in a 1022px column whose text stops at 571px was charged with + // 142 characters a line it never rendered (REN-402). What the reader sees + // is `direct_text_line_rects`, one rect per line box, and the characters + // divide between the lines in proportion to the ink each carries — one + // element's text is one font at one size, so the average advance is the + // same on every line of it. + // + // Charged when at least two rendered lines run past the maximum. The harm + // this rule names is the eye losing its place tracking back to the start + // of the next line, so it takes a column of long lines to do the damage; + // one long line and a short tail is a sentence that wrapped once. if has_direct_text && QUALITY_TEXT_TAGS.contains(&tag) && rect.width > 0.0 && (text_len as f64) > line_max { - let chars_per_line = rect.width / (font_size * 0.5); - if chars_per_line > line_max + 5.0 { - findings.push(RuleHit::new( - "line-length", - format!( - "~{} chars/line (aim for <{})", - number_to_string(math_round(chars_per_line)), - number_to_string(line_max) - ), - )); + let line_box = match q.line_height_px { + Some(px) if px > 0.0 => px, + _ => font_size * 1.2, + }; + let widths = rendered_line_widths(dom, el, line_box); + let total: f64 = widths.iter().sum(); + if total > 0.0 { + let over = line_max + 5.0; + let chars = |w: f64| (text_len as f64) * w / total; + let long = widths.iter().filter(|w| chars(**w) > over).count(); + if long >= 2 { + let longest = widths.iter().copied().fold(0.0, js::math_max); + findings.push(RuleHit::new( + "line-length", + format!( + "~{} chars on {} of {} rendered lines (aim for <{})", + number_to_string(math_round(chars(longest))), + number_to_string(long as f64), + number_to_string(widths.len() as f64), + number_to_string(line_max) + ), + )); + } } } @@ -274,31 +335,43 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec { ]; let border_count = borders.iter().filter(|w| **w > 0.0).count(); let has_bg = has_visible_background_boundary(dom, el); - if border_count >= 2 || has_bg { + // The space the reader sees, not the space the stylesheet declares: + // the inset between the rendered text and the inside of the border + // box. A 44px control with `padding: 0 16px` whose label a flex box + // centres has 12px of air above the label and was charged with "0px + // vertical padding" (REN-403). Nothing to measure the text with is + // nothing to charge on, and the text is measured only for the + // elements that got this far: the probe builds a Range per call, and + // a page has a great many boxes that are not bounded at all. + if let Some(text_rect) = (border_count >= 2 || has_bg) + .then(|| dom.direct_text_rect(el)) + .flatten() + { let mut v_pads: Vec = Vec::new(); let mut h_pads: Vec = Vec::new(); if has_bg || borders[0] > 0.0 { - v_pads.push(spx("paddingTop")); + v_pads.push(text_rect.top - (rect.top + borders[0])); } if has_bg || borders[2] > 0.0 { - v_pads.push(spx("paddingBottom")); + v_pads.push((rect.bottom - borders[2]) - text_rect.bottom); } if has_bg || borders[3] > 0.0 { - h_pads.push(spx("paddingLeft")); + h_pads.push(text_rect.left - (rect.left + borders[3])); } if has_bg || borders[1] > 0.0 { - h_pads.push(spx("paddingRight")); + h_pads.push((rect.right - borders[1]) - text_rect.right); } let v_min = v_pads.iter().copied().fold(f64::INFINITY, js::math_min); let h_min = h_pads.iter().copied().fold(f64::INFINITY, js::math_min); let v_thresh = js::math_max(4.0, font_size * 0.3); let h_thresh = js::math_max(8.0, font_size * 0.5); + let px = |v: f64| number_to_string(math_round(v * 10.0) / 10.0); if v_min < v_thresh { findings.push(RuleHit::new( "cramped-padding", format!( - "{}px vertical padding (need ≥{}px for {}px text)", - number_to_string(v_min), + "{}px of space above and below the text (need ≥{}px for {}px text)", + px(v_min), to_fixed(v_thresh, 1), number_to_string(font_size) ), @@ -307,8 +380,8 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec { findings.push(RuleHit::new( "cramped-padding", format!( - "{}px horizontal padding (need ≥{}px for {}px text)", - number_to_string(h_min), + "{}px of space beside the text (need ≥{}px for {}px text)", + px(h_min), to_fixed(h_thresh, 1), number_to_string(font_size) ), @@ -380,6 +453,13 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec { ]; const PAD_THRESHOLD: f64 = 2.0; const CHILD_INSULATE_THRESHOLD: f64 = 4.0; + // Content that runs past the box is clipped, not snug. A + // table with a min-width inside an `overflow: hidden` frame + // has its far column cut off, which is a defect + // `clipped-overflow-container` is named for; calling it "no + // inset" points at the wrong thing (REN-403). + const OVERFLOW_TOLERANCE: f64 = 1.0; + let mut children_overflow = [false; 4]; let mut children_insulate = [false; 4]; for &child in &children { let child_pad = [ @@ -396,6 +476,18 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec { ]; let cr = dom.rect(child); if cr.width > 0.0 && cr.height > 0.0 { + if rect.top - cr.top > OVERFLOW_TOLERANCE { + children_overflow[0] = true; + } + if cr.right - rect.right > OVERFLOW_TOLERANCE { + children_overflow[1] = true; + } + if cr.bottom - rect.bottom > OVERFLOW_TOLERANCE { + children_overflow[2] = true; + } + if rect.left - cr.left > OVERFLOW_TOLERANCE { + children_overflow[3] = true; + } if cr.top - rect.top >= CHILD_INSULATE_THRESHOLD { children_insulate[0] = true; } @@ -428,7 +520,12 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec { for s in 0..4 { let bg_bounds_side = bg_visible && !(full_bleed_bg_band && (s == 1 || s == 3)); let side_bounded = border_visible[s] || outline_visible || bg_bounds_side; - if side_bounded && pad[s] <= PAD_THRESHOLD && !children_insulate[s] && text_flush[s] { + if side_bounded + && pad[s] <= PAD_THRESHOLD + && !children_insulate[s] + && !children_overflow[s] + && text_flush[s] + { flush_sides.push(side_names[s]); } } @@ -766,22 +863,68 @@ mod tests { fn line_length_and_viewport_edge() { let mut d = FakeDom::new(); let (_h, body) = d.with_page(); - let long = "x".repeat(120); + let long = "x".repeat(240); let p = text_el(&mut d, body, "p", &long, "16px"); - d.set_rect(p, 0.0, 100.0, 1200.0, 40.0); + d.set_rect(p, 0.0, 100.0, 1200.0, 72.0); + // Three rendered lines: two full ones and a tail. + d.set_text_lines(p, &[(0.0, 100.0, 1180.0, 19.0), (0.0, 124.0, 1180.0, 19.0), (0.0, 148.0, 400.0, 19.0)]); let hits = check_element_quality_dom(&d, p, &BrowserConfig::default()); let ids: Vec<&str> = hits.iter().map(|h| h.id.as_str()).collect(); assert!(ids.contains(&"line-length"), "{ids:?}"); - assert_eq!(hits[0].snippet, "~150 chars/line (aim for <80)"); + assert_eq!(hits[0].snippet, "~103 chars on 2 of 3 rendered lines (aim for <80)"); assert!(ids.contains(&"body-text-viewport-edge")); let edge = hits.iter().find(|h| h.id == "body-text-viewport-edge").unwrap(); - assert_eq!(edge.snippet, "

with 120-char body bleeds to viewport edge (left 0px)"); + assert_eq!(edge.snippet, "

with 240-char body bleeds to viewport edge (left 0px)"); // narrower, inset paragraph: neither fires - d.set_rect(p, 40.0, 100.0, 600.0, 40.0); + d.set_rect(p, 40.0, 100.0, 600.0, 72.0); + d.set_text_lines(p, &[(40.0, 100.0, 580.0, 19.0), (40.0, 124.0, 580.0, 19.0), (40.0, 148.0, 580.0, 19.0)]); let hits = check_element_quality_dom(&d, p, &BrowserConfig::default()); assert!(hits.is_empty(), "{hits:?}"); } + /// REN-402. Halfday's pricing copy: a 158-character paragraph in a 1022px + /// card body, rendering 145 characters on its first line and 13 on its + /// second. The old measurement charged the box (`1022 / (15 * 0.5)` = 136 + /// "chars/line") on every paragraph that shape, including the ones whose + /// text stops well short of the box. + #[test] + fn line_length_reads_the_rendered_line_not_the_box() { + let mut d = FakeDom::new(); + let (_h, body) = d.with_page(); + let copy = "People are counted on the first of the month. Someone invited on the 3rd is free until the 1st, and someone removed mid-month is credited on the next invoice."; + assert_eq!(copy.chars().count(), 158); + let p = text_el(&mut d, body, "p", copy, "15px"); + d.set_style(p, "lineHeight", "24px"); + d.set_rect(p, 200.0, 971.0, 1022.0, 48.0); + // One long line and a 13-character tail: the eye tracks back once. + d.set_text_lines(p, &[(200.0, 974.0, 995.4, 18.0), (200.0, 998.0, 85.5, 18.0)]); + assert_eq!(check_element_quality_dom(&d, p, &BrowserConfig::default()), vec![]); + + // The same box, text that stops at 571px: 89 characters on one line. + let meta = text_el(&mut d, body, "p", &"y".repeat(89), "14px"); + d.set_style(meta, "lineHeight", "21.7px"); + d.set_rect(meta, 200.0, 122.0, 992.0, 21.7); + d.set_text_lines(meta, &[(200.0, 124.2, 571.4, 17.0)]); + assert_eq!(check_element_quality_dom(&d, meta, &BrowserConfig::default()), vec![]); + + // A column of long lines is the defect the rule is named for. + let wall = text_el(&mut d, body, "p", &"z".repeat(500), "15px"); + d.set_style(wall, "lineHeight", "24px"); + d.set_rect(wall, 200.0, 100.0, 1022.0, 96.0); + d.set_text_lines( + wall, + &[ + (200.0, 100.0, 1000.0, 18.0), + (200.0, 124.0, 1000.0, 18.0), + (200.0, 148.0, 1000.0, 18.0), + (200.0, 172.0, 600.0, 18.0), + ], + ); + let hits = check_element_quality_dom(&d, wall, &BrowserConfig::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert_eq!(hits[0].snippet, "~139 chars on 3 of 4 rendered lines (aim for <80)"); + } + #[test] fn cramped_padding_vertical() { let mut d = FakeDom::new(); @@ -803,9 +946,54 @@ mod tests { ("paddingRight", "12px"), ], ); + // The text lands 2px under the top edge, which is what the reader sees + // and what the declared padding happens to say here. + d.set_text_lines(p, &[(52.0, 102.0, 276.0, 19.0)]); let hits = check_element_quality_dom(&d, p, &BrowserConfig::default()); assert_eq!(hits.len(), 1, "{hits:?}"); - assert_eq!(hits[0].snippet, "2px vertical padding (need ≥4.8px for 16px text)"); + assert_eq!( + hits[0].snippet, + "2px of space above and below the text (need ≥4.8px for 16px text)" + ); + } + + /// REN-403. Halfday's plan button: 44px tall because the design system + /// says every control is, `padding: 0 16px`, and the label optically + /// centred by a flex box. The declared vertical padding is zero and the + /// space above the label is 12px, which is what the reader sees. + #[test] + fn cramped_padding_measures_the_space_around_the_text() { + let mut d = FakeDom::new(); + let (_h, body) = d.with_page(); + d.set_style(body, "backgroundColor", "rgb(255, 255, 255)"); + let btn = text_el(&mut d, body, "a", "Talk to us about Studio", "15px"); + d.set_rect(btn, 0.0, 778.7, 296.7, 44.0); + d.set_styles( + btn, + &[ + ("backgroundColor", "rgb(255, 255, 255)"), + ("borderTopWidth", "1px"), + ("borderRightWidth", "1px"), + ("borderBottomWidth", "1px"), + ("borderLeftWidth", "1px"), + ("paddingTop", "0px"), + ("paddingBottom", "0px"), + ("paddingLeft", "16px"), + ("paddingRight", "16px"), + ("display", "flex"), + ], + ); + d.set_text_lines(btn, &[(68.0, 791.2, 160.0, 18.0)]); + assert_eq!(check_element_quality_dom(&d, btn, &BrowserConfig::default()), vec![]); + + // The same control with the label actually against the edge: charged. + d.set_text_lines(btn, &[(68.0, 780.2, 160.0, 18.0)]); + let hits = check_element_quality_dom(&d, btn, &BrowserConfig::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert_eq!( + hits[0].snippet, + "0.5px of space above and below the text (need ≥4.5px for 15px text)" + ); } #[test] @@ -847,6 +1035,78 @@ mod tests { ); } + /// REN-403, the wrapper half of the same rule. Crewline's framed table: + /// the `` fills the frame edge to edge, and every cell insets its + /// own text by the padding the stylesheet gives it. Reading the cell's + /// border box called all four sides flush. + #[test] + fn flush_reads_the_text_not_the_cell_that_holds_it() { + let mut d = FakeDom::new(); + let (_h, body) = d.with_page(); + d.set_style(body, "backgroundColor", "rgb(255, 255, 255)"); + let frame = d.add(Some(body), "div"); + d.set_attr(frame, "class", "table-frame"); + d.set_rect(frame, 0.0, 0.0, 860.0, 300.0); + d.set_styles( + frame, + &[ + ("position", "static"), + ("borderTopWidth", "1px"), + ("borderRightWidth", "1px"), + ("borderBottomWidth", "1px"), + ("borderLeftWidth", "1px"), + ("borderTopColor", "rgb(220, 220, 220)"), + ("borderRightColor", "rgb(220, 220, 220)"), + ("borderBottomColor", "rgb(220, 220, 220)"), + ("borderLeftColor", "rgb(220, 220, 220)"), + ("outlineWidth", "0px"), + ("backgroundColor", "rgb(250, 250, 250)"), + ("paddingTop", "0px"), + ("paddingRight", "0px"), + ("paddingBottom", "0px"), + ("paddingLeft", "0px"), + ("fontSize", "15px"), + ], + ); + let table = d.add(Some(frame), "table"); + d.set_rect(table, 0.0, 0.0, 860.0, 300.0); + for (i, (x, y, w)) in [(0.0, 0.0, 430.0), (430.0, 0.0, 430.0), (0.0, 260.0, 430.0)] + .into_iter() + .enumerate() + { + let cell = d.add(Some(table), "td"); + d.add_text(cell, "Wednesday afternoon"); + d.set_rect(cell, x, y, w, 20.0); + // Cell padding: 10px down, 16px across, which is where the text is. + d.set_text_lines(cell, &[(x + 16.0, y + 10.0, w - 32.0, 17.0)]); + let _ = i; + } + assert_eq!(check_element_quality_dom(&d, frame, &BrowserConfig::default()), vec![]); + + // A cell that really does put its text on the frame line is charged. + let tight = d.add(Some(table), "td"); + d.add_text(tight, "Wednesday afternoon"); + d.set_rect(tight, 0.0, 140.0, 860.0, 20.0); + d.set_text_lines(tight, &[(1.0, 140.0, 858.0, 17.0)]); + let hits = check_element_quality_dom(&d, frame, &BrowserConfig::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert_eq!( + hits[0].snippet, + "
\"table-frame\": children flush against border+bg on right/left (no inset)" + ); + + // The same frame at 390px, where the table keeps its min-width and the + // frame hides what does not fit: the right side is clipped, not snug, + // and that is `clipped-overflow-container`'s business (REN-403). + d.set_rect(table, 0.0, 0.0, 1400.0, 300.0); + let hits = check_element_quality_dom(&d, frame, &BrowserConfig::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert_eq!( + hits[0].snippet, + "
\"table-frame\": children flush against border+bg on left (no inset)" + ); + } + #[test] fn typography_rules() { let mut d = FakeDom::new(); diff --git a/crates/core/src/browser/text_collectors.rs b/crates/core/src/browser/text_collectors.rs index beff306f4..d039fdc7a 100644 --- a/crates/core/src/browser/text_collectors.rs +++ b/crates/core/src/browser/text_collectors.rs @@ -5,8 +5,10 @@ //! `checkRepeatedContainerTextDOM`) against the [`Dom`] probe. The pure //! gates live in `checks::rules` / `checks::text_rules`. -use super::dom::{tag_lower, Dom, ElId, ElStyle}; +use super::dom::{matches_or_false, tag_lower, Dom, ElId, ElStyle}; +use super::driver::DesignSystemConfig; use super::element_checks::{class_selector, is_rendered_for_browser_rule}; +use super::{BrowserFinding, ElFinding}; use crate::checks::measures::resolve_length_px; use crate::checks::rules::{check_kicker_above_heading, KickerCandidate, RuleHit}; use crate::checks::text_rules::{ @@ -99,6 +101,17 @@ fn strip_edge_quotes_slice(text: &str, n: usize) -> String { /// JS: checks.mjs#collectKickerCandidates(document, getComputedStyle, resolveLengthPx || 0) pub fn collect_kicker_candidates(dom: &dyn Dom) -> Vec { + collect_kicker_candidates_with_elements(dom) + .into_iter() + .map(|(_, c)| c) + .collect() +} + +/// The same walk, each candidate paired with the eyebrow element it came +/// from. The finding is about that element and belongs on it: reported +/// against the page it named `body`, and a charged row has to have something +/// to point at (REN-406). +pub fn collect_kicker_candidates_with_elements(dom: &dyn Dom) -> Vec<(ElId, KickerCandidate)> { let mut candidates = Vec::new(); for heading in dom .query_all(None, "h1, h2, h3, h4, [role=\"heading\"]") @@ -164,18 +177,60 @@ pub fn collect_kicker_candidates(dom: &dyn Dom) -> Vec { if heading_tag == "h1" && heading_font_size >= 48.0 && kicker_letter_spacing >= 1.6 { continue; } - candidates.push(KickerCandidate { - heading_tag, - heading_text: strip_edge_quotes_slice(&heading_text, 60), - kicker_text: slice_utf16_prefix(&kicker_text, 40), - }); + candidates.push(( + kicker, + KickerCandidate { + heading_tag, + heading_text: strip_edge_quotes_slice(&heading_text, 60), + kicker_text: slice_utf16_prefix(&kicker_text, 40), + }, + )); } candidates } /// JS: checks.mjs#checkKickerAboveHeadingDOM() -pub fn check_kicker_above_heading_dom(dom: &dyn Dom) -> Vec { - check_kicker_above_heading(&collect_kicker_candidates(dom)) +/// +/// Two things the page-level version could not do. The finding lands on the +/// eyebrow it is about rather than on `body`. And an eyebrow the repository's +/// own design document names — `.eyebrow`, written into DESIGN.md as the one +/// place caps are allowed — is that repository's vocabulary, not slop: a +/// pattern the author's contract declares by name is a component with rules, +/// and charging it reviews the design system instead of the change (REN-406). +pub fn check_kicker_above_heading_dom( + dom: &dyn Dom, + design_system: Option<&DesignSystemConfig>, +) -> Vec { + let pairs: Vec<(ElId, KickerCandidate)> = collect_kicker_candidates_with_elements(dom) + .into_iter() + .filter(|(el, _)| !is_declared_component(dom, *el, design_system)) + .collect(); + let (els, candidates): (Vec, Vec) = pairs.into_iter().unzip(); + check_kicker_above_heading(&candidates) + .into_iter() + .zip(els) + .map(|(hit, el)| ElFinding { + el: Some(el), + finding: BrowserFinding::new(hit.id, hit.snippet), + }) + .collect() +} + +/// Whether the repository's design document declares this element by name. +/// +/// The selectors come from the DESIGN.md the review already parses, through +/// the same design-system config the colour and radius rules read. +pub fn is_declared_component( + dom: &dyn Dom, + el: ElId, + design_system: Option<&DesignSystemConfig>, +) -> bool { + let Some(ds) = design_system else { + return false; + }; + ds.declared_selectors + .iter() + .any(|sel| matches_or_false(dom, el, sel)) } /// JS: checks.mjs#collectNumberedSectionLabelCandidates(document, ...) @@ -447,13 +502,29 @@ mod tests { let h = d.add(Some(sec), "h2"); d.add_text(h, "Everything you need"); d.set_style(h, "fontSize", "32px"); - let hits = check_kicker_above_heading_dom(&d); + let hits = check_kicker_above_heading_dom(&d, None); assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "kicker-above-heading"); + assert_eq!(hits[0].finding.type_, "kicker-above-heading"); assert_eq!( - hits[0].snippet, + hits[0].finding.detail, "kicker \"Features\" above h2 \"Everything you need\"" ); + // The finding names the eyebrow, not the page (REN-406). + assert_eq!(hits[0].el, Some(kicker)); + + // An eyebrow the repository's DESIGN.md declares by name stands down. + d.add_selector(kicker, ".eyebrow"); + let ds = DesignSystemConfig { + declared_selectors: vec![".eyebrow".to_string()], + ..Default::default() + }; + assert!(check_kicker_above_heading_dom(&d, Some(&ds)).is_empty()); + // A selector the document does not name leaves it charged. + let other = DesignSystemConfig { + declared_selectors: vec![".kicker".to_string()], + ..Default::default() + }; + assert_eq!(check_kicker_above_heading_dom(&d, Some(&other)).len(), 1); // A card context (heading inside
that also contains the // kicker) stands down. let art = d.add(Some(body), "article"); @@ -463,7 +534,7 @@ mod tests { let h2 = d.add(Some(art), "h3"); d.add_text(h2, "Card heading"); d.set_style(h2, "fontSize", "24px"); - assert_eq!(check_kicker_above_heading_dom(&d).len(), 1); + assert_eq!(check_kicker_above_heading_dom(&d, None).len(), 1); } #[test] diff --git a/crates/core/src/checks/rules.rs b/crates/core/src/checks/rules.rs index 4c7a65f94..b8a5bf81f 100644 --- a/crates/core/src/checks/rules.rs +++ b/crates/core/src/checks/rules.rs @@ -4,8 +4,8 @@ //! `undefined` / `null` distinctions the source relies on. use crate::color::{ - color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_neutral_color, - relative_luminance, Rgba, + color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_gray_ink, + is_neutral_color, relative_luminance, Rgba, }; use crate::constants::{ BORDER_SAFE_TAGS, GENERIC_FONTS, KNOWN_SERIF_FONTS, SAFE_TAGS, WCAG_LARGE_BOLD_TEXT_PX, @@ -231,9 +231,12 @@ fn contrast_findings(opts: &ColorOpts, text_color: &Rgba) -> Vec { } }; let mut findings = Vec::new(); - let text_lum = relative_luminance(text_color); - let is_gray = !has_chroma(Some(text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85; - if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) { + // Gray is low chroma at whatever lightness the ink sits at, and the + // surface is a colour when it has chroma of its own. The old pair of + // tests read relative luminance as if it were lightness, which made every + // off-white under 0.85 gray and charged an off-white nav on a teal + // masthead three times over (REN-404). + if is_gray_ink(text_color) && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) { let bg_label = match opts.effective_bg { Some(bg) => color_to_hex(Some(&bg)), None => format!( @@ -1093,6 +1096,38 @@ mod tests { ); } + /// REN-404. The bench's masthead: `#e8edf2` nav links on `#123a36`. The + /// ink is an off-white with a cool tint, not gray, and the pairing clears + /// contrast; the old test called everything under 0.85 relative luminance + /// gray and charged it three times over on a page with nothing wrong. + #[test] + fn off_white_on_a_colour_is_not_gray_ink() { + let ink = |hex_r: f64, hex_g: f64, hex_b: f64| { + check_colors(&ColorOpts { + tag: "p".to_string(), + font_size: 15.0, + font_weight: 400.0, + has_direct_text: true, + text_color: Some(Rgba::new(hex_r, hex_g, hex_b, 1.0)), + effective_bg: Some(Rgba::new(18.0, 58.0, 54.0, 1.0)), + ..Default::default() + }) + .into_iter() + .map(|h| h.id) + .collect::>() + }; + // #e8edf2 on #123a36. + assert_eq!(ink(232.0, 237.0, 242.0), Vec::::new()); + // White, the other neutral ink a coloured surface carries. + assert_eq!(ink(255.0, 255.0, 255.0), Vec::::new()); + // #8a8f8c: the muddy middle, still charged, and the contrast check + // beside it is untouched. + assert_eq!( + ink(138.0, 143.0, 140.0), + vec!["gray-on-color".to_string(), "low-contrast".to_string()] + ); + } + #[test] fn placeholder_colors_ignore_host_class_heuristics() { let opts = ColorOpts { diff --git a/crates/detect/src/design_system.rs b/crates/detect/src/design_system.rs index c87a87d12..b9e4948bb 100644 --- a/crates/detect/src/design_system.rs +++ b/crates/detect/src/design_system.rs @@ -25,6 +25,7 @@ use crate::jsp; use crate::util::{exists, js_string, re, read_json, read_text, ANY, WS}; const DESIGN_NAMES: &[&str] = &["DESIGN.md", "Design.md", "design.md"]; + const FALLBACK_DIRS: &[&str] = &[".agents/context", "docs"]; const PROJECT_ROOT_MARKERS: &[&str] = &[".git", "package.json", ".impeccable"]; const COLOR_CHANNEL_TOLERANCE: f64 = 6.0; @@ -37,6 +38,11 @@ pub const STATIC_DESIGN_SKIP_TAGS: &[&str] = &[ "head", "title", "meta", "link", "style", "script", "noscript", "template", "source", ]; +re!(DESIGN_BACKTICKED, "`([^`\n]{1,80})`".to_string()); +re!( + DESIGN_CLASS_SELECTOR, + "^\\.[A-Za-z_][A-Za-z0-9_-]*$".to_string() +); re!( FONT_SIZE_LITERAL_RE, format!("^-?[{D}.]+(?:px|rem)$", D = "0-9") @@ -164,6 +170,33 @@ re!(LEADING_WS_RE, format!("^{WS}*")); /// JS: design-system.mjs#parseFrontmatter. `None` when there is no /// `---` block; otherwise the parsed object (possibly empty). +/// Class selectors the design document names as its own, in the order it +/// names them. +/// +/// A design document writes a component in backticks — "`No ALL CAPS outside +/// the `.eyebrow` class`" — and that is the repository declaring a pattern by +/// name. A rule that fires on one of those is reviewing the design system +/// rather than the change, so the browser rules read this list and stand +/// down (REN-406). Only a plain class selector counts: a backticked file +/// name, property or hex is not a component. +pub fn declared_component_selectors(design_md: &str) -> Vec { + let mut out: Vec = Vec::new(); + for cap in DESIGN_BACKTICKED.captures_iter(design_md) { + let token = js::trim(cap.get(1).map(|m| m.as_str()).unwrap_or("")); + if !DESIGN_CLASS_SELECTOR.is_match(token) { + continue; + } + let token = token.to_string(); + if !out.contains(&token) { + out.push(token); + } + if out.len() >= 64 { + break; + } + } + out +} + pub fn parse_frontmatter(md: &str) -> Option> { let lines: Vec<&str> = CRLF_RE.split(md).collect(); if js::trim(lines.first().copied().unwrap_or("")) != "---" { @@ -534,6 +567,10 @@ pub struct AllowedFontSize { #[derive(Debug, Clone, PartialEq, Default)] pub struct DesignSystem { pub present: bool, + /// Class selectors the document names as its own (REN-406). Filled where + /// the markdown itself is at hand; the allowlists come from frontmatter + /// and sidecar, this comes from the prose. + pub declared_selectors: Vec, pub source_path: Option, pub sidecar_path: Option, pub md_newer_than_json: bool, @@ -877,13 +914,15 @@ pub fn load_design_system_for_cwd(cwd: &str) -> Option { let sidecar = sidecar_path.as_deref().and_then(read_json); let sidecar_stat = sidecar_path.as_deref().and_then(mtime_ms); let md_newer = matches!((md_stat, sidecar_stat), (Some(m), Some(s)) if m > s + 1000.0); - Some(normalize_design_system( + let mut ds = normalize_design_system( Some(&frontmatter), sidecar.as_ref(), Some(&md.path), sidecar_path.as_deref(), md_newer, - )) + ); + ds.declared_selectors = declared_component_selectors(&text); + Some(ds) } /// JS `designSystemStartDir(targetPath, cwd)`. @@ -1934,6 +1973,23 @@ fn finding_ignore_or_value_only(item: &Finding) -> String { mod tests { use super::*; + /// REN-406: the halfday design document's one rule about caps. + #[test] + fn declared_component_selectors_reads_the_document() { + let md = "# Halfday design system\n\n- Plain British English, sentence case everywhere.\n No Title Case, no ALL CAPS outside the `.eyebrow` class.\n- `styles/tokens.css` is the only file with a raw colour, `--ink-900` or `#0f172a`.\n- **Button.** Four kinds and no more: `.btn-primary`, `.btn-secondary`,\n `.btn-ghost`, `.btn-danger`. And `.btn-primary` again.\n"; + assert_eq!( + declared_component_selectors(md), + vec![ + ".eyebrow", + ".btn-primary", + ".btn-secondary", + ".btn-ghost", + ".btn-danger" + ] + ); + assert!(declared_component_selectors("nothing to declare").is_empty()); + } + // ── #570 monorepo DESIGN.md inheritance ───────────────────────────────── // Mirrors tests/detect-cli-design-monorepo.test.mjs (public repo main, // 47e41195 + 5d7c1cce + e975bec4 + 91f2c7b4) at the findDesignRoot level. diff --git a/crates/foundation/src/browser/dom.rs b/crates/foundation/src/browser/dom.rs index 511f5537a..f34e058eb 100644 --- a/crates/foundation/src/browser/dom.rs +++ b/crates/foundation/src/browser/dom.rs @@ -168,6 +168,17 @@ pub trait Dom { /// of every non-blank direct text node (rects narrower/shorter than 1px /// dropped); `None` when there is none. fn direct_text_rect(&self, el: ElId) -> Option; + /// The same client rects, unmerged: one per line the text actually + /// rendered on (`Range.getClientRects()` returns a rect per line box), + /// in document order. + /// + /// This is how a rule reads a line rather than the box that holds it. The + /// default answers the union as a single rect, which is what a probe that + /// cannot split a wrapped run has; a caller that needs the count divides + /// the rect by the line box rather than assuming one line. + fn direct_text_line_rects(&self, el: ElId) -> Vec { + self.direct_text_rect(el).into_iter().collect() + } } // ── shared helpers over the trait ───────────────────────────────────────── diff --git a/crates/foundation/src/browser/fake_dom.rs b/crates/foundation/src/browser/fake_dom.rs index a09654fcd..fbf8ea275 100644 --- a/crates/foundation/src/browser/fake_dom.rs +++ b/crates/foundation/src/browser/fake_dom.rs @@ -35,6 +35,8 @@ pub struct FakeEl { pub hidden: bool, pub check_visibility: Option, pub direct_text_rect: Option, + /// The per-line rects of the direct text; empty falls back to the union. + pub direct_text_line_rects: Vec, /// Selectors (exact strings) this element matches beyond `*` and its tag. pub selectors: Vec, /// `id` IDL property override (`None` = "not a string", falls back to attr). @@ -158,6 +160,28 @@ impl FakeDom { self.el_mut(id).rect = Rect::from_xywh(x, y, w, h); self } + /// The union rect of `id`'s direct text, as `getClientRects()` would give it. + pub fn set_text_rect(&mut self, id: ElId, x: f64, y: f64, w: f64, h: f64) -> &mut Self { + self.el_mut(id).direct_text_rect = Some(Rect::from_xywh(x, y, w, h)); + self + } + /// The rects of `id`'s direct text, one per rendered line. The union is + /// derived from them, so a test declares the lines and nothing else. + pub fn set_text_lines(&mut self, id: ElId, lines: &[(f64, f64, f64, f64)]) -> &mut Self { + let rects: Vec = lines + .iter() + .map(|&(x, y, w, h)| Rect::from_xywh(x, y, w, h)) + .collect(); + if !rects.is_empty() { + let left = rects.iter().map(|r| r.left).fold(f64::INFINITY, f64::min); + let top = rects.iter().map(|r| r.top).fold(f64::INFINITY, f64::min); + let right = rects.iter().map(|r| r.right).fold(f64::NEG_INFINITY, f64::max); + let bottom = rects.iter().map(|r| r.bottom).fold(f64::NEG_INFINITY, f64::max); + self.el_mut(id).direct_text_rect = Some(Rect::from_xywh(left, top, right - left, bottom - top)); + } + self.el_mut(id).direct_text_line_rects = rects; + self + } pub fn add_text(&mut self, id: ElId, text: &str) -> &mut Self { self.el_mut(id) .child_nodes @@ -503,4 +527,12 @@ impl Dom for FakeDom { fn direct_text_rect(&self, el: ElId) -> Option { self.els[el as usize].direct_text_rect } + fn direct_text_line_rects(&self, el: ElId) -> Vec { + let lines = &self.els[el as usize].direct_text_line_rects; + if lines.is_empty() { + self.direct_text_rect(el).into_iter().collect() + } else { + lines.clone() + } + } } diff --git a/crates/foundation/src/color.rs b/crates/foundation/src/color.rs index c1700d893..1e7f7625f 100644 --- a/crates/foundation/src/color.rs +++ b/crates/foundation/src/color.rs @@ -320,6 +320,40 @@ pub fn has_chroma(c: Option<&Rgba>, threshold: Option) -> bool { (math_max3(c.r, c.g, c.b) - math_min3(c.r, c.g, c.b)) >= threshold } +/// A colour's lightness and saturation, HSL, both 0..1. +/// +/// Saturation is chroma measured the same way at every lightness: the spread +/// between the channels over the widest spread a colour of that lightness +/// could have. The raw spread cannot say the same thing, because it shrinks +/// towards white and towards black — `#e8edf2` spreads 10 of 255 and is a +/// quarter of the way to fully saturated at its lightness, which is why it +/// reads as a cool off-white and not as gray (REN-404). +pub fn lightness_saturation(c: &Rgba) -> (f64, f64) { + let max = math_max3(c.r, c.g, c.b) / 255.0; + let min = math_min3(c.r, c.g, c.b) / 255.0; + let l = (max + min) / 2.0; + let d = max - min; + let s = if d <= 0.0 || l <= 0.0 || l >= 1.0 { + 0.0 + } else { + d / (1.0 - (2.0 * l - 1.0).abs()) + }; + (l, s) +} + +/// Whether a colour reads as gray ink. +/// +/// Two things at once: little chroma for the lightness it sits at, and a +/// lightness that is neither of the two neutral inks a coloured surface is +/// meant to carry. Near-white and near-black on a colour are deliberate; the +/// muddy middle is what this names. Relative luminance is not lightness and +/// cannot stand in for it: `#e8edf2` measures 0.84 there, under the old 0.85 +/// ceiling, and 0.93 as lightness, which is where the eye puts it. +pub fn is_gray_ink(c: &Rgba) -> bool { + let (l, s) = lightness_saturation(c); + s < 0.2 && l > 0.2 && l < 0.85 +} + /// JS `getHue(c)`. pub fn get_hue(c: Option<&Rgba>) -> f64 { let Some(c) = c else { return 0.0 }; diff --git a/crates/foundation/src/registry.rs b/crates/foundation/src/registry.rs index d9247037c..91e1530ab 100644 --- a/crates/foundation/src/registry.rs +++ b/crates/foundation/src/registry.rs @@ -86,7 +86,7 @@ pub static ANTIPATTERNS: &[Antipattern] = &[ scopes: None, severity: None, name: "AI color palette", - description: "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.", + description: "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.", skill_section: Some("Color & Contrast"), skill_guideline: Some("AI color palette"), }, @@ -416,7 +416,7 @@ pub static ANTIPATTERNS: &[Antipattern] = &[ scopes: Some(&["type", "layout"]), severity: None, name: "Line length too long", - description: "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.", + description: "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line, so it is measured on the lines that rendered and charged when more than one of them runs long. Add a max-width (65ch to 75ch) to text containers.", skill_section: Some("Layout & Space"), skill_guideline: Some("wrap beyond ~80 characters"), }, @@ -426,7 +426,7 @@ pub static ANTIPATTERNS: &[Antipattern] = &[ scopes: Some(&["layout"]), severity: None, name: "Cramped padding", - description: "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.", + description: "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 12–16px) of space inside bordered, outlined, or colored containers.", skill_section: Some("Layout & Space"), skill_guideline: Some("inside bordered or colored containers"), }, diff --git a/crates/wasm/src/js_dom.rs b/crates/wasm/src/js_dom.rs index 2571ab23a..99e51a0fe 100644 --- a/crates/wasm/src/js_dom.rs +++ b/crates/wasm/src/js_dom.rs @@ -58,6 +58,7 @@ extern "C" { fn offset_height(el: u32) -> f64; fn check_visibility(el: u32) -> i32; fn direct_text_rect(el: u32) -> Vec; + fn direct_text_line_rects(el: u32) -> Vec; } fn opt(id: u32) -> Option { @@ -340,4 +341,12 @@ impl Dom for JsDom { Some(to_rect(&v)) } } + /// The probe flattens the per-line rects into one array of eights, in the + /// order `rect` uses; a tail shorter than a rect is ignored. + fn direct_text_line_rects(&self, el: ElId) -> Vec { + direct_text_line_rects(el) + .chunks_exact(8) + .map(to_rect) + .collect() + } } diff --git a/tests/oracle/golden/detect-fixture-json-clipped-overflow-container-html.json b/tests/oracle/golden/detect-fixture-json-clipped-overflow-container-html.json index 62fded2b5..732c611f1 100644 --- a/tests/oracle/golden/detect-fixture-json-clipped-overflow-container-html.json +++ b/tests/oracle/golden/detect-fixture-json-clipped-overflow-container-html.json @@ -1,5 +1,5 @@ { - "stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"
\\\"pass-split-container\\\": children flush against border on all sides (no inset)\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-hidden clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-clip clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-negative clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-right clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-shadow-utility clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overlay-surface clips a positioned child\"\n }\n]\n", + "stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 12–16px) of space inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"
\\\"pass-split-container\\\": children flush against border on all sides (no inset)\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-hidden clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-clip clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-negative clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-right clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-shadow-utility clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overlay-surface clips a positioned child\"\n }\n]\n", "stderr": "", "exit": 2, "signal": null, diff --git a/tests/oracle/golden/detect-fixture-json-color-html.json b/tests/oracle/golden/detect-fixture-json-color-html.json index 1d61a2470..98af1aa75 100644 --- a/tests/oracle/golden/detect-fixture-json-color-html.json +++ b/tests/oracle/golden/detect-fixture-json-color-html.json @@ -1,5 +1,5 @@ { - "stdout": "[\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #969696 on bg #3b82f6\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #969696 on #3b82f6\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #b4b4b4 on bg #10b981\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #b4b4b4 on #10b981\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.7:1 (need 4.5:1) — text #c8c8c8 on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.1:1 (need 4.5:1) — text #505050 on #1e1e1e\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #808080 on bg gradient(#3b82f6, #8b5cf6)\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.1:1 (need 3:1) — text #808080 on #8b5cf6\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #666666 on bg gradient(#3b82f6, #8b5cf6)\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.4:1 (need 4.5:1) — text #666666 on #8b5cf6\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.2:1 (need 4.5:1) — text #5b4f44 on #1f1a15\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.1:1 (need 4.5:1) — text #6c7280 on #374151\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #5c5449 on bg #b6322d\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #5c5449 on #b6322d\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text-gray-400 on bg-blue-500\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #9ca3af on bg #3b82f6\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.4:1 (need 4.5:1) — text #9ca3af on #3b82f6\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#a855f7) on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text-purple-500 on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet gradient (Tailwind)\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"4.0:1 (need 4.5:1) — text #ffffff on #a855f7\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.1:1 (need 4.5:1) — text #3d2418 on #17372d\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.3:1 (need 4.5:1) — text #cfc9bd on #e8e2d6\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"4.1:1 (need 4.5:1) — text #ffffff on #7d7d7d\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"8px functional text \\\"tick\\\" (below 11px floor)\"\n },\n {\n \"antipattern\": \"skipped-heading\",\n \"name\": \"Skipped heading level\",\n \"description\": \"Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"

\\\"Welcome to Our Platform\\\" followed by

\\\"Gradient text\\\" (missing h2)\"\n },\n {\n \"antipattern\": \"skipped-heading\",\n \"name\": \"Skipped heading level\",\n \"description\": \"Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"

\\\"Purple heading text\\\" followed by

\\\"Styled and