mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 17:16:46 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f345f7058 | ||
|
|
bb1ca3c7df |
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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)`:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user