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

310 lines
12 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 — 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<string | number, string | undefined>
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<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>]
]
// ── 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<string, StepMap>,
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<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
})
}
}
}
// ── 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<string, StepMap>, exclude: string): Record<string, ScaleSteps> {
const out: Record<string, ScaleSteps> = {}
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`
)

Powered by TurnKey Linux.