Compare commits

...
Author SHA1 Message Date
Abdul WahabandClaude Fable 5.1 dacf7d4664 Refresh the in-page bundle for the sampled-contrast core changes
The core crate changed on this branch (the sampled-contrast module and
the shared safe-tag gate in check_colors), so the tracked in-page
bundle is regenerated, as docs/ENGINE.md asks after touching
crates/core. This machine's rust-lld cannot link wasm, so the bundle is
the one CI's `bun run build:extension` produced for this branch (run
34444485574 on 634b579b6), taken from its dist artifact. Its registry
JSON is byte-identical to the tracked one: no rule was added. Under the
same CI toolchain the bundle differs from main's by 208 bytes, with no
rule or adapter behavior change.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:31:55 +05:00
Abdul WahabandClaude Fable 5.1 634b579b63 Sampled contrast: url() is a token, not a suffix of an identifier
The sheet url rewrite and its comment scan both matched `url(` inside a
longer identifier, so `myurl(a /* url(x.png) */)` read as an unquoted
url and the comment inside it as url content. Both now require a
non-identifier byte before `url(`, the way the tokenizer does; the
regex re-emits that byte unchanged. A test pins a custom function
wrapping a comment beside a real url on the same rule.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:16:24 +05:00
Abdul WahabandClaude Fable 5.1 4c5f3974b3 Sampled contrast: comment scan honors CSS strings and url tokens
The sheet url rewrite skipped comments by looking for `/*` in the raw
text, which is not how the CSS tokenizer sees it: a `/*` inside a quoted
string or an unquoted url() is content, so `content: "a/*b"` turned the
rest of the sheet into a comment and its relative urls stayed
sheet-relative, where the sampler could not find them. The scan is a
small state machine now (code, string, unquoted url) that mirrors the
tokenizer: a string ends at its quote or at a raw newline, a url at its
closing paren, and only a `/*` in code opens a comment. Tests pin a
string with a marker, a url with a marker, and a real comment after a
string.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:03:50 +05:00
Abdul WahabandClaude Fable 5.1 2422ec058f Sampled contrast: keep the sheet url rewrite inside one url token
The linked-sheet url rewrite ran one regex over the raw stylesheet text,
and its unquoted `url(` form could match across lines to a `)` in a
later rule, so an unclosed `url(` inside a comment would have replaced
the rules between them with one rewritten path, and that text feeds the
whole cascade, not only sampled contrast. Comments are now copied
through untouched, a quoted url ends at its line, and an unquoted one
stops at whitespace, quotes, parens, braces, or a semicolon, as CSS
does. Tests pin a commented `url(`, an unclosed quote, and an
unterminated comment.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:52:08 +05:00
Abdul WahabandClaude Fable 5.1 0f115e5d37 Sampled contrast: no panic on multibyte urls, any layer order, auto size
Second review round for the sampled-contrast path (#560), plus the
Windows CI fix:

- `is_data_uri` byte-sliced the url, so a local image whose name opens
  with a multibyte character panicked the static scanner. It reads the
  prefix with `get` now.
- The ground walk only accepted a layer that begins with `url(`, while a
  `background` shorthand may put color, position, size, or repeat tokens
  before the image; such layers fell out of the sampled path. The call is
  found anywhere in the layer and decoded from there. A fixture case pins
  it, so the fixture and directory goldens gain one finding.
- The decoration gate scaled the missing size axis only for a single
  `40px` token; `40px auto` and `auto 40px` now follow the image's aspect
  ratio the same way.
- The linked-sheet url rewrite did its path math through the OS-flavored
  helpers, which rendered `css/light.png` as `light.png` on Windows. It
  uses the POSIX helpers on both directories, since a CSS url is POSIX on
  every OS.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:43:07 +05:00
Abdul WahabandClaude Fable 5.1 66945a79da 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>
2026-09-10 10:30:34 +05:00
Abdul WahabandClaude Fable 5.1 a82233736c Static engine: pixel-sample image backgrounds behind text
The analytic walk skips the contrast check when a url() image layer backs
text, so CLI scans, CI gates, and the hook had no answer for image-backed
text. Outside a browser there is no CORS and no canvas taint: the static
engine now reads the image itself, a local file next to the markup or a
base64 data URI, decoded with the pure-Rust image crate, sampled on a fixed
6x6 grid, and measured against the text. Static scans have no layout, so
the verdict is coarse by design: the finding fires only when the 90th
percentile of the grid fails. Uniform tint washes composite exactly,
varying scrims keep the skip, no-repeat icons count as decoration, and a
remote URL is never fetched.

The cascade carries backgroundRepeat / backgroundSize for the decoration
gate, stored beside the background shorthand expansion rather than inside
it, because the frozen vectors pin that expansion.

Fixture sampled-image-contrast.html with generated PNG, JPEG, WebP, alpha
and icon images; goldens recorded for the new cases, and the directory
sweeps re-recorded (only the fixture's eight findings moved), noted in
tests/oracle/DELTAS.md.

Refs #560.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:08:00 +05:00
35 changed files with 1551 additions and 85 deletions
Generated
+3
View File
@@ -509,6 +509,7 @@ dependencies = [
"image-webp", "image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png",
"zune-core", "zune-core",
"zune-jpeg", "zune-jpeg",
] ]
@@ -671,9 +672,11 @@ dependencies = [
name = "impeccable-html" name = "impeccable-html"
version = "0.1.5" version = "0.1.5"
dependencies = [ dependencies = [
"base64",
"cssparser", "cssparser",
"ego-tree", "ego-tree",
"html5ever", "html5ever",
"image",
"impeccable-common", "impeccable-common",
"impeccable-core", "impeccable-core",
"impeccable-detect", "impeccable-detect",
+1 -1
View File
@@ -465,7 +465,7 @@ npx impeccable ignores add-value overused-font Inter --reason "Brand font"
The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more). The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports. Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. File scans read the pixels of local background images behind text, so white copy on a near-white photo fails a CI gate without a browser; a remote image is never fetched. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution. By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
+4
View File
@@ -29,6 +29,9 @@
//! checkNumberedSectionLabels, checkEmDashOveruse, isRepeatedTextContainer. //! checkNumberedSectionLabels, checkEmDashOveruse, isRepeatedTextContainer.
//! Open (selector and tag lists, thresholds, the two text parsers): //! Open (selector and tag lists, thresholds, the two text parsers):
//! `impeccable_foundation::rules::text`. //! `impeccable_foundation::rules::text`.
//! - `sampled_contrast`: the static engine's pixel-sampled verdict for text
//! over a `url()` background (#560): grid, decoration gate, wash and
//! compositing rules, the percentile verdict. No JS ancestor.
//! //!
//! Element/document adapters (`checkElement*`, `*DOM`, `*FromDoc`) are NOT in //! Element/document adapters (`checkElement*`, `*DOM`, `*FromDoc`) are NOT in
//! core: the static ones live in the `html` crate against its DOM model, the //! core: the static ones live in the `html` crate against its DOM model, the
@@ -43,6 +46,7 @@ pub mod css_scan;
pub mod html_patterns; pub mod html_patterns;
pub mod measures; pub mod measures;
pub mod rules; pub mod rules;
pub mod sampled_contrast;
pub mod text_rules; pub mod text_rules;
#[cfg(feature = "vectors")] #[cfg(feature = "vectors")]
+19 -10
View File
@@ -135,21 +135,30 @@ fn is_heading_123(tag: &str) -> bool {
matches!(tag, "h1" | "h2" | "h3") matches!(tag, "h1" | "h2" | "h3")
} }
/// The `SAFE_TAGS` skip at the top of `checkColors`: a link, control, or
/// quote is measured only when it paints its own surface. Shared with the
/// sampled-contrast path so both skip the same elements.
pub fn safe_tag_unstyled(opts: &ColorOpts) -> bool {
if !set_has(SAFE_TAGS, opts.tag.as_str()) {
return false;
}
let bg_image = opts.bg_image.as_deref().unwrap_or("");
let own_bg = opts
.bg_color
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
let is_styled_control =
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
!is_styled_control
}
/// JS: checks.mjs#checkColors /// JS: checks.mjs#checkColors
pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> { pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
let tag = opts.tag.as_str(); let tag = opts.tag.as_str();
let bg_image = opts.bg_image.as_deref().unwrap_or(""); let bg_image = opts.bg_image.as_deref().unwrap_or("");
let bg_clip = opts.bg_clip.as_deref().unwrap_or(""); let bg_clip = opts.bg_clip.as_deref().unwrap_or("");
if set_has(SAFE_TAGS, tag) { if safe_tag_unstyled(opts) {
let own_bg = opts return Vec::new();
.bg_color
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
let is_styled_control =
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
if !is_styled_control {
return Vec::new();
}
} }
let mut findings = Vec::new(); let mut findings = Vec::new();
+302
View File
@@ -0,0 +1,302 @@
//! Pixel-sampled contrast for text over a `url()` background image in the
//! static engine (#560). Outside a browser there is no CORS and no canvas
//! taint, so the engine reads the image itself; what it cannot know is
//! layout. The verdict is therefore coarse: a fixed grid over the whole
//! image, and a finding only when nearly all of it fails. A photo with a dark
//! third under the headline passes; white text on a near-white texture does
//! not, wherever the text sits.
//!
//! This module is the decisions: grid geometry, the decoration gate, the
//! wash and compositing rules, the percentile verdict and its label. Reading
//! and decoding the image is the html crate's job.
use crate::checks::rules::{safe_tag_unstyled, ColorOpts, RuleHit};
use crate::color::{color_to_hex, composite_color_over, contrast_ratio, Rgba};
use crate::constants::{WCAG_LARGE_BOLD_TEXT_PX, WCAG_LARGE_TEXT_PX};
use crate::js::{number_to_string, parse_float, to_fixed};
/// Grid points per axis: 6x6 = 36 samples, sampling rather than scanning.
const GRID: usize = 6;
/// A `no-repeat` layer painted smaller than this on a non-repeating axis is
/// an icon, badge, or rule, not the ground the text sits on.
const DECORATION_MAX_PX: f64 = 160.0;
/// The share of the grid that has to resolve to a color before a verdict.
const MIN_SAMPLE_SHARE: f64 = 0.75;
/// The finding fires when this share of the samples fails: the text is
/// somewhere on the image, so only a near-uniform failure is a failure.
const FAIL_PERCENTILE: f64 = 0.9;
/// The color rule's own gates, so the sampler never fires where
/// `check_colors` would not have measured a resolved background.
pub fn applies(opts: &ColorOpts) -> bool {
opts.has_direct_text
&& opts.text_color.is_some()
&& !opts.is_emoji_only
&& opts.bg_clip.as_deref() != Some("text")
&& !safe_tag_unstyled(opts)
}
/// The sample positions over a `width` x `height` raster: cell centers of
/// the `GRID`, row-major.
pub fn grid_points(width: usize, height: usize) -> Vec<(usize, usize)> {
if width == 0 || height == 0 {
return Vec::new();
}
let at = |i: usize, extent: usize| -> usize {
let v = ((i as f64 + 0.5) / GRID as f64 * extent as f64).floor() as usize;
v.min(extent - 1)
};
let mut points = Vec::with_capacity(GRID * GRID);
for gy in 0..GRID {
for gx in 0..GRID {
points.push((at(gx, width), at(gy, height)));
}
}
points
}
/// Whether a layer with this `background-repeat` and `background-size`
/// (the layer's own comma-list entries) paints too little to be the ground:
/// a non-repeating axis whose painted extent is under
/// `DECORATION_MAX_PX`. Repeating, `cover`, `contain`, and percentage sizes
/// fill the box and are never decoration.
pub fn is_decorative_layer(repeat: &str, size: &str, intrinsic_w: f64, intrinsic_h: f64) -> bool {
let repeat = repeat.trim().to_ascii_lowercase();
let tokens: Vec<&str> = repeat.split_whitespace().collect();
let (repeat_x, repeat_y) = match tokens.as_slice() {
["no-repeat"] => (false, false),
["repeat-x"] => (true, false),
["repeat-y"] => (false, true),
[x, y] => (*x != "no-repeat", *y != "no-repeat"),
_ => (true, true),
};
if repeat_x && repeat_y {
return false;
}
let size = size.trim().to_ascii_lowercase();
if size == "cover" || size == "contain" || size.contains('%') {
return false;
}
let tokens: Vec<&str> = size.split_whitespace().collect();
let px = |t: Option<&&str>| t.filter(|t| t.ends_with("px")).map(|t| parse_float(t));
// An `auto` (or absent) axis follows the other at the image's aspect
// ratio, as in CSS: `40px`, `40px auto`, and `auto 40px` all scale.
let (w, h) = match (px(tokens.first()), px(tokens.get(1))) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) if intrinsic_w > 0.0 => (w, intrinsic_h * (w / intrinsic_w)),
(None, Some(h)) if intrinsic_h > 0.0 => (intrinsic_w * (h / intrinsic_h), h),
(Some(w), None) => (w, intrinsic_h),
(None, Some(h)) => (intrinsic_w, h),
(None, None) => (intrinsic_w, intrinsic_h),
};
(!repeat_x && w < DECORATION_MAX_PX) || (!repeat_y && h < DECORATION_MAX_PX)
}
/// A translucent gradient whose stops are all one color is a tint over the
/// image and composites exactly; a gradient that varies is a scrim placed
/// under the text on purpose, and without layout the engine cannot say
/// which stop the text sits on. `None` for the varying case.
pub fn uniform_wash(stops: &[Rgba]) -> Option<Rgba> {
let first = *stops.first()?;
let same = |s: &Rgba| {
s.r == first.r
&& s.g == first.g
&& s.b == first.b
&& (s.alpha_or_one() - first.alpha_or_one()).abs() < 1e-9
};
stops.iter().all(same).then_some(first)
}
/// One grid sample's ground color: the pixel, composited over `under` when
/// it is translucent, then under every `overlays` layer (top to bottom, as
/// the walk collected them). `None` when a translucent pixel has nothing
/// known beneath it.
pub fn composite_sample(pixel: Rgba, under: Option<Rgba>, overlays: &[Rgba]) -> Option<Rgba> {
let mut color = if pixel.alpha_or_one() >= 0.99 {
Rgba::new(pixel.r, pixel.g, pixel.b, 1.0)
} else {
composite_color_over(&pixel, &under?)
};
for overlay in overlays.iter().rev() {
color = composite_color_over(overlay, &color);
}
Some(color)
}
/// The verdict over the resolved `samples` of a grid of `grid_total` points,
/// against the text of `opts`. `ground` names the image in the snippet (its
/// file name, or `data:image/png`).
pub fn sampled_contrast(
opts: &ColorOpts,
ground: &str,
samples: &[Rgba],
grid_total: usize,
) -> Option<RuleHit> {
if !applies(opts) {
return None;
}
let text = opts.text_color?;
let needed = ((grid_total as f64) * MIN_SAMPLE_SHARE).ceil().max(1.0) as usize;
if samples.len() < needed {
return None;
}
let mut ratios: Vec<f64> = samples
.iter()
.map(|bg| {
let fg = if text.alpha_or_one() < 1.0 {
composite_color_over(&text, bg)
} else {
text
};
contrast_ratio(&fg, bg)
})
.collect();
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = ratios.len();
let pick = |share: f64| ratios[((share * n as f64).floor() as usize).min(n - 1)];
let measured = pick(FAIL_PERCENTILE);
let median = pick(0.5);
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if measured >= threshold {
return None;
}
let ratio_label = if to_fixed(measured, 1) == to_fixed(threshold, 1) {
to_fixed(measured, 2)
} else {
to_fixed(measured, 1)
};
Some(RuleHit::new(
"low-contrast",
format!(
"sampled (coarse) {}:1 (need {}:1) — text {} on {}; p90 of {} samples, median {}:1",
ratio_label,
number_to_string(threshold),
color_to_hex(Some(&text)),
ground,
n,
to_fixed(median, 1)
),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn rgba(r: f64, g: f64, b: f64, a: f64) -> Rgba {
Rgba::new(r, g, b, a)
}
fn opts(text: Rgba, font_size: f64) -> ColorOpts {
ColorOpts {
tag: "p".into(),
text_color: Some(text),
font_size,
font_weight: 400.0,
has_direct_text: true,
..Default::default()
}
}
#[test]
fn grid_covers_cell_centers_and_clamps() {
let points = grid_points(96, 64);
assert_eq!(points.len(), 36);
assert_eq!(points[0], (8, 5));
assert_eq!(points[35], (88, 58));
assert_eq!(grid_points(1, 1), vec![(0, 0); 36]);
assert!(grid_points(0, 10).is_empty());
}
#[test]
fn decoration_gate() {
assert!(is_decorative_layer("no-repeat", "auto", 24.0, 24.0));
assert!(is_decorative_layer("no-repeat", "40px", 1200.0, 800.0));
assert!(is_decorative_layer("no-repeat", "40px auto", 1200.0, 800.0));
assert!(is_decorative_layer("no-repeat", "auto 40px", 1200.0, 800.0));
assert!(!is_decorative_layer(
"no-repeat",
"auto 800px",
1200.0,
800.0
));
assert!(is_decorative_layer("repeat-y", "auto", 8.0, 400.0));
assert!(is_decorative_layer("repeat-x", "auto", 1200.0, 80.0));
assert!(!is_decorative_layer("no-repeat", "cover", 24.0, 24.0));
assert!(!is_decorative_layer("no-repeat", "100% auto", 24.0, 24.0));
assert!(!is_decorative_layer("no-repeat", "auto", 1600.0, 900.0));
assert!(!is_decorative_layer("repeat", "auto", 4.0, 4.0));
assert!(!is_decorative_layer("", "", 16.0, 16.0));
assert!(!is_decorative_layer(
"repeat no-repeat",
"auto",
1200.0,
300.0
));
assert!(is_decorative_layer(
"repeat no-repeat",
"auto",
1200.0,
30.0
));
}
#[test]
fn washes_and_compositing() {
let tint = rgba(0.0, 0.0, 0.0, 0.4);
assert_eq!(uniform_wash(&[tint, tint]), Some(tint));
assert_eq!(uniform_wash(&[tint, rgba(0.0, 0.0, 0.0, 0.0)]), None);
assert_eq!(uniform_wash(&[]), None);
let light = rgba(240.0, 240.0, 240.0, 1.0);
assert_eq!(composite_sample(light, None, &[]), Some(light));
let dimmed = composite_sample(light, None, &[tint]).unwrap();
assert_eq!((dimmed.r, dimmed.g, dimmed.b), (144.0, 144.0, 144.0));
let translucent = rgba(240.0, 240.0, 240.0, 0.25);
assert_eq!(composite_sample(translucent, None, &[]), None);
let over_dark =
composite_sample(translucent, Some(rgba(20.0, 20.0, 20.0, 1.0)), &[]).unwrap();
assert_eq!((over_dark.r, over_dark.a), (75.0, Some(1.0)));
}
#[test]
fn verdict_needs_a_near_uniform_failure() {
let white = rgba(253.0, 253.0, 253.0, 1.0);
let light = rgba(243.0, 239.0, 230.0, 1.0);
let dark = rgba(26.0, 24.0, 22.0, 1.0);
let all_light: Vec<Rgba> = vec![light; 36];
let hit = sampled_contrast(&opts(white, 16.0), "hero.png", &all_light, 36).unwrap();
assert_eq!(hit.id, "low-contrast");
assert_eq!(
hit.snippet,
"sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on hero.png; p90 of 36 samples, median 1.1:1"
);
// Half the image is dark: the text may well sit there.
let split: Vec<Rgba> = (0..36)
.map(|i| if i % 2 == 0 { light } else { dark })
.collect();
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &split, 36).is_none());
// Three dark samples out of 36 are not enough to save it.
let mostly: Vec<Rgba> = (0..36).map(|i| if i < 3 { dark } else { light }).collect();
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &mostly, 36).is_some());
// Too few resolved samples: no verdict.
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &all_light[..20], 36).is_none());
// Large text lowers the bar, and the snippet says so.
let mid = rgba(150.0, 150.0, 150.0, 1.0);
let on_mid: Vec<Rgba> = vec![mid; 36];
assert!(
sampled_contrast(&opts(white, 28.0), "hero.png", &on_mid, 36)
.unwrap()
.snippet
.contains("(need 3:1)")
);
// Safe tags and gradient-clipped text never sample.
let mut anchor = opts(white, 16.0);
anchor.tag = "a".into();
assert!(sampled_contrast(&anchor, "hero.png", &all_light, 36).is_none());
let mut clipped = opts(white, 16.0);
clipped.bg_clip = Some("text".into());
assert!(sampled_contrast(&clipped, "hero.png", &all_light, 36).is_none());
}
}
+5
View File
@@ -26,6 +26,11 @@ html5ever = "0.39"
selectors = "0.38" selectors = "0.38"
cssparser = "0.37" cssparser = "0.37"
thiserror = { workspace = true } thiserror = { workspace = true }
# Decoding the background images behind text for the sampled-contrast path
# (#560): pure-Rust decoders only (the comp crate's set plus `png`), so the
# wasm `detect` build keeps working and nothing native is linked.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
base64 = "0.22"
[dev-dependencies] [dev-dependencies]
impeccable-core = { workspace = true, features = ["vectors"] } impeccable-core = { workspace = true, features = ["vectors"] }
+49 -5
View File
@@ -6,11 +6,13 @@
//! and the computed style and hands plain data over. //! and the computed style and hands plain data over.
use crate::background::{ use crate::background::{
a_ge, a_gt, read_own_background_color, resolve_background, resolve_background_info, a_ge, a_gt, find_image_ground, read_own_background_color, resolve_background,
resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt, CustomPropMap, resolve_background_info, resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt,
CustomPropMap,
}; };
use crate::cascade::StyleValues; use crate::cascade::StyleValues;
use crate::dom::{StaticDocument, StaticElement}; use crate::dom::{StaticDocument, StaticElement};
use crate::image_sampling::{ground_label, ImageSampler};
use crate::quality::{collapse_ws, pf0, resolve_font_size_px}; use crate::quality::{collapse_ws, pf0, resolve_font_size_px};
use impeccable_core::checks::measures::{ use impeccable_core::checks::measures::{
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow, self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
@@ -24,13 +26,14 @@ use impeccable_core::checks::rules::{
GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts, ItalicSerifOpts, KickerCandidate, GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts, ItalicSerifOpts, KickerCandidate,
MotionOpts, RuleHit, Sides, MotionOpts, RuleHit, Sides,
}; };
use impeccable_core::checks::sampled_contrast;
use impeccable_core::checks::text_rules::{ use impeccable_core::checks::text_rules::{
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate, check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
parse_numbered_label_text, KickerCandidateInput, NumberedLabelCandidate, parse_numbered_label_text, KickerCandidateInput, NumberedLabelCandidate,
NumberedLabelCandidateInput, HEADING_TAGS, KICKER_CARD_CONTEXT_SELECTOR, KICKER_SKIP_SELECTOR, NumberedLabelCandidateInput, HEADING_TAGS, KICKER_CARD_CONTEXT_SELECTOR, KICKER_SKIP_SELECTOR,
POSITIONED_CHILD_INTERACTIVE_SELECTOR, POSITIONED_CHILD_INTERACTIVE_SELECTOR,
}; };
use impeccable_core::color::{composite_color_over, parse_any_color, parse_rgb}; use impeccable_core::color::{composite_color_over, parse_any_color, parse_rgb, Rgba};
use impeccable_core::js::{self, parse_float, parse_int}; use impeccable_core::js::{self, parse_float, parse_int};
use impeccable_core::js_ext_a::num_truthy; use impeccable_core::js_ext_a::num_truthy;
use impeccable_core::js_ext_b::slice_utf16_prefix; use impeccable_core::js_ext_b::slice_utf16_prefix;
@@ -461,11 +464,15 @@ pub fn check_element_borders(
} }
/// JS: checks.mjs#checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) /// JS: checks.mjs#checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule)
///
/// `images` serves the one branch the JS never had: text whose ground is a
/// `url()` layer is measured against the image's pixels (#560).
pub fn check_element_colors( pub fn check_element_colors(
el: &StaticElement<'_>, el: &StaticElement<'_>,
style: &StyleValues, style: &StyleValues,
tag: &str, tag: &str,
custom_props: CustomPropMap<'_>, custom_props: CustomPropMap<'_>,
images: &ImageSampler,
) -> Vec<RuleHit> { ) -> Vec<RuleHit> {
if sv_opt(style, "visibility") == Some("hidden") { if sv_opt(style, "visibility") == Some("hidden") {
return Vec::new(); return Vec::new();
@@ -539,7 +546,7 @@ pub fn check_element_colors(
sv(style, "backgroundClip") sv(style, "backgroundClip")
} }
}; };
check_colors(&ColorOpts { let opts = ColorOpts {
tag: tag.to_string(), tag: tag.to_string(),
text_color, text_color,
bg_color: own_bg, bg_color: own_bg,
@@ -557,7 +564,44 @@ pub fn check_element_colors(
bg_image: Some(sv(style, "backgroundImage").to_string()), bg_image: Some(sv(style, "backgroundImage").to_string()),
class_list: Some(el.class_name().to_string()), class_list: Some(el.class_name().to_string()),
detector_is_browser: false, detector_is_browser: false,
}) };
let mut findings = check_colors(&opts);
if surface_unresolved {
findings.extend(sampled_image_contrast(el, &opts, images));
}
findings
}
/// The sampled-contrast path (#560): when the analytic walk gave up at a
/// `url()` layer, read that image and measure the text against a grid of
/// its pixels. Every gate that needs no IO runs first, so a container
/// without direct text never touches the file.
fn sampled_image_contrast(
el: &StaticElement<'_>,
opts: &ColorOpts,
images: &ImageSampler,
) -> Option<RuleHit> {
if !sampled_contrast::applies(opts) {
return None;
}
let ground = find_image_ground(el)?;
let raster = images.load(&ground.url)?;
if sampled_contrast::is_decorative_layer(
&ground.repeat,
&ground.size,
raster.intrinsic_width as f64,
raster.intrinsic_height as f64,
) {
return None;
}
let points = sampled_contrast::grid_points(raster.width as usize, raster.height as usize);
let samples: Vec<Rgba> = points
.iter()
.filter_map(|&(x, y)| {
sampled_contrast::composite_sample(raster.pixel(x, y), ground.under, &ground.overlays)
})
.collect();
sampled_contrast::sampled_contrast(opts, &ground_label(&ground.url), &samples, points.len())
} }
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window) /// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
+150
View File
@@ -4,9 +4,11 @@
//! `compositeGradientStops`, `resolveBorderRadiusPx`), static-engine //! `compositeGradientStops`, `resolveBorderRadiusPx`), static-engine
//! branches only (`DETECTOR_IS_BROWSER === false`). //! branches only (`DETECTOR_IS_BROWSER === false`).
use crate::cascade::csstree::strings::decode_url;
use crate::cascade::StyleValues; use crate::cascade::StyleValues;
use crate::dom::StaticElement; use crate::dom::StaticElement;
use impeccable_core::checks::measures::{parse_color_resolved, parse_radius_to_px, CustomProps}; use impeccable_core::checks::measures::{parse_color_resolved, parse_radius_to_px, CustomProps};
use impeccable_core::checks::sampled_contrast::uniform_wash;
use impeccable_core::color::{ use impeccable_core::color::{
composite_color_over, is_no_paint_color_value, parse_any_color, parse_gradient_colors, composite_color_over, is_no_paint_color_value, parse_any_color, parse_gradient_colors,
parse_rgb, split_top_level_commas, Rgba, parse_rgb, split_top_level_commas, Rgba,
@@ -331,3 +333,151 @@ pub fn composite_gradient_stops(
pub fn resolve_border_radius_px(style: &StyleValues, width_px: f64) -> f64 { pub fn resolve_border_radius_px(style: &StyleValues, width_px: f64) -> f64 {
parse_radius_to_px(sv_opt(style, "borderRadius"), width_px).unwrap_or(0.0) parse_radius_to_px(sv_opt(style, "borderRadius"), width_px).unwrap_or(0.0)
} }
// ─── the image ground (#560) ────────────────────────────────────────────────
/// The image a text element sits on when the walk ends at a `url()` layer:
/// what the sampled-contrast path reads instead of skipping.
#[derive(Debug, Clone, PartialEq)]
pub struct ImageGround {
/// The layer's `url()` argument, unescaped and unquoted.
pub url: String,
/// The layer's own `background-repeat` and `background-size` entries.
pub repeat: String,
pub size: String,
/// Translucent paint between the text and the image, top to bottom:
/// tinted surfaces up the chain and uniform gradient washes.
pub overlays: Vec<Rgba>,
/// What a translucent pixel composites over, when the walk can say.
pub under: Option<Rgba>,
}
/// One entry of a comma-separated background list, with the CSS wraparound
/// for a list shorter than the image list.
fn layer_entry(value: &str, index: usize) -> String {
let parts = split_top_level_commas(value);
if parts.is_empty() {
return String::new();
}
js::trim(&parts[index % parts.len()]).to_string()
}
/// The `url()` argument of one background layer: `url(x\ y.png)` from the
/// css-tree generator, `url("x y.png")` from a style attribute. A shorthand
/// layer may carry other tokens around the call (`#000 url(x)center/cover`),
/// so only the call itself is decoded.
fn layer_url(layer: &str) -> String {
let Some(call) = URL_CALL_RE.find(layer) else {
return String::new();
};
let open = call.end();
let mut depth = 1usize;
let mut quote: Option<char> = None;
let mut close = layer.len();
for (i, ch) in layer[open..].char_indices() {
match quote {
Some(q) if ch == q => quote = None,
Some(_) => {}
None if ch == '"' || ch == '\'' => quote = Some(ch),
None if ch == '(' => depth += 1,
None if ch == ')' => {
depth -= 1;
if depth == 0 {
close = open + i + 1;
break;
}
}
None => {}
}
}
let decoded = decode_url(&format!("url({}", &layer[open..close]));
let trimmed = js::trim(&decoded);
let unquoted = trimmed
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| {
trimmed
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
})
.unwrap_or(trimmed);
unquoted.to_string()
}
/// The mirror of [`resolve_background_info`]'s walk for the case it reports
/// as unresolved because a `url()` layer is the ground: that layer plus
/// everything painted between the text and it. `None` whenever the analytic
/// walk would have resolved a color or given up for another reason (an
/// opaque gradient, an unparseable color, a varying scrim).
pub fn find_image_ground(el: &StaticElement<'_>) -> Option<ImageGround> {
let mut current = Some(*el);
let mut overlays: Vec<Rgba> = Vec::new();
while let Some(cur) = current {
let style = cur.style();
let mut bg = read_cascade_background_color(&cur, style, None);
if (bg.is_none() || bg.as_ref().is_some_and(|c| a_lt(c, 0.1)))
&& CURRENTCOLOR_RE.is_match(js::trim(sv(style, "backgroundColor")))
{
let color = sv_opt(style, "color");
bg = parse_rgb(color).or_else(|| parse_color_resolved(color, None));
}
if bg.as_ref().is_some_and(|c| a_ge(c, 0.99)) {
return None;
}
if bg.is_none() && !is_no_paint_color_value(sv_opt(style, "backgroundColor")) {
return None;
}
let bg_image = sv(style, "backgroundImage");
let layers: Vec<String> = if bg_image.is_empty() || bg_image == "none" {
Vec::new()
} else {
split_top_level_commas(bg_image)
};
for (index, layer) in layers.iter().enumerate() {
let layer = js::trim(layer);
// A shorthand layer may put color, position, size, or repeat
// tokens before the image call; the call is what matters.
let is_gradient = GRADIENT_CALL_RE.is_match(layer);
if !is_gradient && URL_CALL_RE.is_match(layer) {
// A layer beneath the image is unknown paint; otherwise the
// element's own color over its parent's ground, white at the
// root like the analytic walk.
let under = if index + 1 < layers.len() {
None
} else {
let parent_ground = match cur.parent_element() {
Some(p) => resolve_background(&p, None),
None => Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
};
match bg {
Some(c) if a_gt(&c, 0.1) => {
parent_ground.map(|p| composite_color_over(&c, &p))
}
_ => parent_ground,
}
};
return Some(ImageGround {
url: layer_url(layer),
repeat: layer_entry(sv(style, "backgroundRepeat"), index),
size: layer_entry(sv(style, "backgroundSize"), index),
overlays,
under,
});
}
if is_gradient {
let stops = parse_gradient_colors(Some(layer));
if stops.is_empty() || stops.iter().all(|s| s.alpha_or_one() >= 0.99) {
return None;
}
overlays.push(uniform_wash(&stops)?);
continue;
}
return None;
}
if let Some(c) = bg.filter(|c| a_gt(c, 0.1)) {
overlays.push(c);
}
current = cur.parent_element();
}
None
}
+177 -6
View File
@@ -10,10 +10,11 @@
use super::checks_shim::CustomProps; use super::checks_shim::CustomProps;
use super::{ use super::{
apply_static_declaration, collect_static_css_rules, compare_static_priority, apply_static_declaration, apply_static_longhand, background_longhands,
is_static_inherited_prop, make_default_style, normalize_static_css_value, collect_static_css_rules, compare_static_priority, is_static_inherited_prop,
parse_static_style_attribute, static_default_style, CssRule, DeclMeta, SpecifiedDecl, make_default_style, normalize_static_css_value, parse_static_style_attribute,
SpecifiedStore, StyleValues, STATIC_DEFAULT_STYLE, static_default_style, CssRule, DeclMeta, SpecifiedDecl, SpecifiedStore, StyleValues,
STATIC_DEFAULT_STYLE,
}; };
use crate::dom::StaticDocument; use crate::dom::StaticDocument;
use crate::profile::{self, Meta, ProfileSink}; use crate::profile::{self, Meta, ProfileSink};
@@ -39,7 +40,7 @@ static REMOTE_HREF_RE: Lazy<Regex> =
/// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs /// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
/// must not resolve as OS-absolute paths; otherwise the whole stylesheet is /// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
/// invisible to every element-level check. /// invisible to every element-level check.
fn resolve_linked_css_path(file_dir: &str, href: &str) -> String { pub(crate) fn resolve_linked_css_path(file_dir: &str, href: &str) -> String {
let stripped = href.split(['?', '#']).next().unwrap_or(""); let stripped = href.split(['?', '#']).next().unwrap_or("");
let root_relative = stripped.starts_with('/') && !stripped.starts_with("//"); let root_relative = stripped.starts_with('/') && !stripped.starts_with("//");
if !root_relative { if !root_relative {
@@ -117,7 +118,15 @@ pub fn collect_static_css_text(
|| std::fs::read(&css_path), || std::fs::read(&css_path),
); );
match read { match read {
Ok(bytes) => style_texts.push(String::from_utf8_lossy(&bytes).into_owned()), Ok(bytes) => {
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(_) => { Err(_) => {
if warned_missing_stylesheets.insert(css_path.clone()) { if warned_missing_stylesheets.insert(css_path.clone()) {
if let Some(warn) = warn { if let Some(warn) = warn {
@@ -132,6 +141,162 @@ pub fn collect_static_css_text(
style_texts.join("\n") style_texts.join("\n")
} }
// A quoted url cannot span a line and an unquoted one cannot hold
// whitespace, quotes, parens, braces, or semicolons (CSS syntax), so a
// stray `url(` can never pair with a `)` in a later rule.
static CSS_URL_RE: Lazy<Regex> = Lazy::new(|| {
// The leading group keeps `url(` from matching inside a longer
// identifier such as `myurl(`; the closure emits it unchanged.
Regex::new(
r#"(?i)(^|[^A-Za-z0-9_-])url\(\s*(?:"((?:[^"\\\n\r]|\\.)*)"|'((?:[^'\\\n\r]|\\.)*)'|([^)"'(\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, and comments are copied through
/// untouched, so nothing inside one can reach the rules after it.
pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
let mut out = String::with_capacity(css.len());
let mut cursor = 0usize;
for (start, end) in comment_spans(css) {
out.push_str(&rewrite_code_urls(&css[cursor..start], sheet_dir, page_dir));
out.push_str(&css[start..end]);
cursor = end;
}
out.push_str(&rewrite_code_urls(&css[cursor..], sheet_dir, page_dir));
out
}
/// The byte spans of every `/* */` comment in `css`, found the way the CSS
/// tokenizer finds them: a `/*` inside a quoted string or an unquoted
/// `url()` is content, not a comment opener, and an unclosed comment runs to
/// the end of the sheet. Every span boundary sits on an ASCII byte, so the
/// spans are valid `str` indices.
/// A byte that can continue a CSS identifier, so `myurl(` is a custom
/// function and not the `url(` token.
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b >= 0x80
}
fn comment_spans(css: &str) -> Vec<(usize, usize)> {
#[derive(Clone, Copy, PartialEq)]
enum State {
Code,
Str(u8),
Url,
}
let bytes = css.as_bytes();
let mut spans = Vec::new();
let mut state = State::Code;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
match state {
State::Code => {
if b == b'/' && bytes.get(i + 1) == Some(&b'*') {
let end = css[i + 2..]
.find("*/")
.map(|at| i + 2 + at + 2)
.unwrap_or(bytes.len());
spans.push((i, end));
i = end;
continue;
}
if b == b'"' || b == b'\'' {
state = State::Str(b);
} else if b.eq_ignore_ascii_case(&b'u')
&& css[i..]
.get(..4)
.is_some_and(|s| s.eq_ignore_ascii_case("url("))
&& !(i > 0 && is_ident_byte(bytes[i - 1]))
{
i += 4;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
// A quoted argument is a string like any other; an
// unquoted one runs to the closing paren.
if i < bytes.len() && bytes[i] != b'"' && bytes[i] != b'\'' {
state = State::Url;
}
continue;
}
}
State::Str(quote) => {
if b == b'\\' {
i += 2;
continue;
}
// A raw newline ends a string in CSS (a bad-string token).
if b == quote || b == b'\n' || b == b'\r' {
state = State::Code;
}
}
State::Url => {
if b == b'\\' {
i += 2;
continue;
}
if b == b')' {
state = State::Code;
}
}
}
i += 1;
}
spans
}
fn rewrite_code_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 before = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let (target, quote) = match (caps.get(2), caps.get(3), caps.get(4)) {
(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);
// Pure POSIX string math on both directories: a CSS url is
// POSIX on every OS, and the win32 helpers would render a drive
// path (`D:\a\...`) differently from the url beside it.
let sheet = jsp::to_posix(sheet_dir);
let page = jsp::to_posix(page_dir);
let absolute = jsp::posix::resolve("/", &[&sheet, path]);
let relative = jsp::posix::relative("/", &page, &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!("{before}url({quote}{relative}{suffix}{quote})")
})
.into_owned()
}
static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| { static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!( Regex::new(&format!(
r"(?i)^(.+?){ws}*::?(?:before|after)$", r"(?i)^(.+?){ws}*::?(?:before|after)$",
@@ -308,6 +473,9 @@ pub fn build_static_style_map(
inline: false, inline: false,
}; };
apply_static_declaration(store, node, &decl.prop, &decl.value, &meta); apply_static_declaration(store, node, &decl.prop, &decl.value, &meta);
for (prop, value) in background_longhands(&decl.prop, &decl.value) {
apply_static_longhand(store, node, &prop, &value, &meta);
}
} }
} }
} }
@@ -331,6 +499,9 @@ pub fn build_static_style_map(
inline: true, inline: true,
}; };
apply_static_declaration(&mut specified, node, &decl.prop, &decl.value, &meta); apply_static_declaration(&mut specified, node, &decl.prop, &decl.value, &meta);
for (prop, value) in background_longhands(&decl.prop, &decl.value) {
apply_static_longhand(&mut specified, node, &prop, &value, &meta);
}
} }
inline_order += 1000; inline_order += 1000;
} }
+33 -9
View File
@@ -187,15 +187,39 @@ pub fn apply_static_declaration<K: Hash + Eq>(
) { ) {
let map = specified.map.entry(node).or_default(); let map = specified.map.entry(node).or_default();
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) { for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) {
let existing = map.get(&expanded_prop).map(|d| &d.meta); apply_expanded(map, &expanded_prop, &expanded_value, meta);
if compare_static_priority(existing, meta) { }
let next = SpecifiedDecl { }
meta: meta.clone(),
prop: expanded_prop.clone(), /// Stores one already-expanded longhand for `node` under the cascade's
value: expanded_value, /// priority rule, the way `apply_static_declaration` stores each pair the
}; /// shorthand expansion yields. For the longhands that ride beside that
map.insert(expanded_prop, next); /// expansion rather than inside it (`background_longhands`).
} pub fn apply_static_longhand<K: Hash + Eq>(
specified: &mut SpecifiedStore<K>,
node: K,
prop: &str,
value: &str,
meta: &DeclMeta,
) {
let map = specified.map.entry(node).or_default();
apply_expanded(map, prop, value, meta);
}
fn apply_expanded(
map: &mut IndexMap<String, SpecifiedDecl>,
prop: &str,
value: &str,
meta: &DeclMeta,
) {
let existing = map.get(prop).map(|d| &d.meta);
if compare_static_priority(existing, meta) {
let next = SpecifiedDecl {
meta: meta.clone(),
prop: prop.to_string(),
value: value.to_string(),
};
map.insert(prop.to_string(), next);
} }
} }
+95
View File
@@ -193,6 +193,101 @@ pub fn parse_static_animation(value: &str) -> StaticAnimation {
} }
} }
static BG_REPEAT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:repeat|no-repeat|repeat-x|repeat-y|space|round)$").expect("BG_REPEAT_RE")
});
static BG_SIZE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^(?:auto|cover|contain|-?[0-9.]+[a-z%]*)$").expect("BG_SIZE_RE"));
/// The per-layer `background-repeat` and `background-size` lists of a
/// `background` shorthand (`url(a) center / cover no-repeat, url(b)` ->
/// `no-repeat, repeat` and `cover, auto`). The css-tree generator may glue
/// the first keyword to the image (`url(a)center/cover`), so a token that
/// holds a function call is read up to its closing paren and the rest kept.
fn parse_static_background_layers(value: &str) -> (String, String) {
let mut repeats: Vec<String> = Vec::new();
let mut sizes: Vec<String> = Vec::new();
for layer in split_css_list(value) {
let mut repeat: Vec<String> = Vec::new();
let mut size: Vec<String> = Vec::new();
let mut in_size = false;
for raw in split_css_tokens(&layer) {
let token = match raw.rfind(')') {
Some(end) if raw.contains('(') => raw[end + 1..].to_string(),
_ => raw,
};
if token.is_empty() {
in_size = false;
continue;
}
// `center/cover`, `center / cover`, or `center/ cover`: the size
// is what follows the slash, one or two values.
if let Some((_, after)) = token.split_once('/') {
in_size = true;
size.clear();
if !after.is_empty() && BG_SIZE_RE.is_match(after) {
size.push(js::to_lower_case(after));
}
continue;
}
if in_size && size.len() < 2 && BG_SIZE_RE.is_match(&token) {
size.push(js::to_lower_case(&token));
continue;
}
in_size = false;
if repeat.len() < 2 && BG_REPEAT_RE.is_match(&token) {
repeat.push(js::to_lower_case(&token));
}
}
repeats.push(if repeat.is_empty() {
"repeat".into()
} else {
repeat.join(" ")
});
sizes.push(if size.is_empty() {
"auto".into()
} else {
size.join(" ")
});
}
(repeats.join(", "), sizes.join(", "))
}
/// The `backgroundRepeat` / `backgroundSize` pairs a declaration sets. Not
/// 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. 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 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),
("backgroundSize".into(), size),
]
}
"background-repeat" if !v.is_empty() => vec![("backgroundRepeat".into(), v.to_string())],
"background-size" if !v.is_empty() => vec![("backgroundSize".into(), v.to_string())],
_ => Vec::new(),
}
}
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> = static BG_IMAGE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE")); Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE"));
static BG_IMAGE_SPLIT_RE: Lazy<Regex> = Lazy::new(|| { static BG_IMAGE_SPLIT_RE: Lazy<Regex> = Lazy::new(|| {
+10 -3
View File
@@ -20,6 +20,7 @@ use crate::adapters::{
use crate::background::{resolve_background, resolve_border_radius_px, sv}; use crate::background::{resolve_background, resolve_border_radius_px, sv};
use crate::cascade::{build_static_style_map, collect_static_css_text}; use crate::cascade::{build_static_style_map, collect_static_css_text};
use crate::dom::{StaticDocument, StaticElement}; use crate::dom::{StaticDocument, StaticElement};
use crate::image_sampling::ImageSampler;
use crate::page::{ use crate::page::{
check_cream_palette, check_page_layout, check_repeated_container_text_from_doc, check_cream_palette, check_page_layout, check_repeated_container_text_from_doc,
check_static_page_typography, check_static_page_typography,
@@ -119,14 +120,19 @@ const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
("radial-spotlight-glow", "*"), ("radial-spotlight-glow", "*"),
]; ];
fn run_rule(rule_id: &str, el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> { fn run_rule(
rule_id: &str,
el: &StaticElement<'_>,
tag: &str,
images: &ImageSampler,
) -> Vec<RuleHit> {
let style = el.style(); let style = el.style();
match rule_id { match rule_id {
"border-rules" => { "border-rules" => {
let radius = resolve_border_radius_px(style, pf0(sv(style, "width"))); let radius = resolve_border_radius_px(style, pf0(sv(style, "width")));
check_element_borders(tag, style, radius, el) check_element_borders(tag, style, radius, el)
} }
"color-rules" => check_element_colors(el, style, tag, None), "color-rules" => check_element_colors(el, style, tag, None, images),
"hover-color-rules" => check_element_hover_contrast(el, style, tag), "hover-color-rules" => check_element_hover_contrast(el, style, tag),
"dark-glow" => { "dark-glow" => {
let base = el.parent_element().unwrap_or(*el); let base = el.parent_element().unwrap_or(*el);
@@ -210,6 +216,7 @@ pub fn detect_html_source(
let css_text = 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); build_static_style_map(&mut doc, css_text.as_str(), profile, fp);
let doc = doc; let doc = doc;
let images = ImageSampler::new(&file_dir.to_string_lossy());
let mut findings: Vec<Finding> = Vec::new(); let mut findings: Vec<Finding> = Vec::new();
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0); let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
@@ -222,7 +229,7 @@ pub fn detect_html_source(
profile, profile,
Meta::new("element", rule_id, fp), Meta::new("element", rule_id, fp),
|h: &RuleHit| h.id.as_str(), |h: &RuleHit| h.id.as_str(),
|| run_rule(rule_id, el, &tag), || run_rule(rule_id, el, &tag, &images),
); );
for h in hits { for h in hits {
if scoped_ignore_active(el, &h.id) { if scoped_ignore_active(el, &h.id) {
+285
View File
@@ -0,0 +1,285 @@
//! The pixels behind image-backed text for the static engine (#560). A
//! `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;
/// The browser overlay draws to a canvas no larger than this on a side.
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.
const CACHE_ENTRIES: usize = 24;
/// A decoded image, RGBA8, at most [`MAX_RASTER_SIDE`] on a side.
pub struct Raster {
pub width: u32,
pub height: u32,
/// The size before the downscale: what `background-size: auto` paints.
pub intrinsic_width: u32,
pub intrinsic_height: u32,
rgba: Vec<u8>,
}
impl Raster {
pub fn pixel(&self, x: usize, y: usize) -> Rgba {
let i = (y.min(self.height as usize - 1) * self.width as usize
+ x.min(self.width as usize - 1))
* 4;
let p = &self.rgba[i..i + 4];
Rgba::new(p[0] as f64, p[1] as f64, p[2] as f64, p[3] as f64 / 255.0)
}
}
thread_local! {
static RASTERS: RefCell<HashMap<String, Option<Rc<Raster>>>> = RefCell::new(HashMap::new());
}
/// 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 {
base: String,
}
impl ImageSampler {
pub fn new(html_dir: &str) -> Self {
ImageSampler {
base: html_dir.to_string(),
}
}
/// The raster behind a `url()` argument, or `None` when it cannot be
/// read here: a remote URL, a missing or oversized file, a format the
/// decoders do not cover (SVG), or an `svg`/text data URI.
pub fn load(&self, url: &str) -> Option<Rc<Raster>> {
let url = url.trim();
if url.is_empty() {
return None;
}
let key = if is_data_uri(url) {
// 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)?
};
if let Some(hit) = RASTERS.with(|c| c.borrow().get(&key).cloned()) {
return hit;
}
let bytes = if is_data_uri(url) {
data_uri_bytes(url)
} else {
read_bounded(&key)
};
let raster = bytes.and_then(|b| decode(&b)).map(Rc::new);
RASTERS.with(|c| {
let mut cache = c.borrow_mut();
if cache.len() >= CACHE_ENTRIES {
cache.clear();
}
cache.insert(key, raster.clone());
});
raster
}
fn resolve_file(&self, url: &str) -> Option<String> {
if url.starts_with("//") || url.contains("://") {
return None;
}
let path = resolve_linked_css_path(&self.base, url);
std::fs::metadata(&path)
.map(|m| m.is_file())
.unwrap_or(false)
.then_some(path)
}
}
fn is_data_uri(url: &str) -> bool {
// `get`, not a byte slice: a url may open with a multibyte character.
url.get(..5)
.is_some_and(|prefix| prefix.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();
if is_data_uri(url) {
let header = url[5..].split(',').next().unwrap_or("");
let mime = header
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
return if mime.is_empty() {
"data:image".into()
} else {
format!("data:{mime}")
};
}
let stripped = url.split(['?', '#']).next().unwrap_or("");
jsp::basename(stripped)
}
fn data_uri_bytes(url: &str) -> Option<Vec<u8>> {
let (header, payload) = url[5..].split_once(',')?;
if !header
.split(';')
.any(|p| p.trim().eq_ignore_ascii_case("base64"))
{
return None;
}
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.as_ref())
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(compact.as_ref()))
.ok()
}
fn read_bounded(path: &str) -> Option<Vec<u8>> {
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
return None;
}
std::fs::read(path).ok()
}
fn decode(bytes: &[u8]) -> Option<Raster> {
let mut reader = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_SIDE);
limits.max_image_height = Some(MAX_IMAGE_SIDE);
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
let decoded = reader.decode().ok()?;
let (intrinsic_width, intrinsic_height) = (decoded.width(), decoded.height());
if intrinsic_width == 0 || intrinsic_height == 0 {
return None;
}
// `thumbnail` also upscales, so only images past the budget go through it.
let scaled = if intrinsic_width.max(intrinsic_height) > MAX_RASTER_SIDE {
decoded.thumbnail(MAX_RASTER_SIDE, MAX_RASTER_SIDE)
} else {
decoded
};
let rgba = scaled.to_rgba8();
Some(Raster {
width: rgba.width(),
height: rgba.height(),
intrinsic_width,
intrinsic_height,
rgba: rgba.into_raw(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(width, height, image::Rgba(rgba));
let mut out = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(img)
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
}
#[test]
fn data_uri_decodes_and_labels() {
let bytes = png_bytes(8, 8, [240, 236, 228, 255]);
let uri = format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&bytes)
);
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);
assert_eq!(ground_label(&uri), "data:image/png");
assert!(sampler.load("data:image/svg+xml;utf8,<svg/>").is_none());
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 url that opens with a multibyte character is a local path, not
// a panic.
assert!(!is_data_uri("dat\u{20ac}"));
assert!(sampler.load("abcd\u{20ac}.png").is_none());
assert_eq!(ground_label("abcd\u{20ac}.png"), "abcd\u{20ac}.png");
// 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_the_page_dir_and_downscale() {
let dir = std::env::temp_dir().join(format!("impeccable-sampler-{}", std::process::id()));
std::fs::create_dir_all(dir.join("img")).unwrap();
std::fs::write(
dir.join("img").join("wide.png"),
png_bytes(1280, 320, [20, 20, 20, 255]),
)
.unwrap();
let sampler = ImageSampler::new(&dir.to_string_lossy());
assert!(sampler.load("wide.png").is_none());
let raster = sampler
.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),
(1280, 320)
);
assert_eq!(raster.pixel(639, 159).g, 20.0);
// The second load is the cached raster.
assert!(Rc::ptr_eq(&raster, &sampler.load("img/wide.png").unwrap()));
std::fs::remove_dir_all(&dir).ok();
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod background;
pub mod cascade; pub mod cascade;
pub mod dom; pub mod dom;
pub mod engine; pub mod engine;
pub mod image_sampling;
pub mod page; pub mod page;
pub mod profile; pub mod profile;
pub mod quality; pub mod quality;
+206
View File
@@ -209,3 +209,209 @@ fn checks_shim_helpers() {
assert_eq!(resolve_length_px("50%", 10.0), Some(5.0)); assert_eq!(resolve_length_px("50%", 10.0), Some(5.0));
assert_eq!(resolve_length_px("1.5", 10.0), Some(15.0)); assert_eq!(resolve_length_px("1.5", 10.0), Some(15.0));
} }
#[test]
fn background_longhands_ride_beside_the_expansion() {
use impeccable_html::cascade::rules::apply_static_longhand;
use impeccable_html::cascade::shorthand::background_longhands;
// The expansion itself is pinned by the recorded vectors and stays
// image-and-color only; repeat and size come from the side channel.
assert_eq!(
background_longhands(
"background",
"url(a.png) center / cover no-repeat, url(b.png)"
),
vec![
(
"backgroundRepeat".to_string(),
"no-repeat, repeat".to_string()
),
("backgroundSize".to_string(), "cover, auto".to_string()),
]
);
// The css-tree generator glues the first keyword to the call.
assert_eq!(
background_longhands("background", "url(a.png)center/cover no-repeat"),
vec![
("backgroundRepeat".to_string(), "no-repeat".to_string()),
("backgroundSize".to_string(), "cover".to_string()),
]
);
assert_eq!(
background_longhands("background", "#fff url(a.png) repeat-x"),
vec![
("backgroundRepeat".to_string(), "repeat-x".to_string()),
("backgroundSize".to_string(), "auto".to_string()),
]
);
assert_eq!(
background_longhands("Background-Size", "100% 32px"),
vec![("backgroundSize".to_string(), "100% 32px".to_string())]
);
// 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 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(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),
);
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("contain")
);
assert_eq!(
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}");
// An unquoted url with a space is not a url token in CSS; it stays put.
assert!(out.contains("URL( 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) }");
// A stray `url(` in a comment, or an unclosed quote, never swallows the
// rules after it: comments pass through untouched and a url form ends
// where CSS says it ends.
let hazards = concat!(
"/* see url( for details */ .g { color: red }\n",
".h { background: url(a.png) }\n",
".i { background: url(\"oops }\n",
".j { background: url(b.png) }\n",
"/* url(unterminated.png",
);
let out = rewrite_sheet_urls(hazards, "/site/css", "/site");
assert!(
out.contains("/* see url( for details */ .g { color: red }"),
"{out}"
);
assert!(out.contains(".h { background: url(css/a.png) }"), "{out}");
assert!(out.contains(".i { background: url(\"oops }"), "{out}");
assert!(out.contains(".j { background: url(css/b.png) }"), "{out}");
assert!(out.ends_with("/* url(unterminated.png"), "{out}");
// A `/*` inside a string or an unquoted url is content, not a comment,
// so the rules after it are still rewritten.
let strings = concat!(
".k { content: \"a/*b\"; background: url(k.png) }\n",
".l { background: url(l/*.png) }\n",
".m { content: 'c/*d'; } /* real url( */ .n { background: url(n.png) }\n",
);
let out = rewrite_sheet_urls(strings, "/site/css", "/site");
assert!(
out.contains(".k { content: \"a/*b\"; background: url(css/k.png) }"),
"{out}"
);
assert!(out.contains(".l { background: url(css/l/*.png) }"), "{out}");
assert!(
out.contains("/* real url( */ .n { background: url(css/n.png) }"),
"{out}"
);
// `myurl(` is a custom function, not the url token: its comment stays a
// comment and its argument is not rewritten.
let custom = ".q { mask: myurl(a /* url(x.png) */); background: url(q.png) }";
let out = rewrite_sheet_urls(custom, "/site/css", "/site");
assert_eq!(
out,
".q { mask: myurl(a /* url(x.png) */); background: url(css/q.png) }"
);
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -332,6 +332,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
#### Static and regex engines (only what affects the contract) #### 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. - `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 (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. - `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`) #### Profiler (`cli/engine/profile/profiler.mjs`)
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fixture: pixel-sampled contrast on image-backed text (static engine)</title>
<link rel="stylesheet" href="sampled-images/site.css">
<style>
body { margin: 0; padding: 40px; background: #ffffff; font-family: Georgia, serif; color: #262421; }
/* Every panel is a url() ground the analytic walk cannot measure. The
static engine reads the image's pixels instead (#560): a fixed grid over
the whole image, and a finding only when nearly all of it fails. Each
case carries UNIQUE copy so a finding attributes to exactly one case.
Images live in sampled-images/: light.png and light.webp are a
near-white texture, dark.jpg a charcoal one, split.png half light and
half dark, cutout.png the light texture at 25% alpha, icon.png a 24px
glyph. */
h1 { font-size: 28px; margin: 0 0 24px; }
.panel { width: 420px; padding: 24px; margin: 0 0 24px; }
.panel p { margin: 0; font-size: 16px; line-height: 1.6; }
.panel h2 { margin: 0; font-size: 28px; line-height: 1.3; }
.light { background: url(sampled-images/light.png); }
.light-webp { background: url("sampled-images/light.webp") center / cover; }
.tokens-first { background: center / cover no-repeat url(sampled-images/light.png); }
.dark-jpg { background: url(sampled-images/dark.jpg) center / cover no-repeat; }
.split { background: url(sampled-images/split.png) repeat; }
.inline-data { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAaUlEQVR42k2PSQrAMAwD/f9ndt+TNl2OLmMQ5CCELUtR7CuLP+cUuPPo19F73tqY09q4nXsXC0SYA8T3mgNxgMiypMGZccLA5Kij6+eMDhKIZEZQsmkBA47UKxLkFKsHjCE6IOBQSv2bH8D48hUetch/AAAAAElFTkSuQmCC); }
/* Uniform tints composite exactly; a wash that varies is a scrim placed
under the text on purpose, and without layout the engine cannot say
which stop the text sits on. */
.wash-weak { background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url(sampled-images/light.png); }
.wash-strong { background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url(sampled-images/light.png); }
.fade-scrim { background: linear-gradient(rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)), url(sampled-images/light.png); }
/* A translucent image composites over what is beneath it: here a dark
section, so the light texture at 25% reads as a dark ground. */
.dark-section { width: 468px; padding: 0; background: #17150f; }
.cutout { background: url(sampled-images/cutout.png); margin: 0; }
.missing { background: url(sampled-images/nope.png); }
.remote { background: url(https://example.com/hero.jpg) center / cover; }
.svg-data { background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23f5f2ea'/></svg>") center / cover; }
/* A no-repeat icon painted at 24px is decoration, not the ground. */
.icon-link { background: url(sampled-images/icon.png) no-repeat left center; padding: 8px 8px 8px 32px; margin: 0 0 24px; width: 380px; font-size: 16px; line-height: 1.6; }
.white { color: #fdfdfd; }
.ink { color: #262421; }
.smoke { color: #3a3632; }
</style>
</head>
<body>
<h1>Sampled contrast fixture</h1>
<!-- ── Should flag ──────────────────────────────────────────────── -->
<div class="panel light">
<p class="white">White copy on a near-white texture fails everywhere the grid lands.</p>
</div>
<div class="panel dark-jpg">
<p class="smoke">Smoke gray copy on a charcoal photo fails wherever it sits.</p>
</div>
<div class="panel light-webp">
<p class="white">The same texture as a WebP decodes through the same path.</p>
</div>
<div class="panel inline-data">
<p class="white">A base64 PNG data URI is read without touching the disk.</p>
</div>
<div class="panel wash-weak">
<p class="white">A thirty percent tint is too weak to carry white copy over the texture.</p>
</div>
<div class="dark-section">
<div class="panel cutout">
<p class="ink">Ink on the translucent texture composites over the dark section and fails.</p>
</div>
</div>
<div class="panel from-sheet">
<p class="white">The ground of this panel is declared in the linked stylesheet.</p>
</div>
<div class="panel light">
<h2 class="white">Large white heading on the texture</h2>
</div>
<div class="panel tokens-first">
<p class="white">Shorthand tokens before the image still name the ground.</p>
</div>
<!-- ── Should pass ──────────────────────────────────────────────── -->
<div class="panel light">
<p class="ink">Ink on the near-white texture reads fine and must not flag.</p>
</div>
<div class="panel dark-jpg">
<p class="white">White copy on the charcoal photo reads fine and must not flag.</p>
</div>
<div class="panel split">
<p class="white">Half of this image is dark, so the copy may well sit there.</p>
</div>
<div class="panel wash-strong">
<p class="white">A sixty percent tint carries white copy over the texture.</p>
</div>
<div class="panel fade-scrim">
<p class="white">A scrim that fades out is placed on purpose; the engine keeps the skip.</p>
</div>
<div class="dark-section">
<div class="panel cutout">
<p class="white">White copy on the translucent texture over the dark section passes.</p>
</div>
</div>
<div class="panel missing">
<p class="white">An image that is not on disk keeps the skip.</p>
</div>
<div class="panel remote">
<p class="white">A remote image is never fetched during a file scan.</p>
</div>
<div class="panel svg-data">
<p class="white">An SVG has no raster to sample, so the skip stands.</p>
</div>
<p class="icon-link">A twenty four pixel icon beside the copy is decoration, not the ground.</p>
<div class="panel light" data-impeccable-ignore="low-contrast">
<p class="white">A waived panel keeps its sampled finding out of the report.</p>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+3
View File
@@ -0,0 +1,3 @@
/* Linked from ../sampled-image-contrast.html. A url() inside a stylesheet is
relative to the sheet, so light.png resolves here, not next to the page. */
.from-sheet { background: url(light.png); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

+12
View File
@@ -164,3 +164,15 @@ installed. The binary's `CLI_VERSION` moves from `3.6.0` to `4.0.0` with the
CLI 4.0.0 release; it is what the binary prints when run directly. CLI 4.0.0 release; it is what the binary prints when run directly.
- `cli-version`. - `cli-version`.
## Recorded 2026-09-10: sampled contrast on image-backed text (#560)
The static engine now reads the pixels of a local `url()` background behind
text instead of skipping the contrast check (docs/CLI-CONTRACT.md, "Sampled
contrast"). The JS never had this path, so the new fixture
`sampled-image-contrast.html` (with `sampled-images/`) has no JS golden; its
cases were recorded from the engine, and the directory sweeps below moved
only by that fixture's nine `low-contrast` findings, verified entry by entry.
- `detect-fixture-json-sampled-image-contrast-html`, `detect-fixture-text-sampled-image-contrast-html`, `detect-fixture-json-sampled-images`, `detect-fixture-text-sampled-images`: new cases.
- `detect-dir-json-all-fixtures`, `detect-dir-text-all-fixtures`, `detect-dir-quiet-all-fixtures`, `detect-no-advisory-json`, `detect-no-advisory-text`: the directory sweep picks up the new fixture.
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{ {
"stdout": "", "stdout": "",
"stderr": "415 anti-patterns found.\n17 advisory notes (not counted).\n", "stderr": "424 anti-patterns found.\n17 advisory notes (not counted).\n",
"exit": 2, "exit": 2,
"signal": null, "signal": null,
"files": {} "files": {}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"stdout": "[\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.3:1 (need 4.5:1) — text #3a3632 on dark.jpg; p90 of 36 samples, median 1.2:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.webp; p90 of 36 samples, median 1.2:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on data:image/png; p90 of 36 samples, median 1.1:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 2.5:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 2.4:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.8:1 (need 4.5:1) — text #262421 on cutout.png; p90 of 36 samples, median 1.8:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 3:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\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\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "[]\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\n [low-contrast] sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\n → 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 [low-contrast] sampled (coarse) 1.3:1 (need 4.5:1) — text #3a3632 on dark.jpg; p90 of 36 samples, median 1.2:1\n → 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 [low-contrast] sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.webp; p90 of 36 samples, median 1.2:1\n → 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 [low-contrast] sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on data:image/png; p90 of 36 samples, median 1.1:1\n → 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 [low-contrast] sampled (coarse) 2.5:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 2.4:1\n → 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 [low-contrast] sampled (coarse) 1.8:1 (need 4.5:1) — text #262421 on cutout.png; p90 of 36 samples, median 1.8:1\n → 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 [low-contrast] sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\n → 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 [low-contrast] sampled (coarse) 1.2:1 (need 3:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\n → 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 [low-contrast] sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\n → 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\n9 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long