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
This commit is contained in:
Paul Bakaus
2026-09-11 12:13:52 -07:00
co-authored by Claude Fable 5.1
parent cb56ed6c19
commit bb1ca3c7df
3 changed files with 166 additions and 6 deletions
+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