From 0f115e5d377d81bfe3ed3b42fb3754502f7a89d2 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 10:43:07 +0500 Subject: [PATCH] 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 --- crates/core/src/checks/sampled_contrast.rs | 27 ++++++++++++------- crates/html/src/background.rs | 19 ++++++++----- crates/html/src/cascade/build.rs | 9 +++++-- crates/html/src/image_sampling.rs | 9 ++++++- .../antipatterns/sampled-image-contrast.html | 4 +++ tests/oracle/DELTAS.md | 2 +- .../golden/detect-dir-json-all-fixtures.json | 2 +- .../golden/detect-dir-quiet-all-fixtures.json | 2 +- .../golden/detect-dir-text-all-fixtures.json | 2 +- ...ture-json-sampled-image-contrast-html.json | 2 +- ...ture-text-sampled-image-contrast-html.json | 2 +- .../golden/detect-no-advisory-json.json | 2 +- .../golden/detect-no-advisory-text.json | 2 +- 13 files changed, 57 insertions(+), 27 deletions(-) diff --git a/crates/core/src/checks/sampled_contrast.rs b/crates/core/src/checks/sampled_contrast.rs index f46ff783e..db3933ff6 100644 --- a/crates/core/src/checks/sampled_contrast.rs +++ b/crates/core/src/checks/sampled_contrast.rs @@ -79,15 +79,16 @@ pub fn is_decorative_layer(repeat: &str, size: &str, intrinsic_w: f64, intrinsic } 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 - } - }); + // 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) } @@ -213,6 +214,14 @@ mod tests { 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)); diff --git a/crates/html/src/background.rs b/crates/html/src/background.rs index 5e7fd3c92..e0d3c8901 100644 --- a/crates/html/src/background.rs +++ b/crates/html/src/background.rs @@ -363,12 +363,14 @@ fn layer_entry(value: &str, index: usize) -> 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`), +/// 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 layer = js::trim(layer); - let open = layer.find('(').map(|i| i + 1).unwrap_or(layer.len()); + let Some(call) = URL_CALL_RE.find(layer) else { + return String::new(); + }; + let open = call.end(); let mut depth = 1usize; let mut quote: Option = None; let mut close = layer.len(); @@ -388,7 +390,7 @@ fn layer_url(layer: &str) -> String { None => {} } } - let decoded = decode_url(&layer[..close]); + let decoded = decode_url(&format!("url({}", &layer[open..close])); let trimmed = js::trim(&decoded); let unquoted = trimmed .strip_prefix('"') @@ -433,7 +435,10 @@ pub fn find_image_ground(el: &StaticElement<'_>) -> Option { }; for (index, layer) in layers.iter().enumerate() { let layer = js::trim(layer); - if URL_START_RE.is_match(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. @@ -459,7 +464,7 @@ pub fn find_image_ground(el: &StaticElement<'_>) -> Option { under, }); } - if GRADIENT_CALL_RE.is_match(layer) { + 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; diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index 441d8d654..2eccce1b5 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -176,8 +176,13 @@ pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String } let cut = target.find(['?', '#']).unwrap_or(target.len()); let (path, suffix) = target.split_at(cut); - let absolute = jsp::resolve("/", &[sheet_dir, path]); - let relative = jsp::to_posix(&jsp::relative("/", page_dir, &absolute)); + // 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(); } diff --git a/crates/html/src/image_sampling.rs b/crates/html/src/image_sampling.rs index 131444b7d..7e80b2933 100644 --- a/crates/html/src/image_sampling.rs +++ b/crates/html/src/image_sampling.rs @@ -121,7 +121,9 @@ impl ImageSampler { } fn is_data_uri(url: &str) -> bool { - url.len() > 5 && url[..5].eq_ignore_ascii_case("data:") + // `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 @@ -242,6 +244,11 @@ mod tests { assert!(sampler.load("https://example.com/hero.jpg").is_none()); assert!(sampler.load("//cdn.example.com/hero.jpg").is_none()); assert_eq!(ground_label("img/hero.jpg?v=3"), "hero.jpg"); + // A 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,{}", diff --git a/tests/fixtures/antipatterns/sampled-image-contrast.html b/tests/fixtures/antipatterns/sampled-image-contrast.html index 16ab06948..9666a32cf 100644 --- a/tests/fixtures/antipatterns/sampled-image-contrast.html +++ b/tests/fixtures/antipatterns/sampled-image-contrast.html @@ -20,6 +20,7 @@ .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); } @@ -73,6 +74,9 @@

Large white heading on the texture

+
+

Shorthand tokens before the image still name the ground.

+
diff --git a/tests/oracle/DELTAS.md b/tests/oracle/DELTAS.md index bb19bfaf6..1dd7388d4 100644 --- a/tests/oracle/DELTAS.md +++ b/tests/oracle/DELTAS.md @@ -172,7 +172,7 @@ 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. +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. diff --git a/tests/oracle/golden/detect-dir-json-all-fixtures.json b/tests/oracle/golden/detect-dir-json-all-fixtures.json index 6bf2afb08..1594ac6ea 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