diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index 230e3f0bf..ad42a9329 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -93,7 +93,8 @@ fn resolve_linked_css_path(file_dir: &str, href: &str) -> String { /// /// Returns that text plus a second one for the page-level pattern scan: the /// same join minus the linked sheets `ignored` accepts (given the resolved -/// path). The cascade keeps the full text. +/// path). Each active sheet is media-filtered once before it enters either +/// corpus. The cascade keeps active styles from ignored sheets too. pub fn collect_static_css_text( doc: &StaticDocument, file_dir: &Path, @@ -107,12 +108,23 @@ 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") { - let text = style_el.text_content(); - pattern_texts.push(text.clone()); - style_texts.push(text); + if style_el + .get_attribute("media") + .is_none_or(|m| m.trim().is_empty() || super::media::matches_media(m)) + { + let text = super::media::screen_styles(&style_el.text_content()); + pattern_texts.push(text.clone()); + style_texts.push(text); + } } 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) { @@ -126,7 +138,7 @@ pub fn collect_static_css_text( ); match read { Ok(bytes) => { - let text = String::from_utf8_lossy(&bytes).into_owned(); + let text = super::media::screen_styles(&String::from_utf8_lossy(&bytes)); if !ignored.is_some_and(|f| f(&css_path)) { pattern_texts.push(text.clone()); } @@ -300,6 +312,7 @@ fn mark_pseudo_rule( } /// JS: css-cascade.mjs#buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) +/// `css_text` is already media-filtered by [`collect_static_css_text`]. pub fn build_static_style_map( doc: &mut StaticDocument, css_text: &str, diff --git a/crates/html/src/cascade/media.rs b/crates/html/src/cascade/media.rs new file mode 100644 index 000000000..e53379a6e --- /dev/null +++ b/crates/html/src/cascade/media.rs @@ -0,0 +1,377 @@ +//! 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), + // A negative media length is valid; compare it with the viewport. + Term::Length(n) if n.is_finite() => 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 { + // These supported features have no zero/none value in this environment. + // https://www.w3.org/TR/mediaqueries-5/#mq-boolean-context + [Term::Ident(name)] + if matches!( + name.as_str(), + "width" | "height" | "orientation" | "prefers-color-scheme" + ) => + { + 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 negative_dimensions_are_comparable_not_unknown() { + for query in [ + "(width > -1px)", + "not (max-width: -1px)", + "(height >= -1em)", + "(-1rem < width <= 80rem)", + "(min-height: -10px)", + "not (height: -1px)", + ] { + assert!(matches_media(query), "{query}"); + } + for query in [ + "(width: -1px)", + "(max-width: -1px)", + "(height < -1em)", + "(-2px < width < -1px)", + "not (min-height: -1px)", + "not (width: -1vw)", + ] { + 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 f38ef4b15..a979bce38 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/ignored_linked_stylesheet.rs b/crates/html/tests/ignored_linked_stylesheet.rs index 67584c2e2..af7de88e8 100644 --- a/crates/html/tests/ignored_linked_stylesheet.rs +++ b/crates/html/tests/ignored_linked_stylesheet.rs @@ -3,8 +3,15 @@ use impeccable_html::{detect_html_source, DetectHtmlOptions}; /// Rule ids the static engine reports for a page linking `vendor/lib.css`, /// with that sheet waived or not. fn ids(waive_sheet: bool) -> Vec { + media_ids(waive_sheet, "screen", "") +} + +fn media_ids(waive_sheet: bool, media: &str, inline: &str) -> Vec { + use std::sync::atomic::{AtomicUsize, Ordering}; + static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( - "impeccable-ignored-sheet-{}-{waive_sheet}", + "impeccable-ignored-sheet-{}-{sequence}", std::process::id() )); std::fs::create_dir_all(dir.join("vendor")).unwrap(); @@ -14,15 +21,15 @@ fn ids(waive_sheet: bool) -> Vec { .pale { color: #ccc; background: #fff; }\n", ) .unwrap(); - let html = r#"mock - -

Mock

Pale body copy on white

"#; + let html = format!(r#"mock + +

Mock

Pale body copy on white

"#); let ignored = |path: &str| path.replace('\\', "/").ends_with("vendor/lib.css"); let options = DetectHtmlOptions { ignored_linked_stylesheet: waive_sheet.then_some(&ignored as &dyn Fn(&str) -> bool), ..Default::default() }; - let ids = detect_html_source(html, &dir.join("index.html"), &options) + let ids = detect_html_source(&html, &dir.join("index.html"), &options) .into_iter() .map(|f| f.antipattern) .collect(); @@ -40,3 +47,17 @@ fn a_waived_sheet_leaves_the_pattern_scan_but_still_styles_the_page() { assert!(!waived.iter().any(|id| id == "gradient-text"), "{waived:?}"); assert!(waived.iter().any(|id| id == "low-contrast"), "{waived:?}"); } + +#[test] +fn media_filtering_and_sheet_waivers_apply_to_both_css_corpora() { + for waived in [false, true] { + let inactive = media_ids(waived, "print", "@media print { .unused-text { background-image: linear-gradient(90deg, #f00, #00f); background-clip: text; color: transparent; } }"); + assert!(!inactive.iter().any(|id| id == "gradient-text"), "{inactive:?}"); + assert!(!inactive.iter().any(|id| id == "low-contrast"), "{inactive:?}"); + } + // Waiving the linked sheet must not remove active inline CSS from the + // pattern corpus. The waived sheet must still supply screen colors. + let inline = media_ids(true, "screen", "@media screen { .unused-text { background-image: linear-gradient(90deg, #f00, #00f); background-clip: text; color: transparent; } }"); + assert!(inline.iter().any(|id| id == "gradient-text"), "{inline:?}"); + assert!(inline.iter().any(|id| id == "low-contrast"), "{inline:?}"); +} diff --git a/crates/html/tests/link_text_contrast.rs b/crates/html/tests/link_text_contrast.rs index 60d25cb57..c23a162a5 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:?}" + ); +} + +#[test] +fn negative_media_dimensions_keep_active_contrast_styles() { + for query in [ + "(width > -1px)", + "not (max-width: -1px)", + "(height >= -1em)", + "(-1rem < width <= 80rem)", + ] { + assert_eq!( + contrast_count(&format!("@media {query}{{body{{color:#eee}}}}")), + 1, + "{query}" + ); + } +} + +#[test] +fn an_unclosed_inactive_sheet_does_not_consume_the_next_sheet() { + let html = "

Active second sheet still applies.

"; + let findings = detect_html_source( + html, + Path::new("/tmp/media-sheet-boundary.html"), + &DetectHtmlOptions::default(), + ); + assert_eq!( + findings + .iter() + .filter(|f| f.antipattern == "low-contrast") + .count(), + 1 + ); +} + +#[test] +fn negative_dimension_fixture_reports_only_the_four_active_cases() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/antipatterns/negative-media-dimensions.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:?}" + ); +} + +#[test] +fn boolean_discrete_features_keep_active_contrast_styles() { + for query in [ + "(orientation)", + "(prefers-color-scheme)", + "screen and (orientation) and (prefers-color-scheme)", + ] { + assert_eq!( + contrast_count(&format!("@media {query}{{body{{color:#eee}}}}")), + 1, + "{query}" + ); + } + for query in [ + "not (orientation)", + "not (prefers-color-scheme)", + "(unsupported-feature)", + "not (unsupported-feature)", + ] { + assert_eq!( + contrast_count(&format!("@media {query}{{body{{color:#eee}}}}")), + 0, + "{query}" + ); + } +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 64691840b..b2879f9cc 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/negative-media-dimensions.html b/tests/fixtures/antipatterns/negative-media-dimensions.html new file mode 100644 index 000000000..76ea9129c --- /dev/null +++ b/tests/fixtures/antipatterns/negative-media-dimensions.html @@ -0,0 +1,34 @@ + +Negative media dimensions +

Negative media dimensions

+

Should flag

+

flag-range-width

Text under (width > -1px)

+

flag-negated-width

Text under not (max-width: -1px)

+

flag-range-height

Text under (height >= -1em)

+

flag-chained-width

Text under (-1rem < width <= 80rem)

+
+

Should pass

+

pass-equal-width

Text under (width: -1px)

+

pass-max-width

Text under (max-width: -1px)

+

pass-range-height

Text under (height < -1em)

+

pass-chained-width

Text under (-2px < width < -1px)

+

pass-negated-height

Text under not (min-height: -1px)

+
+
diff --git a/tests/fixtures/antipatterns/pattern-walk-inactive-at-rules.html b/tests/fixtures/antipatterns/pattern-walk-inactive-at-rules.html index c853adabc..34b0417bd 100644 --- a/tests/fixtures/antipatterns/pattern-walk-inactive-at-rules.html +++ b/tests/fixtures/antipatterns/pattern-walk-inactive-at-rules.html @@ -3,15 +3,15 @@ - Pattern walk: candidates in at-rules that do not apply (browser-only) + Pattern walk: candidates in at-rules that do not apply (1280px screen) + + +

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 c72178814..9785a3e1b 100644 --- a/tests/oracle/DELTAS.md +++ b/tests/oracle/DELTAS.md @@ -7275,6 +7275,26 @@ Merging main into `detect-status-row-stripes` again (2026-10-23): the same five New fixture `low-contrast-resting-states.html`: a stepper's inactive item beside the active one (opacity 0.3 by stylesheet with an opacity transition, the bright item carrying one more class token) reports as advisory in the URL engine (r13-t1), as does a checkout stepper's next step; text a script parked in its `style` attribute at the start of a reveal (Framer's start values, a scrubbed `max(.2, var(...))` opacity, a set of keys parked alike whose component the page shows lit elsewhere) is not at rest there and passes at its declared colour. The should-flag column carries the review's twins: a lone parked badge, parked nav links, an inline design token, a faint modifier, a clickable plan option, identical class lists, disclosure `summary` elements, unchecked `menuitemcheckbox` items, and keys parked alike whose lit peer is `display: none`. New cases `detect-fixture-json-low-contrast-resting-states-html` and `detect-fixture-text-low-contrast-resting-states-html`, recorded from the binary and reviewed by hand: the static engine reads no transition, transform or `will-change`, so it reports eighteen `low-contrast` warnings (the sixteen URL-engine findings, both advisories at full severity, plus the parked card and keys at their faded colour; it does not resolve the scrubbed opacity and scores that copy as declared), exit 2. The URL behaviour is pinned by `crates/browser/tests/transient_opacity.rs`. No existing per-fixture golden moved. The directory sweeps (`detect-dir-json-all-fixtures` and the other sweep cases) also gain these eighteen findings, but they are not re-recorded here: earlier entries list them as accepted deltas, so the oracle no longer compares them. The snapshot capture also reads computed `cursor` now (`STYLE_PROPS`); older recordings read it as empty. Review round, 2026-10-23 (#1148): three rows after the checkout stepper were added as should-flag twins for the control list (`summary`, `menuitemcheckbox`) and for a lit peer that must be shown; both fixture goldens were re-recorded from the binary and reviewed by hand, gaining exactly those three warnings, exit 2. A fourth row, a stepper item whose active sibling is `display: none` (a bright sibling must be shown), adds one more warning in both goldens. +## 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. + +Re-recorded 2026-10-22 after merging main `490141501` (including #998). Both main and this merged tree were built from source, and the five aggregate cases were compared against fresh runs of each binary. The only finding differences are the same two removals and four new fixture findings described above: non-advisory counts move from 1,026 to 1,028, while all 178 advisories remain unchanged. JSON findings and text output were reviewed individually; exit codes remain 2. Frozen function-level vectors are unchanged. + +Review follow-up, 2026-10-23 (#1079): negative lengths in width/height media conditions are valid comparisons, not unsupported conditions. The new `negative-media-dimensions.html` fixture covers four active conditions and five inactive controls. Its JSON/text goldens report exactly four low-contrast findings, matching a real Chromium scan; both exit 2. Compared with the pre-fix `ec80ea6b9` binary on the identical fixture tree, the five aggregate goldens gain only those four findings: non-advisory counts 1,028 to 1,032, all 178 advisories unchanged, no removals, exit 2. JSON findings and text differences were reviewed individually. Filtering each stylesheet once at collection also removes repeated media passes and prevents an unclosed inactive media block in one sheet from consuming the next sheet; a failing-first integration test covers that boundary. Frozen function-level vectors are unchanged. + +Merged main `936867543` on 2026-10-24 without rewriting the PR history. The five aggregate goldens conflicted. Fresh release builds of main and the merged tree were compared finding by finding and line by line before recording the merged results: non-advisory totals are 1,156 on current main and 1,161 on the PR, with the same 190 advisory findings in the same order and unchanged exit 2. The differences are the two existing removals and eight findings from this PR's two fixtures, plus the newly merged `pattern-walk-inactive-at-rules.html` fixture: its `min-width: 4000px` magenta candidates no longer enter the static scan, and the active cyan glow/halo candidates are reported instead (five findings to four, including one unchanged grid advisory). Its JSON/text goldens and explanatory comment were updated after reviewing that exact change. No runtime source was changed to resolve the merge. + +Main's committed aggregates lag its current fixtures (1,127 non-advisory findings and 189 advisories versus a fresh main run's 1,156 and 190). The refreshed aggregate goldens retain those upstream additions: resting states, title/wordmark contrast, inactive-at-rule pattern walks, status-row follow-ups, and scaled one-pixel text. Compared with fresh main output, no other fixture finding changes. Frozen function-level vectors are unchanged. ## Recorded 2026-10-26: one report per colour pair and per outer panel, counted (corpus/round14-counting) Taste calls r14-t2 and r14-t5 (`docs/CLI-CONTRACT.md`, "Round 14 counted repeats"). `low-contrast` reports each colour pair once on every path, the full pass included, and `nested-cards` reports an outer panel once; the finding that reports ends ` (and N more)` when it speaks for other elements. The static engine also counts the copies its SAFE_TAGS dedupe used to drop silently, so several pairs gain a count without losing a finding. Each case below was re-recorded from a release build of the binary and reviewed by hand with a script that weighs every finding as one plus its count: per case, no pair or panel lost weight, and the only weight moved is to the copies the dedupe now counts and to the fixture inks that changed. @@ -7338,3 +7358,10 @@ The design hook remembered a finding as `rule:line[:value]`, so a line inserted ## Recorded 2026-10-28: serve-question missing state and the page-closed grace (#1203) `--start` now records its root in `~/.impeccable/questions/.root.json`, and `--wait`, `--update` and `--stop` follow it when cwd holds no state for the key, so a shell whose cwd was reset still reaches the live round. When neither place has the state, those verbs name the missing state file before the liveness check instead of calling the server dead. `question-wait-no-server` and `question-update-no-server` were re-recorded from the binary and reviewed by hand: both print `serve-question: no state file for key k1 in /.impeccable/questions, and no live round recorded for it elsewhere. ...` (stdout for `--wait`, stderr for `--update`, exit 2 as before) in place of the "server is gone" and "no live question server" lines. The page-closed grace in `--wait` moves from 15 s to 90 s, past the once-a-minute timer cadence of a long-hidden Chrome tab. The new case `question-wait-hidden-tab-beat` pins it: a state file whose `lastBeat` is 30 s old waits out the poll (exit 3 `WAITING`), where it was exit 4 `PAGE CLOSED`. `question-wait-page-closed` (`lastBeat` 1000) is unchanged. A second new case, `question-wait-recorded-root`, holds the state under `proj/` and the pointer in the isolated home, and `--wait` from the workspace root waits (exit 3) where it reported a dead server. A third, `question-stop-recorded-root`, runs `--stop` from the workspace root against the same layout: it prints `stopped` and leaves neither the state file nor the pointer. Three more pin the review follow-ups: `question-update-recorded-root` (the payload is read from the calling directory and lands in `proj/.impeccable/questions/k1.next.json`), `question-wait-answer-decision-comp-recorded-root` (a comp-round pick collected from the workspace root reads `proj/`'s build state and prints `APPROVED COMP`), and `question-stop-keeps-other-root` (`--stop` of a round in cwd keeps a pointer that names another project's round). + + +## Recorded 2026-10-28: static media merge with current main (#1079) + +Merged main `7510e233b` without rewriting history. Runtime source merged cleanly. Main now reports one contrast finding per color pair, so the two media fixtures originally collapsed their four flag cases into one finding. The failing integration tests caught this. Each of their nine flag/pass cases now uses a distinct ink; all four active cases must still report independently and all five inactive cases must stay silent. Their four JSON/text goldens retain four findings, with only three flag inks changing from `#eeeeee` to `#ededed`, `#ececec`, and `#ebebeb`. + +The five aggregate conflicts were resolved against fresh release builds of main and the merged tree, using the identical updated fixture tree for both runs. Main reports 1,202 non-advisory findings; the PR reports 1,189. All 201 advisory findings remain identical and in order. The differences are exactly the five inactive controls in each media fixture, the existing inactive-gradient and pulsing-dot removals, and the previously reviewed magenta-to-cyan inactive-at-rule change (five findings to four). Every other fixture's findings are identical. Exit codes stay 2. The aggregates also retain current upstream fixture and pair-counting changes rather than restoring stale counts from either side. Frozen function vectors are unchanged. diff --git a/tests/oracle/golden/detect-dir-json-all-fixtures.json b/tests/oracle/golden/detect-dir-json-all-fixtures.json index c5b942bea..a14fe6e51 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\": \"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-tracking-pixel.html\",\n \"line\": 0,\n \"snippet\": \" 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-tracking-pixel.html\",\n \"line\": 0,\n \"snippet\": \" at opacity 0.05 \\\"Workshop\\\"\"\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