Sampled contrast: bound data URIs, resolve sheet urls, reset shorthand

Review fixes for the sampled-contrast path (#560):

- A base64 data URI is refused before anything is copied or decoded
  when its payload would exceed the 24 MiB budget a file gets, the
  cache keys it by length and hash instead of holding the URI, and a
  payload without whitespace is decoded in place.
- A linked stylesheet from another directory has its relative url()s
  rewritten to page-relative form when it is inlined, so the sampler
  resolves the image the winning declaration named against one base
  instead of guessing across every sheet directory.
- Every `background` shorthand resets backgroundRepeat and
  backgroundSize to what it names, the defaults when it names nothing,
  so `background: #fff` no longer leaves a stale no-repeat behind for a
  later background-image; CSS-wide keywords pass through and a bare
  var() value is left alone the way the expansion leaves it.

Tests cover the bound, the rewrite (relative, parent, quoted, query,
root-relative, remote, data, fragment, whitespace), and the reset order.
Goldens are unchanged.

AI assistance: Claude Code (Claude Fable 5.1), on maintainer instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-09-10 10:30:34 +05:00
co-authored by Claude Fable 5.1
parent a82233736c
commit 66945a79da
6 changed files with 228 additions and 60 deletions
+59 -8
View File
@@ -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>) {
) -> String {
let mut style_texts: Vec<String> = Vec::new();
let mut sheet_dirs: Vec<String> = Vec::new();
let mut warned_missing_stylesheets: std::collections::HashSet<String> =
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<Regex> = Lazy::new(|| {
Regex::new(r#"(?i)url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)"']*?))\s*\)"#)
.expect("CSS_URL_RE")
});
static URL_SCHEME_RE: Lazy<Regex> =
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: &regex::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<Regex> = Lazy::new(|| {
+16 -3
View File
@@ -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<Expanded> {
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<Expanded> {
}
}
static CSS_WIDE_KEYWORD_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:inherit|initial|unset|revert|revert-layer)$").expect("CSS_WIDE_KEYWORD_RE")
});
static BG_IMAGE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE"));
static BG_IMAGE_SPLIT_RE: Lazy<Regex> = Lazy::new(|| {
+3 -4
View File
@@ -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<Finding> = 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<String> {
.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()
}
+66 -40
View File
@@ -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<HashMap<String, Option<Rc<Raster>>>> = 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<String>,
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:<mime>`.
pub fn ground_label(url: &str) -> String {
let url = url.trim();
@@ -147,10 +161,17 @@ fn data_uri_bytes(url: &str) -> Option<Vec<u8>> {
{
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<str> = 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();
}
}
+83 -4
View File
@@ -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) }");
}
+1 -1
View File
@@ -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 `<link rel=stylesheet href>` 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)` (`/<!doctype\s|<html[\s>]|<head[\s>]/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:<mime>}; 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:<mime>}; 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`, `<style>` blocks (Astro/Vue/Svelte), CSS-in-JS templates, design-system source checks; dedupe (same antipattern+snippet within 2 lines); page analyzers only when `isFullPage` and ext ∈ `{'.html','.htm','.astro','.vue','.svelte'}` or no ext (`<stdin>`): flat-type-hierarchy, monotonous-spacing, em-dash-overuse, marketing-buzzword, aphoristic-cadence, dark-glow (+ radial-halo, marquee); inline ignores last.
#### Profiler (`cli/engine/profile/profiler.mjs`)