diff --git a/Cargo.lock b/Cargo.lock index 2e7f6dbdc..2714ca593 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/README.md b/README.md index c4b9831a5..1ea5aaf47 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/core/src/checks/mod.rs b/crates/core/src/checks/mod.rs index 760af4962..52757bb5c 100644 --- a/crates/core/src/checks/mod.rs +++ b/crates/core/src/checks/mod.rs @@ -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")] diff --git a/crates/core/src/checks/rules.rs b/crates/core/src/checks/rules.rs index 08827a4fc..59d16278d 100644 --- a/crates/core/src/checks/rules.rs +++ b/crates/core/src/checks/rules.rs @@ -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 { 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(); diff --git a/crates/core/src/checks/sampled_contrast.rs b/crates/core/src/checks/sampled_contrast.rs new file mode 100644 index 000000000..f46ff783e --- /dev/null +++ b/crates/core/src/checks/sampled_contrast.rs @@ -0,0 +1,293 @@ +//! 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)); + let w = px(tokens.first()).unwrap_or(intrinsic_w); + let h = px(tokens.get(1)).unwrap_or_else(|| { + // `background-size: 40px` scales the height with the width. + if tokens.len() == 1 && px(tokens.first()).is_some() && intrinsic_w > 0.0 { + intrinsic_h * (w / intrinsic_w) + } else { + 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 { + 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, overlays: &[Rgba]) -> Option { + 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 { + 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 = 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("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 = 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 = (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 = (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 = 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()); + } +} diff --git a/crates/html/Cargo.toml b/crates/html/Cargo.toml index 3c73434f8..951f8ca76 100644 --- a/crates/html/Cargo.toml +++ b/crates/html/Cargo.toml @@ -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"] } diff --git a/crates/html/src/adapters.rs b/crates/html/src/adapters.rs index 7a83bec92..7ce84c512 100644 --- a/crates/html/src/adapters.rs +++ b/crates/html/src/adapters.rs @@ -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 { 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 { + 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 = 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) diff --git a/crates/html/src/background.rs b/crates/html/src/background.rs index d9b7786b9..5e7fd3c92 100644 --- a/crates/html/src/background.rs +++ b/crates/html/src/background.rs @@ -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,146 @@ 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, + /// What a translucent pixel composites over, when the walk can say. + pub under: Option, +} + +/// 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. The layer +/// may carry the rest of a shorthand after the call (`url(x)center/cover`), +/// so only the call itself is decoded. +fn layer_url(layer: &str) -> String { + let layer = js::trim(layer); + let open = layer.find('(').map(|i| i + 1).unwrap_or(layer.len()); + let mut depth = 1usize; + let mut quote: Option = 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(&layer[..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 { + let mut current = Some(*el); + let mut overlays: Vec = 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 = 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); + if URL_START_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 GRADIENT_CALL_RE.is_match(layer) { + 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 +} diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index bde2406fd..db6baa8e7 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -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 = /// 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 { @@ -96,8 +97,9 @@ pub fn collect_static_css_text( profile: Option<&dyn ProfileSink>, file_path: &str, warn: Option<&dyn Fn(&str)>, -) -> String { +) -> (String, Vec) { let mut style_texts: Vec = Vec::new(); + let mut sheet_dirs: Vec = Vec::new(); let mut warned_missing_stylesheets: std::collections::HashSet = std::collections::HashSet::new(); for style_el in doc.query_selector_all("style") { @@ -117,7 +119,13 @@ 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) => { + style_texts.push(String::from_utf8_lossy(&bytes).into_owned()); + let dir = jsp::dirname(&css_path); + if !sheet_dirs.contains(&dir) { + sheet_dirs.push(dir); + } + } Err(_) => { if warned_missing_stylesheets.insert(css_path.clone()) { if let Some(warn) = warn { @@ -129,7 +137,7 @@ pub fn collect_static_css_text( } } } - style_texts.join("\n") + (style_texts.join("\n"), sheet_dirs) } static PSEUDO_RULE_RE: Lazy = Lazy::new(|| { @@ -308,6 +316,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 +342,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; } diff --git a/crates/html/src/cascade/rules.rs b/crates/html/src/cascade/rules.rs index f7289765a..732431b79 100644 --- a/crates/html/src/cascade/rules.rs +++ b/crates/html/src/cascade/rules.rs @@ -187,15 +187,39 @@ pub fn apply_static_declaration( ) { 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( + specified: &mut SpecifiedStore, + 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, + 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); } } diff --git a/crates/html/src/cascade/shorthand.rs b/crates/html/src/cascade/shorthand.rs index 0c975ba39..d33720214 100644 --- a/crates/html/src/cascade/shorthand.rs +++ b/crates/html/src/cascade/shorthand.rs @@ -193,6 +193,88 @@ pub fn parse_static_animation(value: &str) -> StaticAnimation { } } +static BG_REPEAT_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?i)^(?:repeat|no-repeat|repeat-x|repeat-y|space|round)$").expect("BG_REPEAT_RE") +}); +static BG_SIZE_RE: Lazy = + 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 = Vec::new(); + let mut sizes: Vec = Vec::new(); + for layer in split_css_list(value) { + let mut repeat: Vec = Vec::new(); + let mut size: Vec = 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. A `background` shorthand with an image resets both to +/// what it names, as in CSS. +pub fn background_longhands(prop: &str, value: &str) -> Vec { + let v = js::trim(value); + match js::to_lower_case(prop).as_str() { + "background" if BG_IMAGE_RE.is_match(v) => { + 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 BG_IMAGE_RE: Lazy = Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE")); static BG_IMAGE_SPLIT_RE: Lazy = Lazy::new(|| { diff --git a/crates/html/src/engine.rs b/crates/html/src/engine.rs index b2777e4ef..50023d429 100644 --- a/crates/html/src/engine.rs +++ b/crates/html/src/engine.rs @@ -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 { +fn run_rule( + rule_id: &str, + el: &StaticElement<'_>, + tag: &str, + images: &ImageSampler, +) -> Vec { 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); @@ -207,9 +213,11 @@ pub fn detect_html_source( Meta::new("parse-html", "parse-document", fp), || StaticDocument::parse(html), ); - let css_text = collect_static_css_text(&doc, &file_dir, profile, fp, options.warn); + let (css_text, sheet_dirs) = + collect_static_css_text(&doc, &file_dir, profile, fp, options.warn); build_static_style_map(&mut doc, css_text.as_str(), profile, fp); let doc = doc; + let images = ImageSampler::new(&file_dir.to_string_lossy(), &sheet_dirs); let mut findings: Vec = Vec::new(); let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0); @@ -222,7 +230,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) { @@ -394,7 +402,7 @@ pub fn unsupported_selectors(html: &str, file_path: &Path) -> Vec { .map(|p| p.to_path_buf()) .unwrap_or_default(); let mut doc = StaticDocument::parse(html); - let css_text = collect_static_css_text(&doc, &file_dir, None, &file_str, None); + let (css_text, _) = collect_static_css_text(&doc, &file_dir, None, &file_str, None); build_static_style_map(&mut doc, &css_text, None, &file_str); doc.unsupported_selectors() } diff --git a/crates/html/src/image_sampling.rs b/crates/html/src/image_sampling.rs new file mode 100644 index 000000000..20d095209 --- /dev/null +++ b/crates/html/src/image_sampling.rs @@ -0,0 +1,252 @@ +//! The pixels behind image-backed text for the static engine (#560). A +//! `url()` resolves to bytes from a local file next to the markup or from a +//! base64 data URI, never from the network; the pure-Rust decoders turn them +//! into a raster no larger than the browser overlay's 640px canvas; and the +//! raster is cached for the rest of the process, so a directory scan decodes +//! each hero once. Anything unreadable, remote, oversized, or undecodable is +//! `None`, and the caller keeps today's skip. + +use crate::cascade::resolve_linked_css_path; +use base64::Engine; +use impeccable_common::jsp; +use impeccable_core::color::Rgba; +use std::cell::RefCell; +use std::collections::HashMap; +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; +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, +} + +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>>> = RefCell::new(HashMap::new()); +} + +/// Resolves and decodes the `url()` grounds of one document. The bases are +/// the document's directory followed by every linked stylesheet's, since a +/// relative `url()` inside a sheet is relative to the sheet. +pub struct ImageSampler { + bases: Vec, +} + +impl ImageSampler { + pub fn new(html_dir: &str, stylesheet_dirs: &[String]) -> Self { + let mut bases = vec![html_dir.to_string()]; + for dir in stylesheet_dirs { + if !bases.contains(dir) { + bases.push(dir.clone()); + } + } + ImageSampler { bases } + } + + /// 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> { + let url = url.trim(); + if url.is_empty() { + return None; + } + let key = if is_data_uri(url) { + url.to_string() + } 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 { + if url.starts_with("//") || url.contains("://") { + return None; + } + self.bases + .iter() + .map(|base| resolve_linked_css_path(base, url)) + .find(|path| { + std::fs::metadata(path) + .map(|m| m.is_file()) + .unwrap_or(false) + }) + } +} + +fn is_data_uri(url: &str) -> bool { + url.len() > 5 && url[..5].eq_ignore_ascii_case("data:") +} + +/// The name a finding gives the image: the file name, or `data:`. +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> { + let (header, payload) = url[5..].split_once(',')?; + if !header + .split(';') + .any(|p| p.trim().eq_ignore_ascii_case("base64")) + { + return None; + } + let compact: String = payload.chars().filter(|c| !c.is_whitespace()).collect(); + base64::engine::general_purpose::STANDARD + .decode(&compact) + .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&compact)) + .ok() +} + +fn read_bounded(path: &str) -> Option> { + 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 { + 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 { + 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,").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"); + } + + #[test] + fn files_resolve_against_every_base_and_downscale() { + let dir = std::env::temp_dir().join(format!("impeccable-sampler-{}", std::process::id())); + let sheet_dir = dir.join("css"); + std::fs::create_dir_all(&sheet_dir).unwrap(); + std::fs::write( + sheet_dir.join("wide.png"), + png_bytes(1280, 320, [20, 20, 20, 255]), + ) + .unwrap(); + let html_dir = dir.to_string_lossy().into_owned(); + let sampler = ImageSampler::new(&html_dir, &[sheet_dir.to_string_lossy().into_owned()]); + assert!(sampler.load("missing.png").is_none()); + let raster = sampler + .load("wide.png") + .expect("resolves against the sheet 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("wide.png").unwrap())); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/html/src/lib.rs b/crates/html/src/lib.rs index 54128c463..0ff5cb217 100644 --- a/crates/html/src/lib.rs +++ b/crates/html/src/lib.rs @@ -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; diff --git a/crates/html/tests/cascade_units.rs b/crates/html/tests/cascade_units.rs index a1d660883..d4a93bddc 100644 --- a/crates/html/tests/cascade_units.rs +++ b/crates/html/tests/cascade_units.rs @@ -209,3 +209,85 @@ 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())] + ); + assert!(background_longhands("background", "#fff").is_empty()); + assert!(background_longhands("color", "red").is_empty()); + + // A later shorthand with an image resets an earlier longhand, and a + // later longhand overrides a shorthand, under the cascade's priority. + let mut specified: SpecifiedStore<&str> = SpecifiedStore::new(); + let node = "n1"; + let mut apply = |prop: &str, value: &str, m: DeclMeta| { + apply_static_declaration(&mut specified, node, prop, value, &m); + for (p, v) in background_longhands(prop, value) { + apply_static_longhand(&mut specified, node, &p, &v, &m); + } + }; + apply( + "background-repeat", + "no-repeat", + meta(false, [0, 1, 0], 0, false), + ); + apply( + "background", + "url(hero.jpg) center / cover", + meta(false, [0, 1, 0], 1, false), + ); + apply( + "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") + ); +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 7d1f89d76..a89ab017e 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -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 `` that are local (not `/^(https?:)?\/\//i`), query/hash stripped. Runs element rules, design-system rules (`checkSourceDesignSystem` + `collectStaticDesignSystemFindings`, merged), then page rules only when `isFullPage(html)` (`/]|]/i` after stripping comments), plus text-content analyzers; ends with inline-ignore filtering. +- **Sampled contrast (#560, engine only, no JS ancestor)**: when `resolveBackgroundInfo` ends at a `url()` layer (the case the JS skipped), the static engine reads that image and measures the text against its pixels. The url resolves to a local file (relative to the page, then to each linked stylesheet's directory; root-relative paths walk up to the project root the way linked stylesheets do) or a base64 `data:` URI; a remote URL, a missing or unreadable file, a file over 24 MiB, an image over 8192px on a side, and SVG keep the skip. Decoders: PNG, JPEG, GIF, WebP. The image is scaled to at most 640px on a side and read on a fixed 6x6 grid. Each sample composites a translucent pixel over the element's own color and its parent's resolved ground (white at the root; unknown when another layer sits beneath the image), then under every translucent surface and every uniform gradient wash between the text and the image; a gradient whose stops differ is a scrim placed on purpose and keeps the skip, as does an opaque gradient or an unparseable color anywhere in the chain. A layer whose `background-repeat` leaves an axis unrepeated and whose painted extent on that axis (`background-size` in px, else the intrinsic size) is under 160px is decoration and keeps the skip; `cover`, `contain`, and percentage sizes always paint. The cascade carries `backgroundRepeat` / `backgroundSize` for this from the longhands and from the `background` shorthand (per layer, defaults `repeat` / `auto`). A verdict needs 27 of the 36 samples; the finding fires when the 90th-percentile ratio is under the WCAG threshold (same large-text rule as `checkColors`, text alpha blended over the sample): `{id:'low-contrast', snippet:`sampled (coarse) ${p90}:1 (need ${threshold}:1) — text ${hex} on ${file name | data:}; p90 of ${n} samples, median ${median}:1`}` (ratio label to 2 decimals when its 1-decimal form equals the threshold, as in `checkColors`). It is emitted in the `color-rules` pass right after `checkColors`'s hits for that element, obeys the same `SAFE_TAGS` gate, `data-impeccable-ignore`, and inline ignores, and never produces `gray-on-color`. Fixture: `sampled-image-contrast.html` (+ `sampled-images/`). - `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`, ` + + +

Sampled contrast fixture

+ + +
+

White copy on a near-white texture fails everywhere the grid lands.

+
+
+

Smoke gray copy on a charcoal photo fails wherever it sits.

+
+
+

The same texture as a WebP decodes through the same path.

+
+
+

A base64 PNG data URI is read without touching the disk.

+
+
+

A thirty percent tint is too weak to carry white copy over the texture.

+
+
+
+

Ink on the translucent texture composites over the dark section and fails.

+
+
+
+

The ground of this panel is declared in the linked stylesheet.

+
+
+

Large white heading on the texture

+
+ + +
+

Ink on the near-white texture reads fine and must not flag.

+
+
+

White copy on the charcoal photo reads fine and must not flag.

+
+
+

Half of this image is dark, so the copy may well sit there.

+
+
+

A sixty percent tint carries white copy over the texture.

+
+
+

A scrim that fades out is placed on purpose; the engine keeps the skip.

+
+
+
+

White copy on the translucent texture over the dark section passes.

+
+
+
+

An image that is not on disk keeps the skip.

+
+
+

A remote image is never fetched during a file scan.

+
+
+

An SVG has no raster to sample, so the skip stands.

+
+ +
+

A waived panel keeps its sampled finding out of the report.

+
+ + diff --git a/tests/fixtures/antipatterns/sampled-images/cutout.png b/tests/fixtures/antipatterns/sampled-images/cutout.png new file mode 100644 index 000000000..384d9b23f Binary files /dev/null and b/tests/fixtures/antipatterns/sampled-images/cutout.png differ diff --git a/tests/fixtures/antipatterns/sampled-images/dark.jpg b/tests/fixtures/antipatterns/sampled-images/dark.jpg new file mode 100644 index 000000000..446046dc7 Binary files /dev/null and b/tests/fixtures/antipatterns/sampled-images/dark.jpg differ diff --git a/tests/fixtures/antipatterns/sampled-images/icon.png b/tests/fixtures/antipatterns/sampled-images/icon.png new file mode 100644 index 000000000..49a1e2506 Binary files /dev/null and b/tests/fixtures/antipatterns/sampled-images/icon.png differ diff --git a/tests/fixtures/antipatterns/sampled-images/light.png b/tests/fixtures/antipatterns/sampled-images/light.png new file mode 100644 index 000000000..4fd77b8cb Binary files /dev/null and b/tests/fixtures/antipatterns/sampled-images/light.png differ diff --git a/tests/fixtures/antipatterns/sampled-images/light.webp b/tests/fixtures/antipatterns/sampled-images/light.webp new file mode 100644 index 000000000..b3b240458 Binary files /dev/null and b/tests/fixtures/antipatterns/sampled-images/light.webp differ diff --git a/tests/fixtures/antipatterns/sampled-images/site.css b/tests/fixtures/antipatterns/sampled-images/site.css new file mode 100644 index 000000000..fb36603b5 --- /dev/null +++ b/tests/fixtures/antipatterns/sampled-images/site.css @@ -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); } diff --git a/tests/fixtures/antipatterns/sampled-images/split.png b/tests/fixtures/antipatterns/sampled-images/split.png new file mode 100644 index 000000000..59448bd2c Binary files /dev/null and b/tests/fixtures/antipatterns/sampled-images/split.png differ diff --git a/tests/oracle/DELTAS.md b/tests/oracle/DELTAS.md index d0b74dc01..bb19bfaf6 100644 --- a/tests/oracle/DELTAS.md +++ b/tests/oracle/DELTAS.md @@ -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 eight `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. diff --git a/tests/oracle/golden/detect-dir-json-all-fixtures.json b/tests/oracle/golden/detect-dir-json-all-fixtures.json index 349f77ae2..6bf2afb08 100644 --- a/tests/oracle/golden/detect-dir-json-all-fixtures.json +++ b/tests/oracle/golden/detect-dir-json-all-fixtures.json @@ -1,5 +1,5 @@ { - "stdout": "[\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 52,\n \"snippet\": \"[data-case=\\\"Kinpaku Edge\\\"] — inset box-shadow 3px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 53,\n \"snippet\": \"[data-case=\\\"Patina Edge\\\"] — inset box-shadow 3px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 54,\n \"snippet\": \"[data-case=\\\"Accent Edge\\\"] — inset box-shadow 4px stripe (right)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 55,\n \"snippet\": \"[data-case=\\\"Signal Blue Edge\\\"] — inset box-shadow 5px stripe (top)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 66,\n \"snippet\": \"[data-case=\\\"Chromatic Hex Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 67,\n \"snippet\": \"[data-case=\\\"Named Red Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 68,\n \"snippet\": \"[data-case=\\\"Chromatic Rgb Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 69,\n \"snippet\": \"[data-case=\\\"Chromatic Oklch Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 78,\n \"snippet\": \"[data-case=\\\"Trailing Inset Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 79,\n \"snippet\": \"[data-case=\\\"Trailing Inset Token Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 81,\n \"snippet\": \"[data-case=\\\"Inset Named Token Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 86,\n \"snippet\": \"[data-case=\\\"Two Length Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 87,\n \"snippet\": \"[data-case=\\\"Two Length Trailing Inset Edge\\\"] — inset box-shadow 5px stripe (top)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 88,\n \"snippet\": \"[data-case=\\\"Important Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 89,\n \"snippet\": \"[data-case=\\\"Cascade Override Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 91,\n \"snippet\": \"[data-case=\\\"Color First Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro\",\n \"line\": 92,\n \"snippet\": \"[data-case=\\\"Color First Var Edge\\\"] — inset box-shadow 4px stripe (left)\"\n },\n {\n \"antipattern\": \"pulsing-dot\",\n \"name\": \"Pulsing status dot\",\n \"description\": \"Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/blinking-cursor.html\",\n \"line\": 0,\n \"snippet\": \".pass-round-dot — 8x8px dot with infinite \\\"blink-anim\\\" animation\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-right: 5px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-top: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"border-accent-on-rounded\",\n \"name\": \"Border accent on rounded element\",\n \"description\": \"Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-bottom: 3px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px + border-radius: 10px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-top: 4px\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/border-baseline.html\",\n \"line\": 0,\n \"snippet\": \"border-bottom: 3px\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"raster background at opacity 0.04 \\\"Grain\\\"\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \" at opacity 0.05 \\\"Ghost img\\\"\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"raster under a near-opaque gradient wash: linear-gradient(rgba(240,237,226,0.96), rgba(240,237,226,0.96)), url(assets/manual-paper.p\"\n },\n {\n \"antipattern\": \"buried-raster\",\n \"name\": \"Raster buried under a wash or opacity\",\n \"description\": \"A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/buried-raster.html\",\n \"line\": 0,\n \"snippet\": \"raster under a near-opaque gradient wash: linear-gradient(0deg, rgba(255,255,255,.95), rgba(255,255,255,.95)), url(\\\"assets/bone-pape\"\n },\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"
\\\"pass-split-container\\\": children flush against border on all sides (no inset)\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-hidden clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-clip clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-negative clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-right clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-shadow-utility clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overlay-surface clips a positioned child\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #969696 on bg #3b82f6\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #969696 on #3b82f6\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #b4b4b4 on bg #10b981\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #b4b4b4 on #10b981\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.7:1 (need 4.5:1) — text #c8c8c8 on #ffffff\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.1:1 (need 4.5:1) — text #505050 on #1e1e1e\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #808080 on bg gradient(#3b82f6, #8b5cf6)\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.1:1 (need 3:1) — text #808080 on #8b5cf6\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #666666 on bg gradient(#3b82f6, #8b5cf6)\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.4:1 (need 4.5:1) — text #666666 on #8b5cf6\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.2:1 (need 4.5:1) — text #5b4f44 on #1f1a15\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"2.1:1 (need 4.5:1) — text #6c7280 on #374151\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #5c5449 on bg #b6322d\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.2:1 (need 4.5:1) — text #5c5449 on #b6322d\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text-gray-400 on bg-blue-500\"\n },\n {\n \"antipattern\": \"gray-on-color\",\n \"name\": \"Gray text on colored background\",\n \"description\": \"Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text #9ca3af on bg #3b82f6\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.4:1 (need 4.5:1) — text #9ca3af on #3b82f6\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet text (#a855f7) on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"text-purple-500 on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet gradient (Tailwind)\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"4.0:1 (need 4.5:1) — text #ffffff on #a855f7\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.1:1 (need 4.5:1) — text #3d2418 on #17372d\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"1.3:1 (need 4.5:1) — text #cfc9bd on #e8e2d6\"\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\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"4.1:1 (need 4.5:1) — text #ffffff on #7d7d7d\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"8px functional text \\\"tick\\\" (below 11px floor)\"\n },\n {\n \"antipattern\": \"skipped-heading\",\n \"name\": \"Skipped heading level\",\n \"description\": \"Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"

\\\"Welcome to Our Platform\\\" followed by

\\\"Gradient text\\\" (missing h2)\"\n },\n {\n \"antipattern\": \"skipped-heading\",\n \"name\": \"Skipped heading level\",\n \"description\": \"Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/tests/fixtures/antipatterns/color.html\",\n \"line\": 0,\n \"snippet\": \"

\\\"Purple heading text\\\" followed by

\\\"Styled and