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

256 lines
9.4 KiB

/**
* 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<string | number, string | undefined>
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<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>]
]
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<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
})
}
}
}
// ── 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<string, Row[]>()
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`
)

Powered by TurnKey Linux.