/** * 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; 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][] = [ ['light', THEME_BASE_LIGHT_COLOR_SCALES as unknown as Record], ['dark', THEME_BASE_DARK_COLOR_SCALES as unknown as Record] ]; // ── 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, 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 = { light: 0.48, dark: 0.52 }; const RING_ERROR_ALPHA: Record = { 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, 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, exclude: string ): Record { const out: Record = {}; 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` );