/** * Contrast audit — Stage 1 of the "tonal-ramp contrast parity" 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 list for user verdicts (retune vs annotated exception). * * This is measurement only — it changes no output. The pair table below is the * proposed doctrine (grounded in the registry's stated promises); adjust it and * re-run. WCAG 2 is the normative gate (4.5:1 text · 3:1 non-text); APCA Lc is * reported alongside (the framework's on-solid criterion already uses APCA — the * verdict phase can promote it to a co-gate). * * node --import tsx/esm scripts/contrast-audit.ts */ import { apcaLc, oklchToGammaRgb, safeParseColor, wcagContrastRatio } 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 interface Pair { /** Foreground step (the ink / border). */ ink: number /** Background step. */ bg: number label: string /** WCAG 2 floor (the normative gate). */ wcag: number kind: 'text' | 'non-text' } // Proposed pair table — the promises the registry §1 names as "unmeasured". // Radix functional steps: 1 track · 2 subtle-bg · 3 element-bg · 7 UI border · // 9 solid · 11 text · 12 text-strong. const PAIRS: readonly Pair[] = [ { ink: 11, bg: 1, label: 'text·11 over track·1', wcag: 4.5, kind: 'text' }, { ink: 11, bg: 2, label: 'text·11 over bg·2', wcag: 4.5, kind: 'text' }, { ink: 11, bg: 3, label: 'text·11 over element·3', wcag: 4.5, kind: 'text' }, { ink: 12, bg: 1, label: 'textStrong·12 over track·1', wcag: 4.5, kind: 'text' }, { ink: 12, bg: 3, label: 'textStrong·12 over element·3', wcag: 4.5, kind: 'text' }, { ink: 7, bg: 1, label: 'border·7 over track·1', wcag: 3, kind: 'non-text' }, { ink: 7, bg: 2, label: 'border·7 over bg·2', wcag: 3, kind: 'non-text' } ] function measure(inkHex: string, bgHex: string): { wcag: number; apca: number } | 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] ] interface Row { mode: string scale: string pair: string wcagFloor: number wcag: number apca: number } const failures: Row[] = [] let checked = 0 for (const [mode, scales] of MODES) { for (const scale of PALETTE_SCALES) { const steps = scales[scale] if (!steps) continue for (const p of PAIRS) { const inkHex = steps[p.ink] const bgHex = steps[p.bg] if (!inkHex || !bgHex) continue const c = measure(inkHex, bgHex) if (!c) continue checked++ if (c.wcag < p.wcag) { failures.push({ mode, scale, pair: p.label, wcagFloor: p.wcag, wcag: c.wcag, apca: c.apca }) } } } } // ── 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 }) } } } // ── Report ──────────────────────────────────────────────────────────────── const f2 = (n: number) => n.toFixed(2) console.log(`\nContrast audit — ${checked} pair-checks (${PALETTE_SCALES.length} scales × 2 modes × ${PAIRS.length} pairs)\n`) if (failures.length === 0) { console.log('✅ No WCAG drift — every measured pair clears its floor.\n') } else { // Group by pair, then mode. const byPair = new Map() for (const r of failures) { const key = r.pair ;(byPair.get(key) ?? byPair.set(key, []).get(key)!).push(r) } console.log(`⚠️ ${failures.length} pair-checks below the WCAG floor:\n`) for (const [pair, rows] of byPair) { console.log(`── ${pair} (floor ${rows[0].wcagFloor}:1) — ${rows.length} fail`) for (const r of rows.sort((a, b) => a.wcag - b.wcag)) { console.log( ` ${r.mode.padEnd(5)} ${r.scale.padEnd(10)} WCAG ${f2(r.wcag)}:1 APCA Lc ${f2(r.apca)}` ) } console.log('') } } // Summary by pair (fail count) — the shape of the drift. console.log('Summary (per-scale pairs — fails/66):') for (const p of PAIRS) { const n = failures.filter((r) => r.pair === p.label).length console.log(` ${n === 0 ? '✅' : '⚠️ '} ${p.label.padEnd(30)} ${n}/66 ${p.kind === 'non-text' ? '(non-text · see Tier-1 below)' : ''}`) } console.log('') // ── 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('Semantic 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.\n' : `Load-bearing 3:1 FAILURES: ${lbFails.length} → ${[...new Set(lbFails.map((r) => r.label))].join(' · ')}\n` )