/** * 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` )