tooling(color): matriz de semantic-border + compositing en la auditoría de contraste

Extiende el script de Stage 1 con el análisis que pidió el veredicto de
usuario: mide TODOS los tokens semantic-border (subtle·4 / default·6 /
strong·8 / focus-ring / focus-ring-error / invalid·7 / checked·9), cada uno
contra las 3 superficies (default·1 / raised·2 / muted·3) en ambos modos,
marcando load-bearing (3:1 requerido) vs decorativo (exento). Añade
compositing sRGB para los focus rings translúcidos (color-mix @ ~50% alpha).
Fix: oklchToGammaRgb devuelve [0,1] en tupla [r,g,b] (no {r,g,b} ni [0,255]).

Hallazgo: todo el vocabulario de border vive en el rango sutil 4-8 (contrato
Radix); el único que cruza 3:1 es el solid·9 (checked/selected). El resto es
correcto como decorativo salvo el FOCO (indicador único, WCAG 2.4.11) → se
verifica en navegador aparte.

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

@ -96,6 +96,103 @@ for (const [mode, scales] of MODES) {
}
}
// ── 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`)
@ -122,9 +219,37 @@ if (failures.length === 0) {
}
// Summary by pair (fail count) — the shape of the drift.
console.log('Summary (fails per pair):')
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`)
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`
)

Loading…
Cancel
Save

Powered by TurnKey Linux.