tooling(color): script de auditoría de contraste (Stage 1 · tonal-ramp parity)

Pase de medición de la iniciativa #1 de next-features: comprueba si las
promesas de contraste por par de slots se cumplen en las 33 escalas × 2 modos,
reutilizando la matemática de `$color` (apcaLc · wcagContrastRatio ·
oklchToGammaRgb · safeParseColor). WCAG 2 = gate normativo (4.5:1 texto /
3:1 no-texto); APCA Lc reportado al lado. Tabla de pares = las promesas
declaradas (texto·11/12 sobre track·1/bg·2/element·3; border·7 sobre track/bg).
Solo mide — no cambia emisión. Correr: `node --import tsx/esm scripts/contrast-audit.ts`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent c5b268cbff
commit 61ecc101de

@ -0,0 +1,130 @@
/**
* 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
})
}
}
}
}
// ── 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 (fails per pair):')
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`)
}
console.log('')
Loading…
Cancel
Save

Powered by TurnKey Linux.