test(#1668 M2): RED contrast test for palette + AA token bumps

Adds test-issue-1668-m2-contrast.js. Parses public/style.css, resolves
two-tier (--palette-*  → semantic) tokens, computes WCAG contrast for
each foreground/background pair flagged BLOCKER in the M1 a11y audit.

Currently fails on assertion (palette families not yet introduced) —
this is the RED commit for the TDD cycle.
This commit is contained in:
openclaw-bot
2026-06-12 04:01:22 +00:00
parent b0996047ef
commit d761516d60
+161
View File
@@ -0,0 +1,161 @@
/* Issue #1668 M2 / #1671 — Palette indirection + WCAG AA token bumps.
*
* This test:
* 1. Parses public/style.css and extracts CSS custom-property values per
* theme (:root = light, [data-theme="dark"] = dark).
* 2. Resolves var(...) indirection (one level deep is enough for our
* two-tier palette → semantic mapping).
* 3. Computes WCAG relative-luminance contrast ratios for the foreground/
* background pairs that were flagged as BLOCKER in the M1 a11y audit
* (a11y-audit/reports/violations-summary.md).
* 4. Asserts each pair meets WCAG AA (≥4.5:1 for body text).
*
* Source for contrast formula: https://www.w3.org/WAI/WCAG21/Techniques/general/G18
*/
'use strict';
const fs = require('fs');
const assert = require('assert');
const CSS_PATH = 'public/style.css';
const css = fs.readFileSync(CSS_PATH, 'utf8');
// ── Token extraction ──────────────────────────────────────────────────────
function extractBlockTokens(blockRegex) {
const m = css.match(blockRegex);
if (!m) return {};
const body = m[1];
const tokens = {};
const re = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim;
let mm;
while ((mm = re.exec(body)) !== null) {
tokens[mm[1]] = mm[2].trim();
}
return tokens;
}
// Light theme = :root block (the FIRST :root, lines ~99-247)
const lightTokens = extractBlockTokens(/:root\s*\{([\s\S]*?)\n\}/);
// Dark theme = [data-theme="dark"] block
const darkTokens = extractBlockTokens(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/);
function resolveToken(name, theme) {
const map = theme === 'dark' ? { ...lightTokens, ...darkTokens } : lightTokens;
let val = map[name];
if (!val) return null;
// resolve up to 5 levels of var() indirection
for (let i = 0; i < 5; i++) {
const m = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^)]+))?\)\s*$/);
if (!m) break;
const next = map[m[1]];
val = next || (m[2] ? m[2].trim() : null);
if (!val) return null;
}
return val;
}
// ── Color parsing + contrast ──────────────────────────────────────────────
function parseColor(s) {
if (!s) return null;
s = s.trim();
// #rgb / #rrggbb
let m = s.match(/^#([0-9a-f]{3})$/i);
if (m) {
return [
parseInt(m[1][0] + m[1][0], 16),
parseInt(m[1][1] + m[1][1], 16),
parseInt(m[1][2] + m[1][2], 16),
];
}
m = s.match(/^#([0-9a-f]{6})$/i);
if (m) {
return [parseInt(m[1].slice(0,2),16), parseInt(m[1].slice(2,4),16), parseInt(m[1].slice(4,6),16)];
}
m = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (m) return [+m[1], +m[2], +m[3]];
return null;
}
function relLum([r,g,b]) {
const f = (c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
}
function contrast(fg, bg) {
const L1 = relLum(fg), L2 = relLum(bg);
const [hi, lo] = L1 >= L2 ? [L1, L2] : [L2, L1];
return (hi + 0.05) / (lo + 0.05);
}
function ratioFromTokens(fgToken, bgToken, theme) {
const fg = parseColor(resolveToken(fgToken, theme));
const bg = parseColor(resolveToken(bgToken, theme));
assert.ok(fg, `token ${fgToken} (${theme}) did not resolve to a color: got ${resolveToken(fgToken, theme)}`);
assert.ok(bg, `token ${bgToken} (${theme}) did not resolve to a color: got ${resolveToken(bgToken, theme)}`);
return { fg, bg, ratio: contrast(fg, bg) };
}
// ── Palette indirection: existence assertions (closes #1671) ─────────────
const PALETTE_PREFIXES = ['gray', 'blue', 'green', 'amber', 'red', 'purple'];
for (const p of PALETTE_PREFIXES) {
const re = new RegExp(`--palette-${p}-\\d+\\s*:`);
assert.ok(re.test(css), `missing palette family --palette-${p}-* (closes #1671)`);
}
// At least 5 stops per family
for (const p of PALETTE_PREFIXES) {
const re = new RegExp(`--palette-${p}-\\d+\\s*:`, 'g');
const n = (css.match(re) || []).length;
assert.ok(n >= 5, `palette family --palette-${p}-* needs ≥5 stops, got ${n}`);
}
// ── M1-BLOCKER contrast assertions ───────────────────────────────────────
// Each row: [label, fgToken, bgToken, theme, minRatio]
// AA body text = 4.5:1; large text (≥18px or ≥14px+700) = 3:1. Most flagged
// surfaces are body text (11-13px @ 600), so 4.5:1 is the floor.
const CASES = [
// Operator-reported: .hop-named.hop-link chip — was #fff on var(--accent)
// ≈ #4a9eff = 2.75:1. Must use --text-on-accent on --accent-strong (or
// an equivalent darker blue) in BOTH themes.
['hop-named chip (dark)', '--text-on-accent', '--accent-strong', 'dark', 4.5],
['hop-named chip (light)', '--text-on-accent', '--accent-strong', 'light', 4.5],
// .skip-link / .btn.active — same #fff on --accent surface, also a BLOCKER
// in M1. Bumping --accent-strong fixes them all. Verified via the same
// token pair (they all rebind to --accent-strong in the patched CSS).
['btn.active (dark)', '--text-on-accent', '--accent-strong', 'dark', 4.5],
// Body muted text on common surfaces.
['text-muted on surface (dark)', '--text-muted', '--surface-1', 'dark', 4.5],
['text-muted on content-bg (dark)', '--text-muted', '--surface-0', 'dark', 4.5],
['text-muted on card-bg (dark)', '--text-muted', '--card-bg', 'dark', 4.5],
['text-muted on surface (light)', '--text-muted', '--surface-1', 'light', 4.5],
['text-muted on content-bg (light)', '--text-muted', '--surface-0', 'light', 4.5],
// Body text on the canonical page background.
['text on content-bg (dark)', '--text', '--surface-0', 'dark', 7.0],
['text on content-bg (light)', '--text', '--surface-0', 'light', 7.0],
];
let failures = 0;
console.log('\n#1668 M2 contrast audit\n' + '─'.repeat(60));
for (const [label, fgT, bgT, theme, min] of CASES) {
try {
const { fg, bg, ratio } = ratioFromTokens(fgT, bgT, theme);
const ok = ratio >= min;
const fgHex = `#${fg.map(v=>v.toString(16).padStart(2,'0')).join('')}`;
const bgHex = `#${bg.map(v=>v.toString(16).padStart(2,'0')).join('')}`;
console.log(
`${ok ? '✓' : '✗'} ${label.padEnd(42)} ${ratio.toFixed(2).padStart(5)}:1 (need ${min}) ${fgHex} on ${bgHex}`
);
if (!ok) failures++;
assert.ok(ok, `${label}: contrast ${ratio.toFixed(2)}:1 < ${min}:1 (fg ${fgHex} on bg ${bgHex})`);
} catch (e) {
failures++;
console.log(`${label.padEnd(42)} ERROR: ${e.message}`);
throw e;
}
}
console.log('─'.repeat(60));
console.log(failures === 0 ? `All ${CASES.length} contrast cases pass.` : `${failures} failure(s)`);