Contrast: pure WCAG checkContrast/suggestFix/siteColorPairs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e09a87d2a6
commit
39e954bd77
2 files changed
+221
No files matched your search
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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') },
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user