Contrast: pure WCAG checkContrast/suggestFix/siteColorPairs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:51:57 -08:00
1 parent e09a87d2a6
commit 39e954bd77
2 files changed
+221

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import { describe, test, expect } from 'vitest';
import { checkContrast, suggestFix, siteColorPairs } from './contrast';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
const d = DEFAULT_SITE_DESIGN;
const design = (o: Partial<typeof d>) => ({ ...d, ...o });
describe('checkContrast', () => {
test('black on white passes AAA 21:1', () => {
expect(checkContrast('#000', '#fff', d)).toEqual({ status: 'pass', ratio: 21, required: 4.5, level: 'AAA' });
});
test('light grey on white fails', () => {
const v = checkContrast('#aaaaaa', '#ffffff', d);
expect(v).toMatchObject({ status: 'fail', required: 4.5, level: 'fail' });
expect(v.ratio).toBe(2.32);
});
test('large text uses 3:1', () => {
expect(checkContrast('#888888', '#ffffff', d).status).toBe('fail'); // 3.54
expect(checkContrast('#888888', '#ffffff', d, { large: true })).toMatchObject({ status: 'pass', required: 3, level: 'AA-large' });
});
test('resolves tokens and brand tokens', () => {
const dd = design({ textColor: '#ffffff', backgroundColor: '#ffffff' });
expect(checkContrast('var(--wsb-text)', 'var(--wsb-bg)', dd).status).toBe('fail');
const b = design({ swatches: [{ id: 's1', label: 'Brand', hex: '#000000', brand: true, slot: 1 } as any] });
expect(checkContrast('var(--wsb-brand-1)', '#ffffff', b)).toMatchObject({ status: 'pass', ratio: 21 });
});
test('rgb() and semi-transparent hex composite over bg', () => {
expect(checkContrast('rgb(0, 0, 0)', 'rgb(255,255,255)', d).ratio).toBe(21);
// ~50% black over white (unrounded 127.5 grey) -> 4.0
expect(checkContrast('#00000080', '#ffffff', d).ratio).toBe(4);
});
test('unchecked reasons', () => {
expect(checkContrast('#000', 'linear-gradient(red, blue)', d)).toMatchObject({ status: 'unchecked', reason: 'gradient' });
expect(checkContrast('#000', 'url(a.png)', d)).toMatchObject({ status: 'unchecked', reason: 'image' });
expect(checkContrast('#000', 'transparent', d).reason).toBe('transparent');
expect(checkContrast('#000', '#ffffff80', d).reason).toBe('transparent');
expect(checkContrast('var(--nope)', '#fff', d).reason).toBe('unresolved colour');
});
});
describe('suggestFix', () => {
test('keeps hue and passes', () => {
const s = suggestFix('#93c5fd', '#ffffff', d)!;
expect(s.ratio).toBeGreaterThanOrEqual(4.5);
expect(checkContrast(s.hex, '#ffffff', d).status).toBe('pass');
expect(s.hex).toMatch(/^#[0-9a-f]{6}$/);
});
test('lightens on dark backgrounds', () => {
const s = suggestFix('#333333', '#111111', d)!;
expect(checkContrast(s.hex, '#111111', d).status).toBe('pass');
});
test('null when passing or unchecked', () => {
expect(suggestFix('#000', '#fff', d)).toBeNull();
expect(suggestFix('#000', 'linear-gradient(red,blue)', d)).toBeNull();
});
});
describe('siteColorPairs', () => {
test('lists text, muted, link and primary button pairs', () => {
expect(siteColorPairs(d).map((p) => p.id)).toEqual(['text-bg', 'muted-bg', 'link-bg', 'button-primary']);
});
});
+159
View File
@@ -0,0 +1,159 @@
import type { SiteDesign } from '../state/SiteDesignContext';
import { resolveTokenValue, tokenRef } from './tokens';
import type { RGB } from './color';
/* ---------- WCAG 2.x contrast checking (pure) ----------
Only flat, fully-known colour pairs are judged. Gradients, images,
transparent/unknown backgrounds and unresolved tokens come back as
'unchecked' with a reason -- never as a guess. */
export type ContrastVerdict = {
status: 'pass' | 'fail' | 'unchecked';
/** Contrast ratio, 2 decimals. Absent when unchecked. */
ratio?: number;
required: 4.5 | 3;
/** For unchecked: 'gradient' | 'image' | 'transparent' | 'unresolved colour'. */
reason?: string;
level: 'AAA' | 'AA' | 'AA-large' | 'fail';
};
type RGBA = { rgb: RGB; a: number };
function parseRgba(input: string): RGBA | null {
const v = input.trim().toLowerCase();
let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v);
if (m) {
let h = m[1];
if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
const a = h.length === 8 ? parseInt(h.slice(6), 16) / 255 : 1;
return { rgb: [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)], a };
}
m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(v);
if (m) {
let a = 1;
if (m[4] !== undefined) a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
if (Number.isNaN(a)) return null;
return { rgb: [Math.min(255, +m[1]), Math.min(255, +m[2]), Math.min(255, +m[3])], a: Math.min(1, a) };
}
return null;
}
function lum([r, g, b]: RGB): number {
const f = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
const ratioOf = (a: RGB, b: RGB) => {
const la = lum(a), lb = lum(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
};
const hex2 = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
const toHex = ([r, g, b]: RGB) => `#${hex2(r)}${hex2(g)}${hex2(b)}`;
const round2 = (n: number) => Math.round(n * 100) / 100;
type Resolved = { ok: true; c: RGBA } | { ok: false; reason: string };
function resolveColour(value: string, design: SiteDesign): Resolved {
const v = resolveTokenValue(String(value ?? ''), design).trim();
if (/gradient/i.test(v)) return { ok: false, reason: 'gradient' };
if (/url\(/i.test(v)) return { ok: false, reason: 'image' };
if (v.toLowerCase() === 'transparent') return { ok: false, reason: 'transparent' };
const c = parseRgba(v);
if (!c) return { ok: false, reason: 'unresolved colour' };
if (c.a <= 0) return { ok: false, reason: 'transparent' };
return { ok: true, c };
}
/** Resolves both sides to opaque RGB (fg composited over bg), or the reason it cannot. */
function resolvePair(fg: string, bg: string, design: SiteDesign): { fg: RGB; bg: RGB } | { reason: string } {
const b = resolveColour(bg, design);
if (!b.ok) return { reason: b.reason };
if (b.c.a < 1) return { reason: 'transparent' }; // translucent over an unknown layer
const f = resolveColour(fg, design);
if (!f.ok) return { reason: f.reason };
const a = f.c.a;
const comp = f.c.rgb.map((c, i) => c * a + b.c.rgb[i] * (1 - a)) as RGB;
return { fg: comp, bg: b.c.rgb };
}
export function checkContrast(fg: string, bg: string, design: SiteDesign, opts?: { large?: boolean }): ContrastVerdict {
const required: 4.5 | 3 = opts?.large ? 3 : 4.5;
const p = resolvePair(fg, bg, design);
if ('reason' in p) return { status: 'unchecked', required, reason: p.reason, level: 'fail' };
const raw = ratioOf(p.fg, p.bg);
const level: ContrastVerdict['level'] = raw >= 7 ? 'AAA' : raw >= 4.5 ? 'AA' : raw >= 3 ? 'AA-large' : 'fail';
return { status: raw >= required ? 'pass' : 'fail', ratio: round2(raw), required, level };
}
/* ---- HSL lightness walk ---- */
function rgbToHsl([r, g, b]: RGB): [number, number, number] {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [h / 6, s, l];
}
function hslToRgb([h, s, l]: [number, number, number]): RGB {
if (s === 0) return [l * 255, l * 255, l * 255];
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
}
/**
* A readable replacement for `fg` on `bg`: the same hue with lightness walked
* toward the better-contrasting extreme, falling back to black/white. Null when
* the pair is unchecked, already passes, or nothing reaches the threshold.
*/
export function suggestFix(fg: string, bg: string, design: SiteDesign, opts?: { large?: boolean }): { hex: string; ratio: number } | null {
const required = opts?.large ? 3 : 4.5;
const p = resolvePair(fg, bg, design);
if ('reason' in p) return null;
if (ratioOf(p.fg, p.bg) >= required) return null;
const [h, s, l] = rgbToHsl(p.fg);
const darker = lum(p.bg) > 0.179; // light-ish surface -> darken the text
for (let i = 1; i <= 100; i++) {
const nl = darker ? l - i / 100 : l + i / 100;
if (nl < 0 || nl > 1) break;
const hex = toHex(hslToRgb([h, s, nl]));
const rgb = parseRgba(hex)!.rgb;
const r = ratioOf(rgb, p.bg);
if (r >= required) return { hex, ratio: round2(r) };
}
for (const hex of ['#000000', '#ffffff']) {
const r = ratioOf(parseRgba(hex)!.rgb, p.bg);
if (r >= required) return { hex, ratio: round2(r) };
}
return null;
}
export interface SiteColorPair { id: string; label: string; fg: string; bg: string; large?: boolean }
/**
* Site-level colour pairs that are always seen together. The primary button
* pair mirrors ButtonLink's craft defaults (text `var(--wsb-bg)` on
* `var(--wsb-primary)`, 16px/600 -> normal text).
*/
export function siteColorPairs(_design: SiteDesign): SiteColorPair[] {
return [
{ id: 'text-bg', label: 'Text on background', fg: tokenRef('text'), bg: tokenRef('bg') },
{ id: 'muted-bg', label: 'Muted text on background', fg: tokenRef('muted'), bg: tokenRef('bg') },
{ id: 'link-bg', label: 'Link on background', fg: tokenRef('link'), bg: tokenRef('bg') },
{ id: 'button-primary', label: 'Button text on primary', fg: tokenRef('bg'), bg: tokenRef('primary') },
];
}