|
|
|
|
|
/**
|
|
|
|
|
|
* 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('')
|