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
67d018fe05 Fix: print JSON on live-poll --reply success (#800)
Successful --reply was exit 0 with empty stdout, so agents could not tell delivery from a hang. Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 08:47:39 +05:00
3bdb9ff06c Fix: drop stale carbonize diagnostic on complete (#801)
Complete and discarded snapshots no longer keep carbonize_cleanup_required after cleanup is done.

AI assistance: Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 08:47:16 +05:00
41 changed files with 1688 additions and 96 deletions
Generated
+3
View File
@@ -509,6 +509,7 @@ dependencies = [
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
@@ -671,9 +672,11 @@ dependencies = [
name = "impeccable-html"
version = "0.1.5"
dependencies = [
"base64",
"cssparser",
"ego-tree",
"html5ever",
"image",
"impeccable-common",
"impeccable-core",
"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).
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.
+4
View File
@@ -29,6 +29,9 @@
//! checkNumberedSectionLabels, checkEmDashOveruse, isRepeatedTextContainer.
//! Open (selector and tag lists, thresholds, the two text parsers):
//! `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
//! 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 measures;
pub mod rules;
pub mod sampled_contrast;
pub mod text_rules;
#[cfg(feature = "vectors")]
+19 -10
View File
@@ -135,21 +135,30 @@ fn is_heading_123(tag: &str) -> bool {
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
pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
let tag = opts.tag.as_str();
let bg_image = opts.bg_image.as_deref().unwrap_or("");
let bg_clip = opts.bg_clip.as_deref().unwrap_or("");
if set_has(SAFE_TAGS, tag) {
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;
if !is_styled_control {
return Vec::new();
}
if safe_tag_unstyled(opts) {
return 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"
cssparser = "0.37"
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]
impeccable-core = { workspace = true, features = ["vectors"] }
+49 -5
View File
@@ -6,11 +6,13 @@
//! and the computed style and hands plain data over.
use crate::background::{
a_ge, a_gt, read_own_background_color, resolve_background, resolve_background_info,
resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt, CustomPropMap,
a_ge, a_gt, find_image_ground, read_own_background_color, resolve_background,
resolve_background_info, resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt,
CustomPropMap,
};
use crate::cascade::StyleValues;
use crate::dom::{StaticDocument, StaticElement};
use crate::image_sampling::{ground_label, ImageSampler};
use crate::quality::{collapse_ws, pf0, resolve_font_size_px};
use impeccable_core::checks::measures::{
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,
MotionOpts, RuleHit, Sides,
};
use impeccable_core::checks::sampled_contrast;
use impeccable_core::checks::text_rules::{
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
parse_numbered_label_text, KickerCandidateInput, NumberedLabelCandidate,
NumberedLabelCandidateInput, HEADING_TAGS, KICKER_CARD_CONTEXT_SELECTOR, KICKER_SKIP_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_ext_a::num_truthy;
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)
///
/// `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(
el: &StaticElement<'_>,
style: &StyleValues,
tag: &str,
custom_props: CustomPropMap<'_>,
images: &ImageSampler,
) -> Vec<RuleHit> {
if sv_opt(style, "visibility") == Some("hidden") {
return Vec::new();
@@ -539,7 +546,7 @@ pub fn check_element_colors(
sv(style, "backgroundClip")
}
};
check_colors(&ColorOpts {
let opts = ColorOpts {
tag: tag.to_string(),
text_color,
bg_color: own_bg,
@@ -557,7 +564,44 @@ pub fn check_element_colors(
bg_image: Some(sv(style, "backgroundImage").to_string()),
class_list: Some(el.class_name().to_string()),
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)
+150
View File
@@ -4,9 +4,11 @@
//! `compositeGradientStops`, `resolveBorderRadiusPx`), static-engine
//! branches only (`DETECTOR_IS_BROWSER === false`).
use crate::cascade::csstree::strings::decode_url;
use crate::cascade::StyleValues;
use crate::dom::StaticElement;
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::{
composite_color_over, is_no_paint_color_value, parse_any_color, parse_gradient_colors,
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 {
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::{
apply_static_declaration, collect_static_css_rules, compare_static_priority,
is_static_inherited_prop, make_default_style, normalize_static_css_value,
parse_static_style_attribute, static_default_style, CssRule, DeclMeta, SpecifiedDecl,
SpecifiedStore, StyleValues, STATIC_DEFAULT_STYLE,
apply_static_declaration, apply_static_longhand, background_longhands,
collect_static_css_rules, compare_static_priority, is_static_inherited_prop,
make_default_style, normalize_static_css_value, parse_static_style_attribute,
static_default_style, CssRule, DeclMeta, SpecifiedDecl, SpecifiedStore, StyleValues,
STATIC_DEFAULT_STYLE,
};
use crate::dom::StaticDocument;
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
/// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
/// 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 root_relative = stripped.starts_with('/') && !stripped.starts_with("//");
if !root_relative {
@@ -117,7 +118,15 @@ pub fn collect_static_css_text(
|| std::fs::read(&css_path),
);
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(_) => {
if warned_missing_stylesheets.insert(css_path.clone()) {
if let Some(warn) = warn {
@@ -132,6 +141,162 @@ pub fn collect_static_css_text(
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(|| {
Regex::new(&format!(
r"(?i)^(.+?){ws}*::?(?:before|after)$",
@@ -308,6 +473,9 @@ pub fn build_static_style_map(
inline: false,
};
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,
};
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;
}
+33 -9
View File
@@ -187,15 +187,39 @@ pub fn apply_static_declaration<K: Hash + Eq>(
) {
let map = specified.map.entry(node).or_default();
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) {
let existing = map.get(&expanded_prop).map(|d| &d.meta);
if compare_static_priority(existing, meta) {
let next = SpecifiedDecl {
meta: meta.clone(),
prop: expanded_prop.clone(),
value: expanded_value,
};
map.insert(expanded_prop, next);
}
apply_expanded(map, &expanded_prop, &expanded_value, meta);
}
}
/// Stores one already-expanded longhand for `node` under the cascade's
/// priority rule, the way `apply_static_declaration` stores each pair the
/// shorthand expansion yields. For the longhands that ride beside that
/// 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> =
Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE"));
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::cascade::{build_static_style_map, collect_static_css_text};
use crate::dom::{StaticDocument, StaticElement};
use crate::image_sampling::ImageSampler;
use crate::page::{
check_cream_palette, check_page_layout, check_repeated_container_text_from_doc,
check_static_page_typography,
@@ -119,14 +120,19 @@ const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
("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();
match rule_id {
"border-rules" => {
let radius = resolve_border_radius_px(style, pf0(sv(style, "width")));
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),
"dark-glow" => {
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);
build_static_style_map(&mut doc, css_text.as_str(), profile, fp);
let doc = doc;
let images = ImageSampler::new(&file_dir.to_string_lossy());
let mut findings: Vec<Finding> = Vec::new();
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
@@ -222,7 +229,7 @@ pub fn detect_html_source(
profile,
Meta::new("element", rule_id, fp),
|h: &RuleHit| h.id.as_str(),
|| run_rule(rule_id, el, &tag),
|| run_rule(rule_id, el, &tag, &images),
);
for h in hits {
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 dom;
pub mod engine;
pub mod image_sampling;
pub mod page;
pub mod profile;
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("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
+55 -1
View File
@@ -596,6 +596,18 @@ fn write_carbonize_banner(event: &Map<String, Value>, io: &mut Io) {
}
}
fn reply_ack_json(reply: &Reply) -> Value {
let mut m = Map::new();
m.insert("ok".into(), json!(true));
m.insert("id".into(), json!(reply.id));
m.insert("status".into(), json!(reply.ty));
if let Some(f) = &reply.file {
m.insert("file".into(), json!(f));
}
m.insert("_instructions".into(), json!("Poll again now."));
Value::Object(m)
}
/// JS: printPollEvent(event) — a wire-supplied `_instructions` must never
/// win over the locally generated one (#488).
fn print_poll_event(event: &mut Value, io: &mut Io) {
@@ -718,7 +730,13 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
}
};
return match post_reply(&base, &token, &reply) {
Ok(()) => 0,
Ok(()) => {
println(
io,
&serde_json::to_string(&reply_ack_json(&reply)).unwrap_or_default(),
);
0
}
Err(PollError::ConnRefused) => {
io.err(&format!(
"Live server not running. Start one with: {}\n",
@@ -839,4 +857,40 @@ mod tests {
}));
assert!(parsed.get("_instructions").is_none(), "{}", parsed);
}
#[test]
fn reply_ack_json_includes_file_when_present() {
let reply = Reply {
id: "ab12cd34".into(),
ty: "done".into(),
message: None,
file: Some("index.html".into()),
data: None,
source_event_type: None,
};
let parsed = reply_ack_json(&reply);
assert_eq!(parsed["ok"], json!(true));
assert_eq!(parsed["id"], json!("ab12cd34"));
assert_eq!(parsed["status"], json!("done"));
assert_eq!(parsed["file"], json!("index.html"));
assert_eq!(parsed["_instructions"], json!("Poll again now."));
}
#[test]
fn reply_ack_json_omits_file_when_absent() {
let reply = Reply {
id: "ab12cd34".into(),
ty: "steer_done".into(),
message: None,
file: None,
data: None,
source_event_type: None,
};
let parsed = reply_ack_json(&reply);
assert_eq!(parsed["ok"], json!(true));
assert_eq!(parsed["id"], json!("ab12cd34"));
assert_eq!(parsed["status"], json!("steer_done"));
assert!(parsed.get("file").is_none(), "{}", parsed);
assert_eq!(parsed["_instructions"], json!("Poll again now."));
}
}
+72
View File
@@ -418,6 +418,16 @@ fn push_diag(next: &mut Map<String, Value>, d: Value) {
next.insert("diagnostics".to_string(), Value::Array(arr));
}
fn drop_diag(next: &mut Map<String, Value>, error: &str) {
let mut arr = next
.get("diagnostics")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
arr.retain(|d| d.get("error").and_then(|e| e.as_str()) != Some(error));
next.insert("diagnostics".to_string(), Value::Array(arr));
}
/// JS: applyEvent(snapshot, entry)
pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String, Value> {
let event: Map<String, Value> = match entry.get("event") {
@@ -864,6 +874,7 @@ pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String,
set!("phase", json!("discarded"));
set!("pendingEventSeq", Value::Null);
set!("pendingEvent", Value::Null);
drop_diag(&mut next, "carbonize_cleanup_required");
}
"complete" => {
set!("phase", json!("completed"));
@@ -876,6 +887,7 @@ pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String,
set_if!("previewMode", ev("previewMode"));
set!("pendingEventSeq", Value::Null);
set!("pendingEvent", Value::Null);
drop_diag(&mut next, "carbonize_cleanup_required");
}
"agent_error" => {
if canceled && ev("sourceEventType").and_then(|v| v.as_str()) == Some("generate") {
@@ -925,3 +937,63 @@ fn write_snapshot(path: &str, snapshot: &Map<String, Value>, journal_bytes: i64,
pub fn get_str<'a>(m: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
get(m, k).and_then(|v| v.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn journal_entry(seq: i64, event: Value) -> Value {
json!({ "seq": seq, "ts": "2026-01-01T00:00:00.000Z", "event": event })
}
fn has_diag(snapshot: &Map<String, Value>, error: &str) -> bool {
snapshot
.get("diagnostics")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.any(|d| d.get("error").and_then(|e| e.as_str()) == Some(error))
})
.unwrap_or(false)
}
fn replay(id: &str, events: &[Value]) -> Map<String, Value> {
let mut snap = base_snapshot(id);
for entry in events {
snap = apply_event(&snap, entry);
}
snap
}
fn accept_carbonize_done(id: &str, terminal: &str) -> Map<String, Value> {
replay(
id,
&[
journal_entry(
1,
json!({ "id": id, "type": "accept", "variantId": 2 }),
),
journal_entry(
2,
json!({ "id": id, "type": "agent_done", "carbonize": true, "file": "index.html" }),
),
journal_entry(3, json!({ "id": id, "type": terminal })),
],
)
}
#[test]
fn complete_drops_carbonize_cleanup_required() {
let snap = accept_carbonize_done("ab12cd34", "complete");
assert_eq!(snap.get("phase").and_then(|p| p.as_str()), Some("completed"));
assert!(!has_diag(&snap, "carbonize_cleanup_required"));
}
#[test]
fn discarded_drops_carbonize_cleanup_required() {
let snap = accept_carbonize_done("ab12cd34", "discarded");
assert_eq!(snap.get("phase").and_then(|p| p.as_str()), Some("discarded"));
assert!(!has_diag(&snap, "carbonize_cleanup_required"));
}
}
+2 -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)
- `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.
#### Profiler (`cli/engine/profile/profiler.mjs`)
@@ -1740,7 +1741,7 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit
#### `live-poll.mjs` -> `impeccable poll`
- Invoked from live.md poll loop; `--reply` forms quoted in `_instructions` (see instructions.mjs strings in 6.3/below).
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [message]`, `--help`. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [message]`, `--help`. `--reply` success (stdout, exit 0): one compact JSON line `{ok:true,id,status,file? (only when --file was passed),_instructions:'Poll again now.'}`. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Needs `server.json`; else stderr `No running live server found. Start one with: node "<abs>/live.mjs"` exit 1.
- One-shot: loops `GET /poll?token&timeout=<slice ≤270000>&leaseMs=600000[&types]` until an event or total deadline; prints one JSON line (`console.log(JSON.stringify(event))`) with `_instructions` added by `instructionsForEvent` (unless already present). For `accept`/`discard`: spawns `node live-accept.mjs --id ID (--discard | --variant N) [--page-url U] [--param-values JSON]` (30 s), sets `event._acceptResult` (parse failure/throw → `{handled:false, mode:'error', error}`), then POSTs completion `{id, type: completionType, sourceEventType: event.type, message: _acceptResult.error, file: _acceptResult.file, data: {carbonize:true}?}` where completionType = discard: `discarded` if handled else `error`; accept: `agent_done` if handled&carbonize, `complete` if handled, `error` if mode error or (svelte-component unhandled), else `agent_done`; sets `event._completionAck = {ok:true, type}` (+ `final:false, requiresComplete:true, nextCommand:'live-complete.mjs --id <id>', message:'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.'` for carbonize) or `{ok:false, error}`. Stderr banners: manual_edit_apply → 4-line banner starting `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply <id> done --data '<json>'\`.`; carbonize → `⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id <id>. See reference/live.md "Required after accept".`
- Stream: stderr `[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running`; after each reply-needing event waits (poll `/status` every 400 ms) until the id leaves `pendingEvents` (else `Timed out waiting for --reply on event <id>` exit 1); returns on `exit`.
+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-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": "",
"stderr": "415 anti-patterns found.\n17 advisory notes (not counted).\n",
"stderr": "424 anti-patterns found.\n17 advisory notes (not counted).\n",
"exit": 2,
"signal": null,
"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
+2 -2
View File
@@ -1,11 +1,11 @@
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
".impeccable/live/sessions/ab12cd34.jsonl": "{\"seq\":1,\"id\":\"ab12cd34\",\"type\":\"generate\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"generate\",\"action\":\"bolder\",\"count\":3,\"pageUrl\":\"/\",\"element\":{\"tagName\":\"h1\",\"id\":\"hero\",\"classes\":[\"hero-title\"],\"textContent\":\"Oracle Fixture\",\"outerHTML\":\"<h1 id=\\\"hero\\\" class=\\\"hero-title\\\">Oracle Fixture</h1>\"},\"clientSentAt\":1754042400000}}\n{\"seq\":2,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"generate\",\"carbonize\":false,\"arrivedVariants\":3}}\n{\"seq\":3,\"id\":\"ab12cd34\",\"type\":\"accept\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"accept\",\"variantId\":\"2\",\"pageUrl\":\"/\",\"paramValues\":{\"face\":\"serif\"}}}\n{\"seq\":4,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"accept\",\"carbonize\":true}}\n{\"seq\":5,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n",
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
}
}
@@ -1,11 +1,11 @@
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
".impeccable/live/sessions/ab12cd34.jsonl": "{\"seq\":1,\"id\":\"ab12cd34\",\"type\":\"generate\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"generate\",\"action\":\"bolder\",\"count\":3,\"pageUrl\":\"/\",\"element\":{\"tagName\":\"h1\",\"id\":\"hero\",\"classes\":[\"hero-title\"],\"textContent\":\"Oracle Fixture\",\"outerHTML\":\"<h1 id=\\\"hero\\\" class=\\\"hero-title\\\">Oracle Fixture</h1>\"},\"clientSentAt\":1754042400000}}\n{\"seq\":2,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"generate\",\"carbonize\":false,\"arrivedVariants\":3}}\n{\"seq\":3,\"id\":\"ab12cd34\",\"type\":\"accept\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"accept\",\"variantId\":\"2\",\"pageUrl\":\"/\",\"paramValues\":{\"face\":\"serif\"}}}\n{\"seq\":4,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"accept\",\"carbonize\":true}}\n{\"seq\":5,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n",
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
}
}
+3 -3
View File
@@ -1,13 +1,13 @@
{
"steps": [
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null
},
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null
@@ -16,6 +16,6 @@
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
".impeccable/live/sessions/ab12cd34.jsonl": "{\"seq\":1,\"id\":\"ab12cd34\",\"type\":\"generate\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"generate\",\"action\":\"bolder\",\"count\":3,\"pageUrl\":\"/\",\"element\":{\"tagName\":\"h1\",\"id\":\"hero\",\"classes\":[\"hero-title\"],\"textContent\":\"Oracle Fixture\",\"outerHTML\":\"<h1 id=\\\"hero\\\" class=\\\"hero-title\\\">Oracle Fixture</h1>\"},\"clientSentAt\":1754042400000}}\n{\"seq\":2,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"generate\",\"carbonize\":false,\"arrivedVariants\":3}}\n{\"seq\":3,\"id\":\"ab12cd34\",\"type\":\"accept\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"accept\",\"variantId\":\"2\",\"pageUrl\":\"/\",\"paramValues\":{\"face\":\"serif\"}}}\n{\"seq\":4,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"accept\",\"carbonize\":true}}\n{\"seq\":5,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n{\"seq\":6,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n",
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1173,\n \"__nextSeq\": 7\n}\n"
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1173,\n \"__nextSeq\": 7\n}\n"
}
}
@@ -26,7 +26,7 @@
"signal": null
},
{
"stdout": "",
"stdout": "{\"ok\":true,\"id\":\"ab12cd34\",\"status\":\"done\",\"file\":\"index.html\",\"_instructions\":\"Poll again now.\"}\n",
"stderr": "",
"exit": 0,
"signal": null
@@ -44,7 +44,7 @@
"signal": null
},
{
"stdout": "",
"stdout": "{\"ok\":true,\"id\":\"ab12cd34\",\"status\":\"steer_done\",\"_instructions\":\"Poll again now.\"}\n",
"stderr": "",
"exit": 0,
"signal": null