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`, `