scripts/check-dashboard-contrast.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* UISpec P2 — dashboard palette contrast gate.
*
* P2-12 was "the provider swatches are hardcoded hex and 5/11 fall under 3:1 on
* one theme or the other". The fix moved them to theme-aware tokens; this gate
* is what keeps them honest, and it also re-checks the token pairs the account
* UI leans on (labels, badges, dots, brand blocks). The DashboardColor pass
* adds the status/accent -text twins on --surface/-soft and the
* --border-control boundary on every surface rung.
*
* Floors (WCAG 2.2):
* 4.5:1 — text (--text, --text-muted, --text-dim; badge labels; brand button text)
* 3.0:1 — graphical objects (swatches, status dots, meter fills) (1.4.11)
* 1.08 — surface ladder steps (UIPlan G4: a card must separate from its page)
*
* --text-faint is deliberately NOT checked: it is the disabled/decorative token
* and never carries body copy (see the comment on its definition).
*
* Exit 0 = every pair clears its floor in BOTH themes.
* Exported as lintDashboardContrast() for the test suite.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
readStyleBlock, customProperties, resolveVar, parseColor, composite, contrastRatio,
} from './dashboard-css.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
export const PROVIDERS = [
'anthropic', 'openai', 'google', 'deepseek', 'xai', 'alibaba',
'moonshot', 'zhipu', 'minimax', 'windsurf', 'other',
];
/** Hue tokens that render as dots / bars / borders — graphical floor. */
export const GRAPHICAL = ['--accent', '--success', '--warn', '--error', '--info'];
/** Soft washes that sit behind a --text label inside .badge / chips, and the
* hue each wash puts in the 6px dot. */
export const SOFT = {
'--surface-3': '--text-dim',
'--success-soft': '--success',
'--error-soft': '--error',
'--warn-soft': '--warn',
'--info-soft': '--info',
'--accent-soft': '--accent',
};
/** AA label twins: the -text hue must clear 4.5 on --surface AND on the
* matching -soft wash it is painted over (chips, tiers, status rows). */
export const TEXT_ON_SOFT = {
'--accent-text': '--accent-soft',
'--success-text': '--success-soft',
'--warn-text': '--warn-soft',
'--error-text': '--error-soft',
'--info-text': '--info-soft',
};
/** Interactive control boundaries — 3:1 on every rung of the surface ladder. */
export const UI_BORDERS = ['--border-control'];
/** Accent interaction states used as rings/borders — graphical 3:1 floor. */
export const ACCENT_STATES = ['--accent-hover', '--accent-active', '--accent-focus'];
/** Status fills and their explicit hover fills; each carries its own on-* label
* (P1-C3 removed brightness() filters so hover is a measured pair, not a
* post-processed one). */
export const STATUS_FILLS = {
'--success': '--success-hover',
'--warn': '--warn-hover',
'--error': '--error-hover',
'--info': '--info-hover',
};
export const ON_FILL = {
'--on-success': '--success',
'--on-warn': '--warn',
'--on-error': '--error',
'--on-info': '--info',
};
/** Runtime-scoped custom properties the GlowGrid writes from JS; they are not
* declared in the stylesheet by design. Everything else must resolve. */
export const RUNTIME_VARS = ['--gg-delay', '--gg-dur', '--gg-hit-tint'];
export const TEXT_FLOOR = 4.5;
export const GRAPHICAL_FLOOR = 3;
export const LADDER_FLOOR = 1.08;
function readDashboardHtml() {
return fs.readFileSync(path.join(ROOT, 'src/dashboard/index.html'), 'utf8');
}
function themeMaps(html) {
const css = readStyleBlock(html);
const light = customProperties(css, ':root');
const dark = { ...light, ...customProperties(css, '[data-theme="dark"]') };
return { light, dark };
}
function color(vars, name) {
const raw = resolveVar(vars[name], vars);
const parsed = parseColor(raw);
if (!parsed) throw new Error(`${name} is not a colour: ${raw}`);
return parsed;
}
function onSurface(vars, name) {
const surface = color(vars, '--surface');
const fg = color(vars, name);
return fg.a < 1 ? composite(fg, surface) : fg;
}
/** Resolve a foreground over a possibly-translucent background that itself
* sits on `base`. Used for label-on-soft and border-on-surface pairs. */
function onBackground(vars, fgName, bgName, baseName = '--surface') {
const base = color(vars, baseName);
let bg = color(vars, bgName);
if (bg.a < 1) bg = composite(bg, base);
const fg = color(vars, fgName);
return fg.a < 1 ? composite(fg, bg) : fg;
}
/** Every `var(--x)` reference must resolve to a declared custom property (or an
* allowlisted runtime-scoped one). This catches the class of bug where
* `var(--muted)` silently inherits instead of using the muted ramp. */
export function undefinedCustomProperties(html = readDashboardHtml()) {
const declared = new Set();
for (const m of html.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) declared.add(m[1]);
const missing = new Set();
for (const m of html.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) {
const name = m[1];
if (!declared.has(name) && !RUNTIME_VARS.includes(name)) missing.add(name);
}
return [...missing].sort();
}
export function lintDashboardContrast(html = readDashboardHtml()) {
const problems = [];
const { light, dark } = themeMaps(html);
for (const token of undefinedCustomProperties(html)) {
problems.push(`undefined custom property referenced: ${token}`);
}
for (const [theme, vars] of Object.entries({ light, dark })) {
const surface = color(vars, '--surface');
// 1. Provider swatches: 3:1 against the surface they sit on.
for (const provider of PROVIDERS) {
const token = `--prov-${provider}`;
if (!(token in vars)) { problems.push(`${theme}: ${token} is not defined`); continue; }
const ratio = contrastRatio(onSurface(vars, token), surface);
if (ratio < GRAPHICAL_FLOOR) problems.push(`${theme}: ${token} is ${ratio}:1 on --surface (needs ${GRAPHICAL_FLOOR})`);
}
// 2. Text tokens.
for (const token of ['--text', '--text-muted', '--text-dim']) {
const ratio = contrastRatio(onSurface(vars, token), surface);
if (ratio < TEXT_FLOOR) problems.push(`${theme}: ${token} is ${ratio}:1 on --surface (needs ${TEXT_FLOOR})`);
}
// 2b. Text tokens on the raised surfaces. The DevIn panel paints 10px
// --text-dim labels on --surface-2 (.dm-stat-label, .dm-req-num) and the
// surface ladder's top step carries chips, so these are text backgrounds
// too — not only borders between cards.
for (const token of ['--text', '--text-muted', '--text-dim']) {
for (const raised of ['--surface-2', '--surface-3']) {
const ratio = contrastRatio(color(vars, token), color(vars, raised));
if (ratio < TEXT_FLOOR) problems.push(`${theme}: ${token} is ${ratio}:1 on ${raised} (needs ${TEXT_FLOOR})`);
}
}
// 2c. AA status/accent labels on --surface and on their own soft wash.
for (const [token, soft] of Object.entries(TEXT_ON_SOFT)) {
for (const bg of ['--surface', '--surface-2', '--surface-3', soft]) {
const ratio = contrastRatio(onBackground(vars, token, bg), onSurface(vars, bg));
if (ratio < TEXT_FLOOR) problems.push(`${theme}: ${token} is ${ratio}:1 on ${bg} (needs ${TEXT_FLOOR})`);
}
}
// 3. Graphical tokens (status dots, meter fills, focus rings).
for (const token of GRAPHICAL) {
const ratio = contrastRatio(onSurface(vars, token), surface);
if (ratio < GRAPHICAL_FLOOR) problems.push(`${theme}: ${token} is ${ratio}:1 on --surface (needs ${GRAPHICAL_FLOOR})`);
}
// 3b. Accent interaction states are rings/borders too — same 3:1 floor.
for (const token of ACCENT_STATES) {
const ratio = contrastRatio(onSurface(vars, token), surface);
if (ratio < GRAPHICAL_FLOOR) problems.push(`${theme}: ${token} is ${ratio}:1 on --surface (needs ${GRAPHICAL_FLOOR})`);
}
// 3c. Control borders must stay visible on every surface rung.
for (const token of UI_BORDERS) {
for (const bg of ['--surface', '--surface-2', '--surface-3']) {
const ratio = contrastRatio(onBackground(vars, token, bg), color(vars, bg));
if (ratio < GRAPHICAL_FLOOR) problems.push(`${theme}: ${token} is ${ratio}:1 on ${bg} (needs ${GRAPHICAL_FLOOR})`);
}
}
// 4. Filled blocks: the label colour on the fill and its explicit hover
// fill. P1-C3 replaced brightness() filters with these measured pairs.
const onAccent = color(vars, '--on-accent');
const fillPairs = [['--on-accent', '--accent'], ['--on-accent', '--accent-hover']];
for (const [label, fill] of Object.entries(ON_FILL)) {
fillPairs.push([label, ON_FILL[label]], [label, STATUS_FILLS[ON_FILL[label]]]);
}
for (const [label, fill] of fillPairs) {
const ratio = contrastRatio(color(vars, label), color(vars, fill));
if (ratio < TEXT_FLOOR) problems.push(`${theme}: ${label} is ${ratio}:1 on ${fill} (needs ${TEXT_FLOOR})`);
}
for (const brand of ['google', 'github']) {
const fg = color(vars, `--brand-${brand}-fg`);
for (const bg of [`--brand-${brand}-bg`, `--brand-${brand}-bg-hover`]) {
const ratio = contrastRatio(fg, color(vars, bg));
if (ratio < TEXT_FLOOR) problems.push(`${theme}: --brand-${brand}-fg is ${ratio}:1 on ${bg} (needs ${TEXT_FLOOR})`);
}
}
// 5. Badge: --text label on the soft wash, hue dot on the same wash.
const label = color(vars, '--text');
for (const [soft, dot] of Object.entries(SOFT)) {
const wash = onSurface(vars, soft);
const labelRatio = contrastRatio(label, wash);
if (labelRatio < TEXT_FLOOR) problems.push(`${theme}: --text is ${labelRatio}:1 on ${soft} (needs ${TEXT_FLOOR})`);
const dotRatio = contrastRatio(color(vars, dot), wash);
if (dotRatio < GRAPHICAL_FLOOR) problems.push(`${theme}: ${dot} is ${dotRatio}:1 on ${soft} (needs ${GRAPHICAL_FLOOR})`);
}
// 6. Surface ladder: each step must separate from the one below it.
const ladder = ['--paper', '--surface', '--surface-2', '--surface-3'];
for (let i = 1; i < ladder.length; i++) {
const ratio = contrastRatio(color(vars, ladder[i]), color(vars, ladder[i - 1]));
if (ratio < LADDER_FLOOR) problems.push(`${theme}: ${ladder[i]} is ${ratio}:1 against ${ladder[i - 1]} (needs ${LADDER_FLOOR})`);
}
}
return { ok: problems.length === 0, problems };
}
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const { ok, problems } = lintDashboardContrast();
if (ok) {
console.log('✓ dashboard palette: every token pair clears its WCAG floor in both themes');
process.exit(0);
}
console.error(`✗ dashboard palette: ${problems.length} contrast failure(s)`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}