diff --git a/craft/src/utils/contrast.test.ts b/craft/src/utils/contrast.test.ts new file mode 100644 index 0000000..e067034 --- /dev/null +++ b/craft/src/utils/contrast.test.ts @@ -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) => ({ ...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']); + }); +}); diff --git a/craft/src/utils/contrast.ts b/craft/src/utils/contrast.ts new file mode 100644 index 0000000..1dde6b5 --- /dev/null +++ b/craft/src/utils/contrast.ts @@ -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') }, + ]; +}