Compare commits

...
Author SHA1 Message Date
Paul BakausandClaude Fable 5.1 3f345f7058 Add a real-browser regression for the documented-palette case
Greptile asked for coverage beyond FakeDom, and it is right that the
interesting question here is a browser one: whether the `oklch()` a
DESIGN.md declares and the `oklch()` Chrome computes for an element are the
same color by the time the rule sees them.

A `file://` target loads the DESIGN.md that governs the page's directory,
so the whole path runs end to end: the allowlist is parsed from markdown,
Chrome renders the page, and the browser element sweep decides. The test
writes two pages with the same shapes — verdigris text on a dark instrument
face, and a violet-to-verdigris gradient — one in declared tokens and one in
colors the DESIGN.md never mentions, and asserts the first is silent while
the second still fires. It skips cleanly with no installed browser or no
built binary, the way differential.rs does.

Verified against the pre-fix engine: the declared page reports two
ai-color-palette findings there and none here.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:24:57 -07:00
Paul BakausandClaude Fable 5.1 bb1ca3c7df Fix: a DESIGN.md token is not the generic AI palette
`ai-color-palette` catches the palette nobody picked: the violet and the
cyan a model reaches for when there is no design system. The browser sweep
was reading only the hue, so a site whose own documented tokens land in
those bands tripped it on every element wearing one. On impeccable-site's
"Paper and instruments" pull request, where the DESIGN.md palette is a
verdigris `oklch(70% 0.12 188)` on an `oklch(24% 0 0)` instrument face,
that was 218 findings across 54 pages, all of them "Cyan neon text on dark
background" against a token the author had written down.

`check_element_ai_palette_dom` now takes the scan's design system and skips
any gradient stop or text color the DESIGN.md declares, matched with the
same `browser_colors_close` tolerance the `design-system-color` rule uses,
so a color that rule calls declared is declared here too. A scan with no
DESIGN.md, or one whose DESIGN.md has no palette, is unchanged: there is
nothing to consult and every color stays in scope.

Measured on that site (54 pages, headless Chrome, the in-page bundle):
with the design system, ai-color-palette drops 218 -> 8; without one it
stays at 218. The 8 survivors are the real thing, the purple gradients the
/slop and /docs before-and-after exhibits ship on purpose. No
design-system-* count moves.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:13:52 -07:00
4 changed files with 304 additions and 6 deletions
@@ -0,0 +1,138 @@
//! `ai-color-palette` against a project's own documented palette, through a
//! real browser.
//!
//! The FakeDom tests in `impeccable-core` pin the rule's decision; this one
//! pins the thing only a browser can answer: that the `oklch()` a DESIGN.md
//! declares and the `oklch()` Chrome computes for an element are the same
//! color by the time the rule sees them. A `file://` target loads the
//! DESIGN.md that governs the page's directory, so the whole path runs — the
//! allowlist is parsed from the markdown, the page is rendered, and the
//! browser element sweep decides.
//!
//! Skips cleanly without an installed browser or a built binary, the way
//! `differential.rs` does.
//!
//! Env:
//! - `IMPECCABLE_BIN` — the binary (default `target/debug/impeccable`).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("workspace root")
}
/// A DESIGN.md whose palette is all `oklch()`, including a verdigris that
/// sits inside the rule's cyan band and a violet inside its purple one.
const DESIGN_MD: &str = "---\n\
name: Instruments\n\
colors:\n\
\x20 paper: \"oklch(97.8% 0 0)\"\n\
\x20 ink: \"oklch(13% 0 0)\"\n\
\x20 instrument: \"oklch(24% 0 0)\"\n\
\x20 patina: \"oklch(70% 0.12 188)\"\n\
\x20 iris: \"oklch(58% 0.2 300)\"\n\
---\n\n\
The palette above is the whole system.\n";
fn page(swatch_color: &str, gradient: &str) -> String {
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<title>Instruments</title></head>\
<body style=\"background: oklch(97.8% 0 0); color: oklch(13% 0 0); font-family: Arial, sans-serif\">\
<div style=\"background: oklch(24% 0 0); padding: 24px\">\
<span style=\"color: {swatch_color}; font-size: 16px\">Live</span>\
</div>\
<section style=\"background-image: {gradient}; height: 200px\"></section>\
</body></html>"
)
}
fn rules(bin: &Path, dir: &Path, file: &str) -> Vec<String> {
let url = format!("file://{}", dir.join(file).display());
let out = Command::new(bin)
.arg("detect")
.arg("--json")
.arg(&url)
.current_dir(dir)
.output()
.expect("run detect");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let parsed: Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("detect {url} did not print JSON ({e}): {stdout}"));
parsed
.as_array()
.expect("findings array")
.iter()
.filter_map(|f| f.get("antipattern")?.as_str().map(str::to_string))
.collect()
}
#[test]
fn ai_palette_respects_a_documented_oklch_palette() {
let env: HashMap<String, String> = std::env::vars().collect();
if impeccable_browser::discovery::find_browser(&env).is_err() {
eprintln!("skip: no installed browser found");
return;
}
let bin = std::env::var("IMPECCABLE_BIN")
.map(PathBuf::from)
.unwrap_or_else(|_| workspace_root().join("target/debug/impeccable"));
if !bin.exists() {
eprintln!(
"skip: {} missing (cargo build -p impeccable, or set IMPECCABLE_BIN)",
bin.display()
);
return;
}
let dir = std::env::temp_dir().join(format!("impeccable-ds-palette-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
// A project marker, so the DESIGN.md walk-up stops here rather than
// climbing out of the temp directory.
std::fs::write(dir.join("package.json"), "{\"name\":\"ds-palette-fixture\"}\n").unwrap();
std::fs::write(dir.join("DESIGN.md"), DESIGN_MD).unwrap();
std::fs::write(
dir.join("declared.html"),
page(
"oklch(70% 0.12 188)",
"linear-gradient(oklch(58% 0.2 300), oklch(70% 0.12 188))",
),
)
.unwrap();
std::fs::write(
dir.join("undeclared.html"),
page(
"rgb(0, 229, 255)",
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
),
)
.unwrap();
let declared = rules(&bin, &dir, "declared.html");
let undeclared = rules(&bin, &dir, "undeclared.html");
let _ = std::fs::remove_dir_all(&dir);
// Every color on this page is a token the DESIGN.md declares, so neither
// the palette rule nor the drift rule has anything to say.
assert!(
!declared.iter().any(|r| r == "ai-color-palette"),
"declared tokens reported as a generic AI palette: {declared:?}"
);
assert!(
!declared.iter().any(|r| r == "design-system-color"),
"declared tokens reported as drift: {declared:?}"
);
// The same shapes in colors the DESIGN.md never declared still fire.
assert!(
undeclared.iter().any(|r| r == "ai-color-palette"),
"undeclared neon and violet gradient went unreported: {undeclared:?}"
);
}
+63 -1
View File
@@ -1353,7 +1353,11 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
findings.extend(hits(ec::check_element_colors_dom(dom, el)));
findings.extend(hits(ec::check_element_motion_dom(dom, el)));
findings.extend(hits(ec::check_element_glow_dom(dom, el)));
findings.extend(hits(ec::check_element_ai_palette_dom(dom, el)));
findings.extend(hits(ec::check_element_ai_palette_dom(
dom,
el,
design_system.as_ref(),
)));
findings.extend(hits(ec::check_element_radial_spotlight_dom(dom, el)));
findings.extend(hits(ec::check_element_icon_tile_dom(dom, el)));
findings.extend(hits(ec::check_element_italic_serif_dom(dom, el)));
@@ -1791,6 +1795,64 @@ mod tests {
assert!(types(&out).contains(&"design-system-font".to_string()));
}
/// End to end through the collector: a page whose colors are all its own
/// documented oklch tokens must not report `ai-color-palette`, while a
/// color the DESIGN.md never declared still reports both rules.
#[test]
fn ai_palette_respects_the_design_system_palette() {
// oklch(24% 0 0) instrument face carrying oklch(70% 0.12 188) verdigris.
let make_dom = |text_color: &str| {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let panel = d.add(Some(body), "div");
d.set_styles(panel, &[("backgroundColor", "rgb(58, 58, 58)")]);
d.el_mut(panel).check_visibility = Some(true);
let label = d.add(Some(panel), "span");
d.add_text(label, "Live");
d.set_styles(
label,
&[
("color", text_color),
("backgroundColor", "rgba(0, 0, 0, 0)"),
("fontFamily", "Inter, sans-serif"),
],
);
d.el_mut(label).check_visibility = Some(true);
d
};
let types = |out: &CollectResult| -> Vec<String> {
out.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.collect()
};
let design_system = json!({
"present": true,
"hasFonts": true, "allowedFonts": ["Inter"],
"hasColors": true,
"allowedColors": [{ "r": 15, "g": 182, "b": 172 }, { "r": 58, "g": 58, "b": 58 }]
});
let with_ds = BrowserConfig {
design_system: Some(design_system),
..Default::default()
};
let without_ds = BrowserConfig::default();
// No DESIGN.md: the teal is an unexplained neon and the rule fires.
let out = collect_browser_findings(&make_dom("rgb(15, 182, 172)"), &without_ds);
assert!(types(&out).contains(&"ai-color-palette".to_string()));
// Declared token: neither the palette rule nor the drift rule fires.
let out = collect_browser_findings(&make_dom("rgb(15, 182, 172)"), &with_ds);
assert!(!types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(!types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
// An undeclared neon on the same page still reports both.
let out = collect_browser_findings(&make_dom("rgb(0, 229, 255)"), &with_ds);
assert!(types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
}
#[test]
fn disabled_values_parse_and_normalize_like_the_js() {
// JS `.filter(e => e && typeof e === 'object' && e.rule && e.value)`:
+101 -3
View File
@@ -11,6 +11,7 @@ use super::dom::{
class_attr, class_attr_or_prop, closest_or_none, direct_text, has_direct_text_longer_than,
matches_or_false, pf0, safe_id, style_px, tag_lower, Dom, ElId, ElStyle, Rect,
};
use super::driver::{browser_colors_close, DesignSystemConfig};
use super::BrowserFinding;
use crate::checks::measures::{
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
@@ -716,11 +717,42 @@ pub fn check_element_glow_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
})
}
/// True when the scan was given a DESIGN.md and that file declares this
/// color as one of the project's own.
///
/// `ai-color-palette` is a rule about the *unchosen* palette: the purple and
/// the cyan a model reaches for when nobody picked one. A color the author
/// wrote down in DESIGN.md was picked, so it is not that default whatever
/// its hue, and a site whose whole palette is its own documented tokens must
/// not trip the rule on every element that wears one. With no design system
/// there is nothing to consult and every color stays in scope, which is the
/// behavior every scan without a DESIGN.md keeps.
///
/// The tolerance is `browser_colors_close`, the same one the
/// `design-system-color` rule matches computed colors with, so a token the
/// design-system rule calls declared is declared here too.
fn is_declared_design_color(ds: Option<&DesignSystemConfig>, c: &Rgba) -> bool {
let Some(ds) = ds else { return false };
if !ds.has_colors {
return false;
}
ds.allowed_colors
.iter()
.any(|allowed| browser_colors_close(c, allowed))
}
/// JS: checks.mjs#checkElementAIPaletteDOM(el)
pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
pub fn check_element_ai_palette_dom(
dom: &dyn Dom,
el: ElId,
design_system: Option<&DesignSystemConfig>,
) -> Vec<RuleHit> {
let mut findings = Vec::new();
let bg_image = dom.style(el, "backgroundImage");
for c in parse_gradient_colors(Some(&bg_image)) {
if is_declared_design_color(design_system, &c) {
continue;
}
if has_chroma(Some(&c), Some(50.0)) {
let hue = get_hue(Some(&c));
if hue >= 260.0 && hue <= 310.0 {
@@ -739,7 +771,8 @@ pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
}
}
}
let text_color = parse_rgb_or_any(&dom.style(el, "color"));
let text_color = parse_rgb_or_any(&dom.style(el, "color"))
.filter(|c| !is_declared_design_color(design_system, c));
if let Some(tc) = text_color {
if has_chroma(Some(&tc), Some(80.0)) {
let hue = get_hue(Some(&tc));
@@ -1534,7 +1567,72 @@ mod tests {
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
);
d.set_style(hero, "color", "rgb(0, 0, 0)");
let hits = check_element_ai_palette_dom(&d, hero);
let hits = check_element_ai_palette_dom(&d, hero, None);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].snippet, "Purple/violet gradient background");
}
/// A DESIGN.md palette built out of the project's own oklch tokens is not
/// the generic assistant default, however cyan or violet the tokens are.
/// The verdigris-on-instrument pair here is the shape that fired 80 times
/// on one site whose whole palette is documented.
fn design_system_with(colors: &[(f64, f64, f64)]) -> DesignSystemConfig {
DesignSystemConfig {
has_colors: true,
allowed_colors: colors
.iter()
.map(|&(r, g, b)| Rgba { r, g, b, a: None })
.collect(),
..DesignSystemConfig::default()
}
}
#[test]
fn ai_palette_skips_colors_the_design_system_declares() {
let (mut d, body) = page();
// oklch(24% 0 0) instrument face, oklch(70% 0.12 188) verdigris text.
let panel = d.add(Some(body), "div");
d.set_style(panel, "backgroundColor", "rgb(58, 58, 58)");
let label = d.add(Some(panel), "span");
d.set_style(label, "color", "rgb(15, 182, 172)");
// With no DESIGN.md the rule still fires: nothing says the teal was chosen.
let hits = check_element_ai_palette_dom(&d, label, None);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].snippet, "Cyan neon text on dark background");
// Declared in DESIGN.md, so it is the project's palette, not the default.
let ds = design_system_with(&[(15.0, 182.0, 172.0)]);
assert!(check_element_ai_palette_dom(&d, label, Some(&ds)).is_empty());
// A design system that declares some other color leaves the rule alone.
let other = design_system_with(&[(200.0, 40.0, 30.0)]);
assert_eq!(check_element_ai_palette_dom(&d, label, Some(&other)).len(), 1);
// `hasColors: false` is a DESIGN.md with no palette section: no allowlist
// to consult, so the rule keeps its unconstrained behavior.
let empty = DesignSystemConfig::default();
assert_eq!(check_element_ai_palette_dom(&d, label, Some(&empty)).len(), 1);
}
#[test]
fn ai_palette_gradient_skips_declared_stops_but_not_undeclared_ones() {
let (mut d, body) = page();
let hero = d.add(Some(body), "section");
d.set_style(
hero,
"backgroundImage",
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
);
d.set_style(hero, "color", "rgb(0, 0, 0)");
// The violet stop is a declared token, so this gradient is the project's.
let ds = design_system_with(&[(168.0, 85.0, 247.0), (59.0, 130.0, 246.0)]);
assert!(check_element_ai_palette_dom(&d, hero, Some(&ds)).is_empty());
// Declaring only the blue stop leaves the violet one in scope.
let partial = design_system_with(&[(59.0, 130.0, 246.0)]);
let hits = check_element_ai_palette_dom(&d, hero, Some(&partial));
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].snippet, "Purple/violet gradient background");
}
File diff suppressed because one or more lines are too long