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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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.