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

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