From d4d90aa9f04c7e2e99c3daec909adaba415551bc Mon Sep 17 00:00:00 2001 From: mager Date: Mon, 19 Oct 2026 08:47:43 -0500 Subject: [PATCH 1/4] Fix static HTML scans mixing inactive media styles Apply the screen/light/1280x800 policy approved in #988 before the static cascade and CSS checks. Add regressions and review the intended CLI golden changes without changing frozen vectors. AI assistance: Prepared with OpenAI Codex under Mager's direction. --- crates/html/src/cascade/build.rs | 17 +- crates/html/src/cascade/media.rs | 343 ++++++++++++++++++ crates/html/src/cascade/mod.rs | 1 + crates/html/tests/link_text_contrast.rs | 9 +- crates/html/tests/media_environment.rs | 141 +++++++ docs/CLI-CONTRACT.md | 21 ++ .../static-media-environment.html | 73 ++++ tests/oracle/DELTAS.md | 14 + .../golden/detect-dir-json-all-fixtures.json | 2 +- .../golden/detect-dir-quiet-all-fixtures.json | 2 +- .../golden/detect-dir-text-all-fixtures.json | 2 +- ...fixture-json-linked-url-patterns-html.json | 2 +- .../detect-fixture-json-pulsing-dot-html.json | 2 +- ...re-json-static-media-environment-html.json | 7 + ...fixture-text-linked-url-patterns-html.json | 2 +- .../detect-fixture-text-pulsing-dot-html.json | 2 +- ...re-text-static-media-environment-html.json | 7 + .../golden/detect-no-advisory-json.json | 2 +- .../golden/detect-no-advisory-text.json | 2 +- 19 files changed, 634 insertions(+), 17 deletions(-) create mode 100644 crates/html/src/cascade/media.rs create mode 100644 crates/html/tests/media_environment.rs create mode 100644 tests/fixtures/antipatterns/static-media-environment.html create mode 100644 tests/oracle/golden/detect-fixture-json-static-media-environment-html.json create mode 100644 tests/oracle/golden/detect-fixture-text-static-media-environment-html.json diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index a49c12e837..0731e77fad 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -101,10 +101,21 @@ pub fn collect_static_css_text( let mut warned_missing_stylesheets: std::collections::HashSet = std::collections::HashSet::new(); for style_el in doc.query_selector_all("style") { - style_texts.push(style_el.text_content()); + if style_el + .get_attribute("media") + .is_none_or(|m| m.trim().is_empty() || super::media::matches_media(m)) + { + style_texts.push(style_el.text_content()); + } } let file_dir_str = file_dir.to_string_lossy().into_owned(); for link in doc.query_selector_all("link") { + if link + .get_attribute("media") + .is_some_and(|m| !m.trim().is_empty() && !super::media::matches_media(m)) + { + continue; + } let rel = link.get_attribute("rel").unwrap_or(""); let href = link.get_attribute("href").unwrap_or(""); if !STYLESHEET_REL_RE.is_match(rel) || href.is_empty() || REMOTE_HREF_RE.is_match(href) { @@ -129,7 +140,7 @@ pub fn collect_static_css_text( } } } - style_texts.join("\n") + super::media::screen_styles(&style_texts.join("\n")) } static PSEUDO_RULE_RE: Lazy = Lazy::new(|| { @@ -292,6 +303,8 @@ pub fn build_static_style_map( profile: Option<&dyn ProfileSink>, file_path: &str, ) { + let screen_css = super::media::screen_styles(css_text); + let css_text = screen_css.as_str(); let mut specified: SpecifiedStore = SpecifiedStore::new(); let mut hover_specified: SpecifiedStore = SpecifiedStore::new(); let mut placeholder_specified: SpecifiedStore = SpecifiedStore::new(); diff --git a/crates/html/src/cascade/media.rs b/crates/html/src/cascade/media.rs new file mode 100644 index 0000000000..ae8b04ca0b --- /dev/null +++ b/crates/html/src/cascade/media.rs @@ -0,0 +1,343 @@ +//! Static scans use the default rendered environment: screen, light, 1280×800. +//! Unknown queries never contribute styles, including when negated. Filtering +//! keeps byte offsets/newlines and leaves the frozen rule collector unchanged. +use cssparser::{Delimiter, Parser, ParserInput, Token}; +use std::ops::Range; + +const WIDTH: f64 = 1280.0; +const HEIGHT: f64 = 800.0; + +#[derive(Debug)] +enum Term { + Ident(String), + Length(f64), + Mark(char), + Group(Vec), + Unknown, +} + +fn terms(input: &mut Parser<'_, '_>, depth: usize) -> Vec { + let mut out = Vec::new(); + while let Ok(token) = input.next().cloned() { + out.push(match token { + Token::Ident(s) => Term::Ident(s.to_ascii_lowercase()), + Token::Number { value, .. } if value == 0.0 => Term::Length(0.0), + Token::Dimension { value, unit, .. } => { + let scale = match unit.to_ascii_lowercase().as_str() { + "px" => 1.0, + // Media-query font units use the initial font size. + "em" | "rem" => 16.0, + _ => { + out.push(Term::Unknown); + continue; + } + }; + Term::Length(f64::from(value) * scale) + } + Token::Colon => Term::Mark(':'), + Token::Delim(c) => Term::Mark(c), + Token::ParenthesisBlock if depth < 32 => { + let nested: Result<_, cssparser::ParseError<'_, ()>> = + input.parse_nested_block(|p| Ok(terms(p, depth + 1))); + match nested { + Ok(t) => Term::Group(t), + Err(_) => Term::Unknown, + } + } + _ => Term::Unknown, + }); + } + out +} + +fn ident(t: &Term, name: &str) -> bool { + matches!(t, Term::Ident(s) if s == name) +} + +fn dimension(t: &Term) -> Option { + match t { + Term::Ident(s) if s == "width" => Some(WIDTH), + Term::Ident(s) if s == "height" => Some(HEIGHT), + Term::Length(n) if n.is_finite() && *n >= 0.0 => Some(*n), + _ => None, + } +} + +fn comparison(left: f64, op: &[Term], right: f64) -> Option { + match op { + [Term::Mark('=')] => Some(left == right), + [Term::Mark('<')] => Some(left < right), + [Term::Mark('>')] => Some(left > right), + [Term::Mark('<'), Term::Mark('=')] => Some(left <= right), + [Term::Mark('>'), Term::Mark('=')] => Some(left >= right), + _ => None, + } +} + +fn feature(t: &[Term]) -> Option { + match t { + [Term::Ident(name)] if name == "width" || name == "height" => Some(true), + [Term::Ident(name), Term::Mark(':'), Term::Ident(value)] => match name.as_str() { + "prefers-color-scheme" => match value.as_str() { + "light" => Some(true), + "dark" => Some(false), + _ => None, + }, + "orientation" => match value.as_str() { + "landscape" => Some(true), + "portrait" => Some(false), + _ => None, + }, + _ => None, + }, + [Term::Ident(name), Term::Mark(':'), value @ Term::Length(_)] => { + let (kind, key) = if let Some(s) = name.strip_prefix("min-") { + (1, s) + } else if let Some(s) = name.strip_prefix("max-") { + (-1, s) + } else { + (0, name.as_str()) + }; + let actual = match key { + "width" => WIDTH, + "height" => HEIGHT, + _ => return None, + }; + let expected = dimension(value)?; + Some(match kind { + 1 => actual >= expected, + -1 => actual <= expected, + _ => actual == expected, + }) + } + _ => { + // Range syntax: width >= 80em; 1000px < width <= 1280px. + let indices: Vec = t + .iter() + .enumerate() + .filter_map(|(i, v)| dimension(v).map(|_| i)) + .collect(); + if !matches!(indices.len(), 2 | 3) || indices[0] != 0 || *indices.last()? != t.len() - 1 + { + return None; + } + let features: Vec = indices + .iter() + .copied() + .filter(|&i| matches!(&t[i], Term::Ident(_))) + .collect(); + if features.len() != 1 || (indices.len() == 3 && features[0] != indices[1]) { + return None; + } + if indices.len() == 3 { + let first = &t[1]; + let second = &t[indices[1] + 1]; + if !matches!( + (first, second), + (Term::Mark('<'), Term::Mark('<')) | (Term::Mark('>'), Term::Mark('>')) + ) { + return None; + } + } + let mut result = true; + for pair in indices.windows(2) { + result &= comparison( + dimension(&t[pair[0]])?, + &t[pair[0] + 1..pair[1]], + dimension(&t[pair[1]])?, + )?; + } + Some(result) + } + } +} + +fn condition(t: &[Term]) -> Option { + match t { + [Term::Group(inner)] => feature(inner).or_else(|| condition(inner)), + [not, Term::Group(inner)] if ident(not, "not") => { + Some(!feature(inner).or_else(|| condition(inner))?) + } + _ => { + if t.len() < 3 || t.len() % 2 == 0 { + return None; + } + let is_and = ident(&t[1], "and"); + if !is_and && !ident(&t[1], "or") { + return None; + } + let mut value = condition(&t[..1])?; + for chunk in t[1..].chunks_exact(2) { + if !ident(&chunk[0], if is_and { "and" } else { "or" }) { + return None; + } + let next = condition(&chunk[1..])?; + value = if is_and { value && next } else { value || next }; + } + Some(value) + } + } +} + +/// Evaluate a nonempty media query list. Callers handle absent/empty attributes. +pub fn matches_media(query: &str) -> bool { + let mut source = ParserInput::new(query); + let mut input = Parser::new(&mut source); + loop { + let parsed: Result<_, cssparser::ParseError<'_, ()>> = + input.parse_until_before(Delimiter::Comma, |p| Ok(terms(p, 0))); + if parsed.is_ok_and(|t| matches_query(&t)) { + return true; + } + if input.expect_comma().is_err() { + return false; + } + } +} + +fn matches_query(t: &[Term]) -> bool { + let mut index = 0; + let negate = t.first().is_some_and(|t| ident(t, "not")); + if t.first().is_some_and(|t| ident(t, "only")) || negate { + index += 1; + } + if let Some(Term::Ident(media)) = t.get(index) { + if matches!(media.as_str(), "and" | "or" | "not" | "only") { + return false; + } + let media_matches = media == "screen" || media == "all"; + index += 1; + let rest = if index == t.len() { + Some(true) + } else if ident(&t[index], "and") && !t[index + 1..].iter().any(|v| ident(v, "or")) { + // A media type permits a condition-without-or. Nested or is valid. + condition(&t[index + 1..]) + } else { + None + }; + rest.is_some_and(|rest| { + if negate { + !(media_matches && rest) + } else { + media_matches && rest + } + }) + } else { + condition(&t) == Some(true) + } +} + +fn inactive_ranges(input: &mut Parser<'_, '_>, ranges: &mut Vec>, depth: usize) { + while !input.is_exhausted() { + let start = input.position(); + let Ok(token) = input.next_including_whitespace_and_comments().cloned() else { + break; + }; + let mut keep = depth < 64; + if matches!(&token, Token::AtKeyword(name) if name.eq_ignore_ascii_case("media")) { + let prelude = input.position(); + let _: Result<(), cssparser::ParseError<'_, ()>> = input.parse_until_before( + Delimiter::CurlyBracketBlock | Delimiter::Semicolon, + |p| { + while p.next_including_whitespace_and_comments().is_ok() {} + Ok(()) + }, + ); + keep = keep && matches_media(input.slice_from(prelude)); + if !matches!(input.next(), Ok(Token::CurlyBracketBlock)) { + continue; + } + } else if !matches!(token, Token::CurlyBracketBlock) { + continue; + } + let _: Result<(), cssparser::ParseError<'_, ()>> = input.parse_nested_block(|p| { + if keep && depth < 64 { + inactive_ranges(p, ranges, depth + 1); + } else { + while p.next_including_whitespace_and_comments().is_ok() {} + } + Ok(()) + }); + if !keep { + ranges.push(start.byte_index()..input.position().byte_index()); + } + } +} + +pub fn screen_styles(css: &str) -> String { + let mut source = ParserInput::new(css); + let mut ranges = Vec::new(); + inactive_ranges(&mut Parser::new(&mut source), &mut ranges, 0); + let mut bytes = css.as_bytes().to_vec(); + for range in ranges { + for byte in &mut bytes[range] { + if *byte != b'\n' && *byte != b'\r' { + *byte = b' '; + } + } + } + String::from_utf8(bytes).expect("only whole CSS token ranges are replaced") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_grammar_and_unknown_negation() { + for query in [ + "SCREEN and ( width : 1280px )", + "screen/**/and (width:80rem)", + "screen/* , ( */ and (width:80rem)", + "not print", + "not screen and (max-width:500px)", + "not (prefers-color-scheme:dark)", + "(width)", + "(height:800px)", + "(orientation:landscape)", + "(1280px >= width > 1000px)", + "((min-width:80em) and (prefers-color-scheme:light))", + "(width > 2000px) or (width <= 1280px)", + ] { + assert!(matches_media(query), "{query}"); + } + for query in [ + "", + "only", + "not", + "screen and", + "screen nonsense", + "screen or (width)", + "screen and (width) or (height)", + "(prefers-reduced-motion:no-preference)", + "not (prefers-reduced-motion:reduce)", + "(prefers-color-scheme:dark)", + "(height > 800px)", + "(orientation:portrait)", + "(width:calc(100px + 1180px))", + "(width:1vw)", + "not (unknown:yes)", + "not screen and (unknown:yes)", + "(width == 1280px)", + "(1000px < width > 500px)", + "(width < 2000px < 3000px)", + "(width) and (height) or (width)", + ] { + assert!(!matches_media(query), "{query}"); + } + } + + #[test] + fn filtering_preserves_offsets_strings_comments_and_unrelated_at_rules() { + let keep = "/* @media print { } */ .a{content:'@media print {é}';background:url('x{y}') } @font-face{font-family:a;src:url(a)} @keyframes move{from{opacity:0}to{opacity:1}}"; + assert_eq!(screen_styles(keep), keep); + let css = format!("{keep}\n@MEDIA print{{.a{{color:red;content:'é'}}}}\n.b{{color:blue}}"); + let filtered = screen_styles(&css); + assert_eq!(filtered.len(), css.len()); + assert_eq!(filtered.lines().count(), css.lines().count()); + assert!(filtered.starts_with(keep)); + assert!(filtered.ends_with("\n.b{color:blue}")); + assert!(!filtered.contains("color:red")); + assert!(!screen_styles("@m\\65 dia print{.a{color:red}}").contains("color:red")); + } +} diff --git a/crates/html/src/cascade/mod.rs b/crates/html/src/cascade/mod.rs index f38ef4b159..a979bce383 100644 --- a/crates/html/src/cascade/mod.rs +++ b/crates/html/src/cascade/mod.rs @@ -13,6 +13,7 @@ pub mod build; pub mod checks_shim; pub mod csstree; pub mod defaults; +pub(crate) mod media; pub mod rules; pub mod shorthand; pub mod values; diff --git a/crates/html/tests/link_text_contrast.rs b/crates/html/tests/link_text_contrast.rs index 60d25cb57f..c23a162a5a 100644 --- a/crates/html/tests/link_text_contrast.rs +++ b/crates/html/tests/link_text_contrast.rs @@ -409,12 +409,9 @@ a.pale { color: #aaaaaa; font-size: 14px; } } #[test] -fn a_media_query_never_hides_anything_from_the_contrast_pass() { - // The static cascade descends every `@media` block, so a `max-width` - // query's `display: none` is the winning value here whatever viewport a - // reader is on. Standing an element down for it deletes the coverage - // this engine has always had of desktop-only markup: both links below - // are read by somebody, and both are reported. +fn inactive_mobile_media_does_not_hide_desktop_contrast_findings() { + // Static scans use a 1280px-wide screen. The mobile-only display rule + // does not apply, so both desktop links remain in the contrast pass. let html = r#"

Readable paragraph text.

"); + detect_html_source( + &html, + Path::new("/tmp/media-environment.html"), + &DetectHtmlOptions::default(), + ) + .iter() + .filter(|f| f.antipattern == "low-contrast") + .count() +} + +#[test] +fn dark_and_print_do_not_combine() { + assert_eq!(contrast_count(":root{--ink:#111;--paper:#fff}@media screen and (prefers-color-scheme:dark){:root{--ink:#eee;--paper:#111}}body{color:var(--ink);background:var(--paper)}@media print{body{background:#fff}}"), 0); +} + +#[test] +fn inactive_and_unknown_media_do_not_override_screen_styles() { + for query in [ + "print", + "speech", + "(prefers-color-scheme: dark)", + "(max-width: 1279px)", + "(min-width: 1281px)", + "(unknown-feature: yes)", + "not (unknown-feature: yes)", + "(prefers-reduced-motion: no-preference)", + "not (prefers-reduced-motion: reduce)", + "screen and (min-width: 9000px)", + ] { + assert_eq!( + contrast_count(&format!("@media {query}{{body{{color:#eee}}}}")), + 0, + "{query}" + ); + } +} + +#[test] +fn active_queries_still_report_real_contrast_errors() { + for query in [ + "screen", + "all", + "only screen", + "not print", + "(prefers-color-scheme:light)", + "(min-width:1280px)", + "(max-width:1280px)", + "(width:80em)", + "(width >= 1280px)", + "(1000px < width <= 1280px)", + "print, screen", + "(unknown-feature:yes), screen", + "screen and ((min-width:1000px) or (max-width:100px))", + ] { + assert_eq!( + contrast_count(&format!("@media {query}{{body{{color:#eee}}}}")), + 1, + "{query}" + ); + } +} + +#[test] +fn inactive_parent_stays_inactive_and_layer_contents_are_preserved() { + assert_eq!( + contrast_count("@media print{@media screen{body{color:#eee}}}"), + 0 + ); + assert_eq!( + contrast_count("@layer theme{@media print{body{color:#eee}}}"), + 0 + ); + assert_eq!( + contrast_count("@media screen{@layer theme{body{color:#eee}}}"), + 1 + ); +} + +#[test] +fn inactive_variables_do_not_reach_the_root_cascade() { + assert_eq!( + contrast_count(":root{--ink:#111}@media print{:root{--ink:#eee}}body{color:var(--ink)}"), + 0 + ); + assert_eq!( + contrast_count("@media print{:root{--ink:#eee}}:root{--ink:#111}body{color:var(--ink)}"), + 0 + ); +} + +#[test] +fn media_attributes_on_inline_and_linked_stylesheets_are_respected() { + let dir = std::env::temp_dir().join(format!("impeccable-media-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("print.css"), "body{color:#eee}").unwrap(); + let html = "

Readable paragraph text.

"; + let findings = detect_html_source(html, &dir.join("index.html"), &DetectHtmlOptions::default()); + std::fs::remove_dir_all(dir).unwrap(); + assert!( + !findings.iter().any(|f| f.antipattern == "low-contrast"), + "{findings:?}" + ); +} + +#[test] +fn matching_stylesheet_media_still_applies() { + let html = "

Unreadable paragraph text.

"; + let findings = detect_html_source( + html, + Path::new("/tmp/media-style.html"), + &DetectHtmlOptions::default(), + ); + assert_eq!( + findings + .iter() + .filter(|f| f.antipattern == "low-contrast") + .count(), + 1 + ); +} + +#[test] +fn shared_fixture_has_four_real_findings_and_no_inactive_findings() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/antipatterns/static-media-environment.html"); + let html = std::fs::read_to_string(&path).unwrap(); + let findings = detect_html_source(&html, &path, &DetectHtmlOptions::default()); + assert_eq!( + findings + .iter() + .filter(|f| f.antipattern == "low-contrast") + .count(), + 4, + "{findings:?}" + ); +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 2baa64c35c..b6f4a89c26 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -33,6 +33,27 @@ Notation: `stdout>` / `stderr>` are literal writes; `exit N`; template strings u --- +### Static HTML media environment + +Static HTML scans resolve CSS for one environment, matching the default rendered +scan: `screen`, a light color scheme, and a 1280 × 800 CSS-pixel viewport. This +applies to inline and local linked stylesheets, their `media` attributes, and +nested `@media` blocks. Nonmatching and unsupported queries do not contribute +styles, custom properties, or CSS pattern findings. Plain CSS/JS text scans are +unchanged; a static HTML scan is not a review of every responsive state. + +Supported conditions are `screen`/`all` media types (other media types do not +match), `not`/`only`, comma-separated alternatives, grouped `and`/`or` conditions, +light/dark `prefers-color-scheme`, `orientation`, and `width`/`height` conditions. +Dimensions support min/max, equality, and range syntax in px or initial-font +em/rem (16px). Unsupported features, units, functions, and malformed conditions +are excluded, including when negated. Use a rendered scan at the desired viewport +for other environments. Existing `@supports` and `@layer` behavior is unchanged. + +The recorded `collectStaticCssRules` function vectors retain their historical +behavior. Runtime scans filter media before the collector and custom-property +resolution; those frozen vectors are not regenerated. + ### PART 1 — the `impeccable` binary and the detect engine #### `cli/bin/cli.js` -> `impeccable ` (root dispatcher) diff --git a/tests/fixtures/antipatterns/static-media-environment.html b/tests/fixtures/antipatterns/static-media-environment.html new file mode 100644 index 0000000000..8555ea9f09 --- /dev/null +++ b/tests/fixtures/antipatterns/static-media-environment.html @@ -0,0 +1,73 @@ + + + + + Static scan media environment + + + +

Static scan media environment

+
+
+

Should flag

+
+

flag-screen

+

This text checks screen.

+
+
+

flag-light

+

This text checks (prefers-color-scheme: light).

+
+
+

flag-width

+

This text checks (min-width: 1280px).

+
+
+

flag-list

+

This text checks print, screen and (width <= 1280px).

+
+
+
+

Should pass

+
+

pass-print

+

This text checks print.

+
+
+

pass-dark

+

This text checks (prefers-color-scheme: dark).

+
+
+

pass-narrow

+

This text checks (max-width: 1279px).

+
+
+

pass-unknown

+

This text checks (unrecognized-feature: yes).

+
+
+

pass-negated-unknown

+

This text checks not (unrecognized-feature: yes).

+
+
+
+ + diff --git a/tests/oracle/DELTAS.md b/tests/oracle/DELTAS.md index 369dc08379..73b4a0f0f4 100644 --- a/tests/oracle/DELTAS.md +++ b/tests/oracle/DELTAS.md @@ -7122,3 +7122,17 @@ Re-recorded 2026-10-19 after merging main (with #1042 and #1043): the eight dire The branch added two browser fixtures, `low-contrast-pixel-reads.html` and `low-contrast-settled-states.html`, with their own per-fixture cases, but the directory and no-advisory sweeps were not re-recorded with them. They are now. Re-recorded from the binary and reviewed by hand: the directory sweeps (json, text and quiet) and the no-advisory sweeps (json and text) gain exactly the five findings the per-fixture goldens hold, in path order (on `low-contrast-pixel-reads.html` two `low-contrast` at 2.8:1 for `#fbfaf8` on `#979797` and two `dark-glow` for the `#f472b6` glow; on `low-contrast-settled-states.html` one `low-contrast` at 1.2:1 for `#ededed` on `#ffffff`), and the count moves from 950 to 955. No other fixture's output moved, and the scope sweeps did not change. Re-recorded 2026-10-19 after merging main (with #1042, #1043 and #1040): the five directory and no-advisory sweeps, from a binary built from the merged tree and reviewed by hand. Each gains exactly those five findings on top of main and loses none: failures move from 977 to 982, advisory notes stay at 173, exit codes unchanged; the scope sweeps still do not move. + + +## Recorded 2026-10-19: one media environment for static HTML (#988) + +Per the [approved policy](https://github.com/pbakaus/impeccable/issues/988#issuecomment-6194496662), static HTML scans use screen, light color scheme, and the default 1280 × 800 viewport. Inline/local linked stylesheet media attributes and nested `@media` blocks are filtered before declarations, custom properties, keyframes, and CSS pattern checks. Unsupported queries are excluded, even when negated. This does not change plain CSS/JS text scans or the frozen function-level vectors. + +The following CLI goldens were regenerated from the native build and reviewed as finding-by-finding JSON changes and line-by-line text changes. Exit codes remain 2; no unrelated findings or advisory counts change. + +- `detect-fixture-json-linked-url-patterns-html`, `detect-fixture-text-linked-url-patterns-html`: remove only the contrast finding on `div.inactive-media-stripes`, whose gradient is inside `(max-width: 1px)`. The inactive `@supports` case remains; this PR does not evaluate supports conditions. The fixture count changes from 12 to 11. +- `detect-fixture-json-pulsing-dot-html`, `detect-fixture-text-pulsing-dot-html`: remove only the `.rec-mark` pulsing-dot finding. Its animation is inside `(prefers-reduced-motion: no-preference)`, which this static environment does not evaluate. Both reduced-motion branches are excluded. The fixture count changes from 5 to 4; the rendered/browser path remains unchanged. +- `detect-fixture-json-static-media-environment-html`, `detect-fixture-text-static-media-environment-html`: new fixture with four real low-contrast findings under matching screen/light/width/list queries and five passing cases under print/dark/narrow/unknown/negated-unknown queries. +- `detect-dir-json-all-fixtures`, `detect-dir-text-all-fixtures`, `detect-dir-quiet-all-fixtures`, `detect-no-advisory-json`, `detect-no-advisory-text`: the same two removals plus the four new fixture findings. The total changes from 982 to 984; the 173 advisory notes are unchanged. Scope-filtered goldens do not change. + +`tests/oracle/vectors/calls/` and the historical `collectStaticCssRules` collector are unchanged. Runtime filtering occurs before that collector. diff --git a/tests/oracle/golden/detect-dir-json-all-fixtures.json b/tests/oracle/golden/detect-dir-json-all-fixtures.json index df9bb25613..bf1f64a478 100644 --- a/tests/oracle/golden/detect-dir-json-all-fixtures.json +++ b/tests/oracle/golden/detect-dir-json-all-fixtures.json @@ -1,5 +1,5 @@ { - "stdout": "[\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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-brand-hue-headings-only.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#5c2d91) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-brand-hue-headings-only.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#6a35a6) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-brand-hue.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#5c2d91) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-brand-hue.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#6a35a6) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-category-colours.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#7c3aed) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-category-colours.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#7c3aed) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-category-colours.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#9333ea) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-category-colours.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet accent colors detected\"\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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-muted-violet.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#7c3aed) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-muted-violet.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#7a6699) 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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette-muted-violet.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet accent colors detected\"\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. 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.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#8526fe) on heading\"\n },\n {\n \"antipattern\": \"radial-halo\",\n \"name\": \"Radial-gradient background halo\",\n \"description\": \"A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/ai-color-palette.html\",\n \"line\": 0,\n \"snippet\": \"radial-gradient halo (#8526fe → transparent) on dark page\"\n },\n {\n \"antipattern\": \"all-caps-body\",\n \"name\": \"All-caps body text\",\n \"description\": \"Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings. Only a run that reads as a sentence counts: 80 characters or more of an element's own text. Buttons, nav items, kickers and eyebrows set in caps are a convention and stay silent.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/all-caps-body.html\",\n \"line\": 0,\n \"snippet\": \"text-transform: uppercase on 162 chars of body text\"\n },\n {\n \"antipattern\": \"all-caps-body\",\n \"name\": \"All-caps body text\",\n \"description\": \"Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings. Only a run that reads as a sentence counts: 80 characters or more of an element's own text. Buttons, nav items, kickers and eyebrows set in caps are a convention and stay silent.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/all-caps-body.html\",\n \"line\": 0,\n \"snippet\": \"text-transform: uppercase on 140 chars of body text\"\n },\n {\n \"antipattern\": \"all-caps-body\",\n \"name\": \"All-caps body text\",\n \"description\": \"Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings. Only a run that reads as a sentence counts: 80 characters or more of an element's own text. Buttons, nav items, kickers and eyebrows set in caps are a convention and stay silent.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/all-caps-body.html\",\n \"line\": 0,\n \"snippet\": \"text-transform: uppercase on 159 chars of body text\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 52,\n \"snippet\": \"[data-case=\\\"Kinpaku Edge\\\"] — inset box-shadow 3px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 53,\n \"snippet\": \"[data-case=\\\"Patina Edge\\\"] — inset box-shadow 3px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 54,\n \"snippet\": \"[data-case=\\\"Accent Edge\\\"] — inset box-shadow 4px stripe (right)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 55,\n \"snippet\": \"[data-case=\\\"Signal Blue Edge\\\"] — inset box-shadow 5px stripe (top)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 66,\n \"snippet\": \"[data-case=\\\"Chromatic Hex Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 67,\n \"snippet\": \"[data-case=\\\"Named Red Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 68,\n \"snippet\": \"[data-case=\\\"Chromatic Rgb Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 69,\n \"snippet\": \"[data-case=\\\"Chromatic Oklch Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 78,\n \"snippet\": \"[data-case=\\\"Trailing Inset Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 79,\n \"snippet\": \"[data-case=\\\"Trailing Inset Token Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 81,\n \"snippet\": \"[data-case=\\\"Inset Named Token Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 86,\n \"snippet\": \"[data-case=\\\"Two Length Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 87,\n \"snippet\": \"[data-case=\\\"Two Length Trailing Inset Edge\\\"] — inset box-shadow 5px stripe (top)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 88,\n \"snippet\": \"[data-case=\\\"Important Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 89,\n \"snippet\": \"[data-case=\\\"Cascade Override Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 91,\n \"snippet\": \"[data-case=\\\"Color First Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 92,\n \"snippet\": \"[data-case=\\\"Color First Var Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"pulsing-dot\",\n \"name\": \"Pulsing status dot\",\n \"description\": \"Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/blinking-cursor.html\",\n \"line\": 0,\n \"snippet\": \".pass-round-dot — 8x8px dot with infinite \\\"blink-anim\\\" animation\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-accent-blank-lines.css\",\n \"line\": 10,\n \"snippet\": \"border-bottom: 3px solid\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-accent-blank-lines.css\",\n \"line\": 14,\n \"snippet\": \"border-bottom: 4px solid\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-accent-blank-lines.css\",\n \"line\": 24,\n \"snippet\": \"border-bottom: 5px solid\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-accent-blank-lines.css\",\n \"line\": 34,\n \"snippet\": \"border-bottom: 6px solid\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-accent-blank-lines.css\",\n \"line\": 40,\n \"snippet\": \"border-top: 9px solid\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-right: 5px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 6px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 9px\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-top: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-bottom: 3px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"advisory\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/bounce-easing-pulse.html\",\n \"line\": 0,\n \"snippet\": \"animation: fixture-bounce\",\n \"advisory\": true\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"advisory\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/bounce-easing-pulse.html\",\n \"line\": 0,\n \"snippet\": \"animation: fixture-bounce-in\",\n \"advisory\": true\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"advisory\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/bounce-easing-unread-property.html\",\n \"line\": 0,\n \"snippet\": \"cubic-bezier(.2, 1.4, .4, 1)\",\n \"advisory\": true\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/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"1.1:1 (need 4.5:1) — text #f2f2f2 on #ffffff\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"raster background at opacity 0.04 \\\"Grain\\\"\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \" at opacity 0.05 \\\"Ghost img\\\"\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \" at opacity 0.04 \\\"Buried texture\\\"\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \" at opacity 0 \\\"Pin\\\"\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"raster background at opacity 0\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \" at opacity 0 \\\"Inbox view\\\"\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"raster under a near-opaque gradient wash: linear-gradient(rgba(240,237,226,0.96), rgba(240,237,226,0.96)), url(assets/manual-paper.p\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"raster under a near-opaque gradient wash: linear-gradient(0deg, rgba(255,255,255,.95), rgba(255,255,255,.95)), url(\\\"assets/bone-pape\"\n },\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-hidden clips positioned ul.pop\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-clip clips positioned span.pop\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-negative clips positioned span.tip-negative\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-right clips positioned div.flyout\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-shadow-utility clips positioned div.pop.bg-white.shadow-lg\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overlay-surface clips positioned div.pop.modal-overlay\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-rail-tooltip clips positioned span.rail-tooltip\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.inner-clip.flag-nested-row clips positioned span.tip-negative\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.inner-clip.flag-nested-note clips positioned span.tip-second\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-translated-menu clips positioned div.translated-menu\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-empty-menu clips positioned div.empty-menu\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-native-dialog clips positioned dialog.pop.native-dialog\",\n \"advisory\": true\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\": \"advisory\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.hero__panel.flag-bem-tooltip clips positioned span.hero__badge-tooltip\",\n \"advisory\": true\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 #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 (gradient on div.card)\"\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 (gradient on div.card)\"\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. 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.\",\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. 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.\",\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. 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.\",\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 (gradient on div.bg-gradient-to-r)\"\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 (gradient on div.mix-hex-brand)\"\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\": \"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 #aaaaaa on #fafafa\"\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