You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/contrast-audit.ts

329 lines
12 KiB

/**
* Contrast audit — the measurement half of the "tonal-ramp contrast" initiative
* (docs/next-features.md §1). Measures whether the framework's slot-pair
* contrast PROMISES actually hold, across all 33 donor scales × 2 modes, and
* prints the drift.
*
* It consumes the RATIFIED pair table as data (`$color` → `CONTRAST_PAIRS`,
* doctrine `theming/reference.md §40`), so the audit and the future
* by-construction solver measure against ONE source. It runs over TWO banks:
* 1. the static base (ground-truth — the hard `text-strong·12` gate must hold),
* 2. morph-GENERATED output (the Stage-2 regression bank — the gap the solver
* closes).
*
* This is measurement only — it changes no output.
*
* node --import tsx/esm scripts/contrast-audit.ts
*/
import {
apcaLc,
CONTRAST_PAIRS,
generateScale,
oklchToGammaRgb,
oklchToHex,
pickNearestTemplate,
safeParseColor,
scaleToTemplate,
SECONDARY_TEXT_APCA_FLOOR,
wcagContrastRatio,
type ContrastPair,
type ScaleSteps
} from '$color';
import { PALETTE_SCALES } from '../src/uix/eidos/lib/types.ts';
import {
THEME_BASE_DARK_COLOR_SCALES,
THEME_BASE_LIGHT_COLOR_SCALES
} from '../src/uix/eidos/lib/themes/base.ts';
type StepMap = Record<string | number, string | undefined>;
const f2 = (n: number) => n.toFixed(2);
function measure(inkHex?: string, bgHex?: string): { wcag: number; apca: number } | null {
if (!inkHex || !bgHex) return null;
const ink = safeParseColor(inkHex);
const bg = safeParseColor(bgHex);
if (!ink || !bg) return null;
const inkRgb = oklchToGammaRgb(ink);
const bgRgb = oklchToGammaRgb(bg);
return { wcag: wcagContrastRatio(inkRgb, bgRgb), apca: Math.abs(apcaLc(inkRgb, bgRgb)) };
}
const MODES: readonly [string, Record<string, StepMap>][] = [
['light', THEME_BASE_LIGHT_COLOR_SCALES as unknown as Record<string, StepMap>],
['dark', THEME_BASE_DARK_COLOR_SCALES as unknown as Record<string, StepMap>]
];
// ── Pair measurement, per the ratified §40 contract ─────────────────────────
interface PairResult {
mode: string;
scale: string;
pair: ContrastPair;
wcag: number;
apca: number;
/** For `secondary` pairs: the APCA of the cap step (text-strong) over the same bg. */
capApca: number | null;
}
function auditPairs(
scales: Record<string, StepMap>,
mode: string,
scaleNames: readonly string[]
): PairResult[] {
const out: PairResult[] = [];
for (const scale of scaleNames) {
const steps = scales[scale];
if (!steps) continue;
for (const pair of CONTRAST_PAIRS) {
const c = measure(steps[pair.ink], steps[pair.bg]);
if (!c) continue;
let capApca: number | null = null;
if (pair.policy.gate === 'secondary') {
const cc = measure(steps[pair.policy.apcaCapStep], steps[pair.bg]);
capApca = cc ? cc.apca : null;
}
out.push({ mode, scale, pair, wcag: c.wcag, apca: c.apca, capApca });
}
}
return out;
}
/** Print the three §40 tiers for a bank; returns the count of HARD-gate failures. */
function summarize(results: PairResult[], title: string): number {
console.log(`\n${title}`);
const aa = results.filter((r) => r.pair.policy.gate === 'aa');
const sec = results.filter((r) => r.pair.policy.gate === 'secondary');
const dec = results.filter((r) => r.pair.policy.gate === 'exempt');
// Hard AA gate — text-strong·12 (the one ratified floor).
const aaFail = aa.filter((r) => r.pair.policy.gate === 'aa' && r.wcag < r.pair.policy.wcag);
if (aaFail.length === 0) {
const aaMin = aa.length ? Math.min(...aa.map((r) => r.wcag)) : NaN;
console.log(
` ✅ HARD · text-strong·12 ≥ 4.5:1 WCAG — ${aa.length}/${aa.length} clear (min ${f2(aaMin)}:1)`
);
} else {
console.log(` ⚠️ HARD · text-strong·12 ≥ 4.5:1 WCAG — ${aaFail.length}/${aa.length} FAIL:`);
for (const r of aaFail.sort((a, b) => a.wcag - b.wcag))
console.log(
` ${r.mode.padEnd(5)} ${r.scale.padEnd(10)} ${r.pair.label.padEnd(28)} ${f2(r.wcag)}:1`
);
}
// Secondary tier — text·11 band (≈APCA 60; sub-4.5 by design → informational).
if (sec.length) {
const apcas = sec.map((r) => r.apca);
const below = sec.filter((r) => r.apca < SECONDARY_TEXT_APCA_FLOOR);
const overCap = sec.filter((r) => r.capApca != null && r.apca > r.capApca);
console.log(
` • SOFT · text·11 ≈APCA ${SECONDARY_TEXT_APCA_FLOOR} band — Lc ${f2(Math.min(...apcas))}–${f2(Math.max(...apcas))} · ${below.length}/${sec.length} below floor (by design) · ≤ text-strong: ${overCap.length === 0 ? '✅' : `⚠️ ${overCap.length} over cap`}`
);
}
// Decorative — border·7 (exempt): measured for context only.
if (dec.length) {
const w = dec.map((r) => r.wcag);
console.log(
` · EXEMPT· border·7 — WCAG ${f2(Math.min(...w))}–${f2(Math.max(...w))}:1 (decorative accent, not gated)`
);
}
return aaFail.length;
}
// ── Bank 1: the static base (ground-truth) ──────────────────────────────────
const baseResults: PairResult[] = [];
for (const [mode, scales] of MODES) baseResults.push(...auditPairs(scales, mode, PALETTE_SCALES));
console.log(
`\nContrast audit — §40 tonal-ramp contract (${PALETTE_SCALES.length} scales × 2 modes)`
);
const baseFails = summarize(baseResults, '── Bank 1 · static base (ground-truth)');
// ── Tier-1: the LOAD-BEARING borders (WCAG 1.4.11 — 3:1 required) ───────────
// The per-scale `border` (step 7) is a DECORATIVE accent (used only when a
// filled/colored element also carries its boundary via bg + content) → exempt.
// The borders that ARE the sole indicator of a control's boundary/state use
// STRONGER tokens; those are the real 3:1 promise. Role bindings in the base:
// neutral→gray, primary→purple, risk→orange.
function compositeSrgb(fgHex: string, alpha: number, bgHex: string): string | null {
const fg = safeParseColor(fgHex);
const bg = safeParseColor(bgHex);
if (!fg || !bg) return null;
// `oklchToGammaRgb` returns a normalized [0,1] `[r,g,b]` tuple — scale to [0,255].
const f = oklchToGammaRgb(fg);
const b = oklchToGammaRgb(bg);
const hex = (i: 0 | 1 | 2) =>
Math.max(0, Math.min(255, Math.round((f[i] * alpha + b[i] * (1 - alpha)) * 255)))
.toString(16)
.padStart(2, '0');
return `#${hex(0)}${hex(1)}${hex(2)}`;
}
// Role bindings in the base theme: neutral→gray, primary→purple, risk→orange.
const NEUTRAL = 'gray';
const PRIMARY = 'purple';
const RISK = 'orange';
const FOCUS_ALPHA: Record<string, number> = { light: 0.48, dark: 0.52 };
const RING_ERROR_ALPHA: Record<string, number> = { light: 0.52, dark: 0.56 };
// The surfaces a control (and thus its border) sits on.
const SURFACES: readonly { id: string; step: number }[] = [
{ id: 'default', step: 1 },
{ id: 'raised', step: 2 },
{ id: 'muted', step: 3 }
];
interface BorderToken {
label: string;
/** `true` = the sole indicator of a boundary/state → WCAG 1.4.11 wants 3:1. */
loadBearing: boolean;
/** Resolve the (already-composited) border color for a mode's scale map. */
color: (s: Record<string, StepMap>, mode: string, surfaceHex: string) => string | undefined;
}
const BORDER_TOKENS: readonly BorderToken[] = [
{ label: 'border-subtle (neutral·4)', loadBearing: false, color: (s) => s[NEUTRAL]?.[4] },
{
label: 'border-default (neutral·6) — resting',
loadBearing: false,
color: (s) => s[NEUTRAL]?.[6]
},
{
label: 'border-strong (neutral·8) — hover/ghost',
loadBearing: true,
color: (s) => s[NEUTRAL]?.[8]
},
{
label: 'focus ring (primary·8 @ ~50%)',
loadBearing: true,
color: (s, mode, surf) => {
const fg = s[PRIMARY]?.[8];
return fg ? (compositeSrgb(fg, FOCUS_ALPHA[mode], surf) ?? undefined) : undefined;
}
},
{
label: 'focus ring-error (risk·8 @ ~54%)',
loadBearing: true,
color: (s, mode, surf) => {
const fg = s[RISK]?.[8];
return fg ? (compositeSrgb(fg, RING_ERROR_ALPHA[mode], surf) ?? undefined) : undefined;
}
},
{ label: 'invalid border (risk·7)', loadBearing: true, color: (s) => s[RISK]?.[7] },
{ label: 'checked/selected (primary·9 solid)', loadBearing: true, color: (s) => s[PRIMARY]?.[9] }
];
interface T1Row {
mode: string;
surface: string;
label: string;
loadBearing: boolean;
wcag: number;
apca: number;
pass: boolean;
}
const t1: T1Row[] = [];
for (const [mode, scales] of MODES) {
for (const surf of SURFACES) {
const surfaceHex = scales[NEUTRAL]?.[surf.step];
if (!surfaceHex) continue;
for (const bt of BORDER_TOKENS) {
const c = bt.color(scales, mode, surfaceHex);
if (!c) continue;
const m = measure(c, surfaceHex);
if (!m) continue;
t1.push({
mode,
surface: surf.id,
label: bt.label,
loadBearing: bt.loadBearing,
wcag: m.wcag,
apca: m.apca,
pass: m.wcag >= 3
});
}
}
}
// ── Semantic border matrix (the tokens that ARE used as borders) ────────────
// `L` = load-bearing (sole indicator → WCAG 1.4.11 wants 3:1); `·` = decorative
// (accompanied by a fill / other cue → exempt). Floor 3:1 for the L rows.
console.log('\nSemantic border matrix — WCAG:1 vs each surface (L = load-bearing/3:1-required):\n');
const surfIds = SURFACES.map((s) => s.id);
const header =
` ${'token'.padEnd(38)} ${'kind'.padEnd(4)} ` +
surfIds.map((s) => `${('' + s).padEnd(14)}`).join('');
for (const mode of ['light', 'dark']) {
console.log(`── ${mode} (surface: default·1 / raised·2 / muted·3)`);
console.log(header);
for (const bt of BORDER_TOKENS) {
const cells = surfIds.map((sid) => {
const r = t1.find((x) => x.mode === mode && x.surface === sid && x.label === bt.label);
if (!r) return ''.padEnd(14);
const mark = !r.loadBearing ? ' ' : r.pass ? '✅' : '⚠️';
return `${mark}${f2(r.wcag)}`.padEnd(14);
});
console.log(
` ${bt.label.padEnd(38)} ${(bt.loadBearing ? 'L' : '·').padEnd(4)} ${cells.join('')}`
);
}
console.log('');
}
const lbFails = t1.filter((r) => r.loadBearing && !r.pass);
console.log(
lbFails.length === 0
? 'Load-bearing borders: ✅ all clear 3:1.'
: `Load-bearing 3:1 FAILURES: ${lbFails.length} → ${[...new Set(lbFails.map((r) => r.label))].join(' · ')}`
);
// ── Bank 2: morph-GENERATED output (the Stage-2 regression bank) ─────────────
// For every base scale, treat its solid (step 9) as a seed and MORPH-generate a
// fresh scale from the OTHER base scales as templates (the seed's own scale is
// excluded — else it lands on itself and the test trivially equals the base).
// This is morph-only (no solver yet). Finding: it already clears the HARD
// text-strong·12 gate — the donor's L-curve pins step 12 near the ink extreme,
// so AA holds by construction on base-like seeds. The solver's genuine job is
// therefore the text·11 band and robustness on out-of-distribution seeds
// (probed in-browser at F2), NOT the hard gate. Value of this bank: it stays
// green as the base + generator evolve (a regression guard, not a red-to-green
// milestone).
function templatesExcept(
scales: Record<string, StepMap>,
exclude: string
): Record<string, ScaleSteps> {
const out: Record<string, ScaleSteps> = {};
for (const name of PALETTE_SCALES) {
if (name === exclude) continue;
const s = scales[name];
if (!s) continue;
const hexes = Array.from({ length: 12 }, (_, i) => s[i + 1]);
if (hexes.some((h) => !h)) continue;
out[name] = scaleToTemplate(hexes as string[]);
}
return out;
}
const generated: PairResult[] = [];
for (const [mode, scales] of MODES) {
for (const scale of PALETTE_SCALES) {
const solidHex = scales[scale]?.[9];
if (!solidHex) continue;
const seed = safeParseColor(solidHex);
if (!seed) continue;
const templates = templatesExcept(scales, scale);
const tplName = pickNearestTemplate(seed, templates);
const gen = generateScale(seed, templates[tplName]);
const genHex: StepMap = {};
gen.forEach((step, i) => {
genHex[i + 1] = oklchToHex(step);
});
generated.push(...auditPairs({ [scale]: genHex }, mode, [scale]));
}
}
const genFails = summarize(
generated,
'\n── Bank 2 · morph-generated, leave-one-out (Stage-2 regression bank)'
);
console.log(
`\nSummary: base HARD fails ${baseFails} · generated HARD fails ${genFails} · load-bearing border fails ${lbFails.length}\n`
);

Powered by TurnKey Linux.