diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index db6baa8e7..441d8d654 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -97,9 +97,8 @@ pub fn collect_static_css_text( profile: Option<&dyn ProfileSink>, file_path: &str, warn: Option<&dyn Fn(&str)>, -) -> (String, Vec) { +) -> String { let mut style_texts: Vec = Vec::new(); - let mut sheet_dirs: Vec = Vec::new(); let mut warned_missing_stylesheets: std::collections::HashSet = std::collections::HashSet::new(); for style_el in doc.query_selector_all("style") { @@ -120,11 +119,13 @@ pub fn collect_static_css_text( ); match read { Ok(bytes) => { - style_texts.push(String::from_utf8_lossy(&bytes).into_owned()); - let dir = jsp::dirname(&css_path); - if !sheet_dirs.contains(&dir) { - sheet_dirs.push(dir); - } + let text = String::from_utf8_lossy(&bytes); + let sheet_dir = jsp::dirname(&css_path); + style_texts.push(if sheet_dir == file_dir_str { + text.into_owned() + } else { + rewrite_sheet_urls(&text, &sheet_dir, &file_dir_str) + }); } Err(_) => { if warned_missing_stylesheets.insert(css_path.clone()) { @@ -137,7 +138,57 @@ pub fn collect_static_css_text( } } } - (style_texts.join("\n"), sheet_dirs) + style_texts.join("\n") +} + +static CSS_URL_RE: Lazy = Lazy::new(|| { + Regex::new(r#"(?i)url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)"']*?))\s*\)"#) + .expect("CSS_URL_RE") +}); +static URL_SCHEME_RE: Lazy = + Lazy::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9+.-]*:").expect("URL_SCHEME_RE")); + +/// A relative `url()` in a stylesheet is relative to the sheet, and the +/// cascade sees one concatenated text, so a sheet inlined from another +/// directory has its relative urls rewritten to page-relative form here. +/// This is what lets the sampled-contrast path (#560) resolve the image the +/// winning declaration named. Root-relative, remote, `data:`, fragment, and +/// escaped urls are left as they are. +pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String { + CSS_URL_RE + .replace_all(css, |caps: ®ex::Captures| { + let whole = caps.get(0).map(|m| m.as_str()).unwrap_or(""); + let (target, quote) = match (caps.get(1), caps.get(2), caps.get(3)) { + (Some(m), _, _) => (m.as_str(), "\""), + (_, Some(m), _) => (m.as_str(), "'"), + (_, _, Some(m)) => (js::trim(m.as_str()), ""), + _ => return whole.to_string(), + }; + let lower = js::to_lower_case(target); + if target.is_empty() + || target.contains('\\') + || target.starts_with('#') + || target.starts_with('/') + || lower.starts_with("data:") + || URL_SCHEME_RE.is_match(target) + { + return whole.to_string(); + } + let cut = target.find(['?', '#']).unwrap_or(target.len()); + let (path, suffix) = target.split_at(cut); + let absolute = jsp::resolve("/", &[sheet_dir, path]); + let relative = jsp::to_posix(&jsp::relative("/", page_dir, &absolute)); + if relative.is_empty() { + return whole.to_string(); + } + let needs_quotes = quote.is_empty() + && relative + .chars() + .any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\'')); + let quote = if needs_quotes { "\"" } else { quote }; + format!("url({quote}{relative}{suffix}{quote})") + }) + .into_owned() } static PSEUDO_RULE_RE: Lazy = Lazy::new(|| { diff --git a/crates/html/src/cascade/shorthand.rs b/crates/html/src/cascade/shorthand.rs index d33720214..53b690b9f 100644 --- a/crates/html/src/cascade/shorthand.rs +++ b/crates/html/src/cascade/shorthand.rs @@ -257,12 +257,22 @@ fn parse_static_background_layers(value: &str) -> (String, String) { /// part of `expand_static_declaration`, whose output the recorded vectors /// pin; the cascade stores these beside it (`apply_static_longhand`) so the /// sampled-contrast path (#560) can tell a tiled or cover image from a -/// no-repeat icon. A `background` shorthand with an image resets both to -/// what it names, as in CSS. +/// no-repeat icon. Every `background` shorthand resets both to what it +/// names, the defaults when it names nothing, as in CSS; a CSS-wide keyword +/// passes through, and a bare `var()` value is left alone the way the +/// expansion leaves it. pub fn background_longhands(prop: &str, value: &str) -> Vec { let v = js::trim(value); match js::to_lower_case(prop).as_str() { - "background" if BG_IMAGE_RE.is_match(v) => { + "background" if VAR_ANYWHERE_RE.is_match(v) && !BG_IMAGE_RE.is_match(v) => Vec::new(), + "background" if CSS_WIDE_KEYWORD_RE.is_match(v) => { + let keyword = js::to_lower_case(v); + vec![ + ("backgroundRepeat".into(), keyword.clone()), + ("backgroundSize".into(), keyword), + ] + } + "background" => { let (repeat, size) = parse_static_background_layers(v); vec![ ("backgroundRepeat".into(), repeat), @@ -275,6 +285,9 @@ pub fn background_longhands(prop: &str, value: &str) -> Vec { } } +static CSS_WIDE_KEYWORD_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?i)^(?:inherit|initial|unset|revert|revert-layer)$").expect("CSS_WIDE_KEYWORD_RE") +}); static BG_IMAGE_RE: Lazy = Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE")); static BG_IMAGE_SPLIT_RE: Lazy = Lazy::new(|| { diff --git a/crates/html/src/engine.rs b/crates/html/src/engine.rs index 50023d429..e0003fd92 100644 --- a/crates/html/src/engine.rs +++ b/crates/html/src/engine.rs @@ -213,11 +213,10 @@ pub fn detect_html_source( Meta::new("parse-html", "parse-document", fp), || StaticDocument::parse(html), ); - let (css_text, sheet_dirs) = - collect_static_css_text(&doc, &file_dir, profile, fp, options.warn); + let css_text = collect_static_css_text(&doc, &file_dir, profile, fp, options.warn); build_static_style_map(&mut doc, css_text.as_str(), profile, fp); let doc = doc; - let images = ImageSampler::new(&file_dir.to_string_lossy(), &sheet_dirs); + let images = ImageSampler::new(&file_dir.to_string_lossy()); let mut findings: Vec = Vec::new(); let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0); @@ -402,7 +401,7 @@ pub fn unsupported_selectors(html: &str, file_path: &Path) -> Vec { .map(|p| p.to_path_buf()) .unwrap_or_default(); let mut doc = StaticDocument::parse(html); - let (css_text, _) = collect_static_css_text(&doc, &file_dir, None, &file_str, None); + let css_text = collect_static_css_text(&doc, &file_dir, None, &file_str, None); build_static_style_map(&mut doc, &css_text, None, &file_str); doc.unsupported_selectors() } diff --git a/crates/html/src/image_sampling.rs b/crates/html/src/image_sampling.rs index 20d095209..131444b7d 100644 --- a/crates/html/src/image_sampling.rs +++ b/crates/html/src/image_sampling.rs @@ -1,17 +1,22 @@ //! The pixels behind image-backed text for the static engine (#560). A -//! `url()` resolves to bytes from a local file next to the markup or from a -//! base64 data URI, never from the network; the pure-Rust decoders turn them -//! into a raster no larger than the browser overlay's 640px canvas; and the -//! raster is cached for the rest of the process, so a directory scan decodes -//! each hero once. Anything unreadable, remote, oversized, or undecodable is -//! `None`, and the caller keeps today's skip. +//! `url()` resolves to bytes from a local file relative to the page (linked +//! stylesheets have their urls rewritten to page-relative form when they are +//! inlined, see `rewrite_sheet_urls`) or from a base64 data URI, never from +//! the network; the pure-Rust decoders turn them into a raster no larger than +//! the browser overlay's 640px canvas; and the raster is cached for the rest +//! of the process, so a directory scan decodes each hero once. Anything +//! unreadable, remote, oversized, or undecodable is `None`, and the caller +//! keeps today's skip. The same byte budget bounds a file on disk and a data +//! URI's payload, checked before anything is copied or decoded. use crate::cascade::resolve_linked_css_path; use base64::Engine; use impeccable_common::jsp; use impeccable_core::color::Rgba; +use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use std::io::Cursor; use std::rc::Rc; @@ -20,6 +25,9 @@ const MAX_RASTER_SIDE: u32 = 640; /// Files above this are not read: a hero is a few megabytes, and the hook /// runs on every edit. const MAX_FILE_BYTES: u64 = 24 * 1024 * 1024; +/// The base64 payload length that decodes to [`MAX_FILE_BYTES`], so a data +/// URI is refused before its payload is copied or decoded. +const MAX_DATA_URI_CHARS: usize = (MAX_FILE_BYTES as usize / 3) * 4 + 4; const MAX_IMAGE_SIDE: u32 = 8192; const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024; /// Decoded rasters kept per process; the map is cleared when full. @@ -49,22 +57,18 @@ thread_local! { static RASTERS: RefCell>>> = RefCell::new(HashMap::new()); } -/// Resolves and decodes the `url()` grounds of one document. The bases are -/// the document's directory followed by every linked stylesheet's, since a -/// relative `url()` inside a sheet is relative to the sheet. +/// Resolves and decodes the `url()` grounds of one document, relative to +/// the document's directory. A url from a linked stylesheet reaches the +/// cascade already rewritten to page-relative form. pub struct ImageSampler { - bases: Vec, + base: String, } impl ImageSampler { - pub fn new(html_dir: &str, stylesheet_dirs: &[String]) -> Self { - let mut bases = vec![html_dir.to_string()]; - for dir in stylesheet_dirs { - if !bases.contains(dir) { - bases.push(dir.clone()); - } + pub fn new(html_dir: &str) -> Self { + ImageSampler { + base: html_dir.to_string(), } - ImageSampler { bases } } /// The raster behind a `url()` argument, or `None` when it cannot be @@ -76,7 +80,12 @@ impl ImageSampler { return None; } let key = if is_data_uri(url) { - url.to_string() + // Refused before anything is allocated: a project file can carry + // any size of data URI, and the hook scans on every edit. + if url.len() > MAX_DATA_URI_CHARS + 256 { + return None; + } + data_uri_key(url) } else { self.resolve_file(url)? }; @@ -103,14 +112,11 @@ impl ImageSampler { if url.starts_with("//") || url.contains("://") { return None; } - self.bases - .iter() - .map(|base| resolve_linked_css_path(base, url)) - .find(|path| { - std::fs::metadata(path) - .map(|m| m.is_file()) - .unwrap_or(false) - }) + let path = resolve_linked_css_path(&self.base, url); + std::fs::metadata(&path) + .map(|m| m.is_file()) + .unwrap_or(false) + .then_some(path) } } @@ -118,6 +124,14 @@ fn is_data_uri(url: &str) -> bool { url.len() > 5 && url[..5].eq_ignore_ascii_case("data:") } +/// The cache key of a data URI: its length and a hash, so the cache never +/// holds a copy of the URI itself. +fn data_uri_key(url: &str) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + format!("data:{}:{:016x}", url.len(), hasher.finish()) +} + /// The name a finding gives the image: the file name, or `data:`. pub fn ground_label(url: &str) -> String { let url = url.trim(); @@ -147,10 +161,17 @@ fn data_uri_bytes(url: &str) -> Option> { { return None; } - let compact: String = payload.chars().filter(|c| !c.is_whitespace()).collect(); + if payload.len() > MAX_DATA_URI_CHARS { + return None; + } + let compact: Cow = if payload.chars().any(char::is_whitespace) { + Cow::Owned(payload.chars().filter(|c| !c.is_whitespace()).collect()) + } else { + Cow::Borrowed(payload) + }; base64::engine::general_purpose::STANDARD - .decode(&compact) - .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&compact)) + .decode(compact.as_ref()) + .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(compact.as_ref())) .ok() } @@ -212,7 +233,7 @@ mod tests { "data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(&bytes) ); - let sampler = ImageSampler::new("/nonexistent", &[]); + let sampler = ImageSampler::new("/nonexistent"); let raster = sampler.load(&uri).expect("png data uri decodes"); assert_eq!((raster.width, raster.height), (8, 8)); assert_eq!(raster.pixel(3, 3).r, 240.0); @@ -221,24 +242,29 @@ mod tests { assert!(sampler.load("https://example.com/hero.jpg").is_none()); assert!(sampler.load("//cdn.example.com/hero.jpg").is_none()); assert_eq!(ground_label("img/hero.jpg?v=3"), "hero.jpg"); + // A payload past the byte budget is refused before it is decoded. + let huge = format!( + "data:image/png;base64,{}", + "A".repeat(MAX_DATA_URI_CHARS + 1) + ); + assert!(sampler.load(&huge).is_none()); + assert_ne!(data_uri_key(&uri), data_uri_key(&huge)); } #[test] - fn files_resolve_against_every_base_and_downscale() { + fn files_resolve_against_the_page_dir_and_downscale() { let dir = std::env::temp_dir().join(format!("impeccable-sampler-{}", std::process::id())); - let sheet_dir = dir.join("css"); - std::fs::create_dir_all(&sheet_dir).unwrap(); + std::fs::create_dir_all(dir.join("img")).unwrap(); std::fs::write( - sheet_dir.join("wide.png"), + dir.join("img").join("wide.png"), png_bytes(1280, 320, [20, 20, 20, 255]), ) .unwrap(); - let html_dir = dir.to_string_lossy().into_owned(); - let sampler = ImageSampler::new(&html_dir, &[sheet_dir.to_string_lossy().into_owned()]); - assert!(sampler.load("missing.png").is_none()); + let sampler = ImageSampler::new(&dir.to_string_lossy()); + assert!(sampler.load("wide.png").is_none()); let raster = sampler - .load("wide.png") - .expect("resolves against the sheet dir"); + .load("img/wide.png") + .expect("resolves against the page dir"); assert_eq!((raster.width, raster.height), (640, 160)); assert_eq!( (raster.intrinsic_width, raster.intrinsic_height), @@ -246,7 +272,7 @@ mod tests { ); assert_eq!(raster.pixel(639, 159).g, 20.0); // The second load is the cached raster. - assert!(Rc::ptr_eq(&raster, &sampler.load("wide.png").unwrap())); + assert!(Rc::ptr_eq(&raster, &sampler.load("img/wide.png").unwrap())); std::fs::remove_dir_all(&dir).ok(); } } diff --git a/crates/html/tests/cascade_units.rs b/crates/html/tests/cascade_units.rs index d4a93bddc..84098b404 100644 --- a/crates/html/tests/cascade_units.rs +++ b/crates/html/tests/cascade_units.rs @@ -249,30 +249,48 @@ fn background_longhands_ride_beside_the_expansion() { background_longhands("Background-Size", "100% 32px"), vec![("backgroundSize".to_string(), "100% 32px".to_string())] ); - assert!(background_longhands("background", "#fff").is_empty()); + // A color-only shorthand still resets both longhands, as in CSS. + assert_eq!( + background_longhands("background", "#fff"), + vec![ + ("backgroundRepeat".to_string(), "repeat".to_string()), + ("backgroundSize".to_string(), "auto".to_string()), + ] + ); + assert_eq!( + background_longhands("background", "Inherit"), + vec![ + ("backgroundRepeat".to_string(), "inherit".to_string()), + ("backgroundSize".to_string(), "inherit".to_string()), + ] + ); + assert!(background_longhands("background", "var(--surface)").is_empty()); assert!(background_longhands("color", "red").is_empty()); // A later shorthand with an image resets an earlier longhand, and a // later longhand overrides a shorthand, under the cascade's priority. let mut specified: SpecifiedStore<&str> = SpecifiedStore::new(); let node = "n1"; - let mut apply = |prop: &str, value: &str, m: DeclMeta| { - apply_static_declaration(&mut specified, node, prop, value, &m); + let apply = |specified: &mut SpecifiedStore<&str>, prop: &str, value: &str, m: DeclMeta| { + apply_static_declaration(specified, node, prop, value, &m); for (p, v) in background_longhands(prop, value) { - apply_static_longhand(&mut specified, node, &p, &v, &m); + apply_static_longhand(specified, node, &p, &v, &m); } }; apply( + &mut specified, "background-repeat", "no-repeat", meta(false, [0, 1, 0], 0, false), ); apply( + &mut specified, "background", "url(hero.jpg) center / cover", meta(false, [0, 1, 0], 1, false), ); apply( + &mut specified, "background-size", "contain", meta(false, [0, 1, 0], 2, false), @@ -290,4 +308,65 @@ fn background_longhands_ride_beside_the_expansion() { map.get("backgroundImage").map(|d| d.value.as_str()), Some("url(hero.jpg) center / cover") ); + // `background: #fff` after a longhand resets it, so a later + // `background-image` is classified with the defaults, not a stale value. + apply( + &mut specified, + "background-repeat", + "no-repeat", + meta(false, [0, 1, 0], 3, false), + ); + apply( + &mut specified, + "background", + "#fff", + meta(false, [0, 1, 0], 4, false), + ); + let map = specified.get(&node).expect("node entry"); + assert_eq!( + map.get("backgroundRepeat").map(|d| d.value.as_str()), + Some("repeat") + ); + assert_eq!( + map.get("backgroundSize").map(|d| d.value.as_str()), + Some("auto") + ); +} + +#[test] +fn linked_sheet_urls_are_rewritten_page_relative() { + use impeccable_html::cascade::build::rewrite_sheet_urls; + + let css = concat!( + ".a { background: url(light.png) }\n", + ".b { background: url(\"../img/hero.jpg?v=3\") no-repeat }\n", + ".c { background-image: url('./x.webp'), url(/root.png), url(data:image/png;base64,AAAA) }\n", + ".d { background: url(https://cdn.example.com/a.png) }\n", + ".e { mask: url(#clip) }\n", + ".f { background: URL( a b.png ) }\n", + ); + let out = rewrite_sheet_urls(css, "/site/css", "/site"); + assert!( + out.contains(".a { background: url(css/light.png) }"), + "{out}" + ); + assert!( + out.contains(".b { background: url(\"img/hero.jpg?v=3\") no-repeat }"), + "{out}" + ); + assert!( + out.contains("url('css/x.webp'), url(/root.png), url(data:image/png;base64,AAAA)"), + "{out}" + ); + assert!(out.contains("url(https://cdn.example.com/a.png)"), "{out}"); + assert!(out.contains("url(#clip)"), "{out}"); + assert!(out.contains("url(\"css/a b.png\")"), "{out}"); + // A sheet beside the page keeps every path where it was (the engine + // does not even call the rewrite for that case). + let same = rewrite_sheet_urls(css, "/site", "/site"); + assert!(same.contains(".a { background: url(light.png) }"), "{same}"); + assert!(same.contains("url(\"../img/hero.jpg?v=3\")"), "{same}"); + // A sheet above the page walks back up. + let up = rewrite_sheet_urls(".a { background: url(light.png) }", "/site", "/site/pages"); + assert_eq!(up, ".a { background: url(../light.png) }"); } diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index a89ab017e..db7e0abaf 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -332,7 +332,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` ( #### Static and regex engines (only what affects the contract) - `detectHtml`: reads file, imports `htmlparser2`, `css-select`, `css-tree`, `domutils`; on import failure prints once to stderr `impeccable detect: DEGRADED - HTML parser modules unavailable (htmlparser2, css-select, css-tree, domutils).\nFalling back to regex matching. Custom properties, selector matching and computed contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n` and falls back to `detectText`. Inlines `` that are local (not `/^(https?:)?\/\//i`), query/hash stripped. Runs element rules, design-system rules (`checkSourceDesignSystem` + `collectStaticDesignSystemFindings`, merged), then page rules only when `isFullPage(html)` (`/]|]/i` after stripping comments), plus text-content analyzers; ends with inline-ignore filtering. -- **Sampled contrast (#560, engine only, no JS ancestor)**: when `resolveBackgroundInfo` ends at a `url()` layer (the case the JS skipped), the static engine reads that image and measures the text against its pixels. The url resolves to a local file (relative to the page, then to each linked stylesheet's directory; root-relative paths walk up to the project root the way linked stylesheets do) or a base64 `data:` URI; a remote URL, a missing or unreadable file, a file over 24 MiB, an image over 8192px on a side, and SVG keep the skip. Decoders: PNG, JPEG, GIF, WebP. The image is scaled to at most 640px on a side and read on a fixed 6x6 grid. Each sample composites a translucent pixel over the element's own color and its parent's resolved ground (white at the root; unknown when another layer sits beneath the image), then under every translucent surface and every uniform gradient wash between the text and the image; a gradient whose stops differ is a scrim placed on purpose and keeps the skip, as does an opaque gradient or an unparseable color anywhere in the chain. A layer whose `background-repeat` leaves an axis unrepeated and whose painted extent on that axis (`background-size` in px, else the intrinsic size) is under 160px is decoration and keeps the skip; `cover`, `contain`, and percentage sizes always paint. The cascade carries `backgroundRepeat` / `backgroundSize` for this from the longhands and from the `background` shorthand (per layer, defaults `repeat` / `auto`). A verdict needs 27 of the 36 samples; the finding fires when the 90th-percentile ratio is under the WCAG threshold (same large-text rule as `checkColors`, text alpha blended over the sample): `{id:'low-contrast', snippet:`sampled (coarse) ${p90}:1 (need ${threshold}:1) — text ${hex} on ${file name | data:}; p90 of ${n} samples, median ${median}:1`}` (ratio label to 2 decimals when its 1-decimal form equals the threshold, as in `checkColors`). It is emitted in the `color-rules` pass right after `checkColors`'s hits for that element, obeys the same `SAFE_TAGS` gate, `data-impeccable-ignore`, and inline ignores, and never produces `gray-on-color`. Fixture: `sampled-image-contrast.html` (+ `sampled-images/`). +- **Sampled contrast (#560, engine only, no JS ancestor)**: when `resolveBackgroundInfo` ends at a `url()` layer (the case the JS skipped), the static engine reads that image and measures the text against its pixels. The url resolves to a local file relative to the page (a linked stylesheet from another directory has its relative urls rewritten to page-relative form when it is inlined, so a url resolves against the sheet that declared it; root-relative paths walk up to the project root the way linked stylesheets do) or a base64 `data:` URI; a remote URL, a missing or unreadable file, a file over 24 MiB, a data URI whose payload decodes to more than that, an image over 8192px on a side, and SVG keep the skip. Decoders: PNG, JPEG, GIF, WebP. The image is scaled to at most 640px on a side and read on a fixed 6x6 grid. Each sample composites a translucent pixel over the element's own color and its parent's resolved ground (white at the root; unknown when another layer sits beneath the image), then under every translucent surface and every uniform gradient wash between the text and the image; a gradient whose stops differ is a scrim placed on purpose and keeps the skip, as does an opaque gradient or an unparseable color anywhere in the chain. A layer whose `background-repeat` leaves an axis unrepeated and whose painted extent on that axis (`background-size` in px, else the intrinsic size) is under 160px is decoration and keeps the skip; `cover`, `contain`, and percentage sizes always paint. The cascade carries `backgroundRepeat` / `backgroundSize` for this from the longhands and from the `background` shorthand (per layer, defaults `repeat` / `auto`). A verdict needs 27 of the 36 samples; the finding fires when the 90th-percentile ratio is under the WCAG threshold (same large-text rule as `checkColors`, text alpha blended over the sample): `{id:'low-contrast', snippet:`sampled (coarse) ${p90}:1 (need ${threshold}:1) — text ${hex} on ${file name | data:}; p90 of ${n} samples, median ${median}:1`}` (ratio label to 2 decimals when its 1-decimal form equals the threshold, as in `checkColors`). It is emitted in the `color-rules` pass right after `checkColors`'s hits for that element, obeys the same `SAFE_TAGS` gate, `data-impeccable-ignore`, and inline ignores, and never produces `gray-on-color`. Fixture: `sampled-image-contrast.html` (+ `sampled-images/`). - `detectText`: regex line matchers (ids: side-tab, border-accent-on-rounded, overused-font, gradient-text, ai-color-palette, gray-on-color, bounce-easing, layout-transition, broken-image), inset-stripe/pseudo-stripe CSS scans, `codex-grid-background`, `