import { describe, expect, it } from 'vitest'; import { BASE_PALETTE, contrastRatio, DEFAULT_THEME_PRESET_ID, deriveAccentTokens, isHexColor, normalizeHexColor, relativeLuminance, THEME_PRESETS, type ThemeModeName, } from './theme'; const MODES: ThemeModeName[] = ['light', 'dark']; function backgroundsOf(mode: ThemeModeName): string[] { const { bg, bgSubtle, surface } = BASE_PALETTE[mode]; return [bg, bgSubtle, surface]; } describe('color math', () => { it('matches the WCAG anchor points', () => { expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5); expect(relativeLuminance('#000000')).toBeCloseTo(0, 5); expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 3); expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5); // Symmetry: order of arguments must not matter. expect(contrastRatio('#2f6f4f', '#ffffff')).toBeCloseTo(contrastRatio('#ffffff', '#2f6f4f'), 8); }); it('normalizes and validates hex colors', () => { expect(normalizeHexColor(' #2F6F4F ')).toBe('#2f6f4f'); expect(normalizeHexColor('2f6f4f')).toBe('#2f6f4f'); expect(isHexColor('#123abc')).toBe(true); expect(isHexColor('#123')).toBe(false); expect(() => normalizeHexColor('teal')).toThrow(); }); }); describe('deriveAccentTokens', () => { it('keeps a hex verbatim when it already passes the mode', () => { // The default pond green passes light mode as-is (it IS the light token). expect(deriveAccentTokens('#2f6f4f', 'light').accent).toBe('#2f6f4f'); }); it('derives conforming tokens for every preset in both modes', () => { for (const preset of THEME_PRESETS) { for (const mode of MODES) { const tokens = deriveAccentTokens(preset.accent, mode); for (const background of backgroundsOf(mode)) { expect( contrastRatio(tokens.accent, background), `${preset.id} ${mode} vs ${background}`, ).toBeGreaterThanOrEqual(4.5); } expect( contrastRatio(tokens.accent, tokens.accentContrast), `${preset.id} ${mode} accent text`, ).toBeGreaterThanOrEqual(4.5); } } }); it('derives conforming tokens across the whole color space (sweep)', () => { // 36 hues × 3 saturations × 3 lightnesses × both modes — garish neon // yellow and near-black picks included. Conformance must hold BY // CONSTRUCTION for any input (ADR 0018), so this sweep is the fence. for (let hue = 0; hue < 360; hue += 10) { for (const saturation of [0.15, 0.6, 1]) { for (const lightness of [0.05, 0.5, 0.95]) { const input = hslInputHex(hue / 360, saturation, lightness); for (const mode of MODES) { const tokens = deriveAccentTokens(input, mode); for (const background of backgroundsOf(mode)) { expect( contrastRatio(tokens.accent, background), `${input} ${mode} vs ${background}`, ).toBeGreaterThanOrEqual(4.5); } expect(contrastRatio(tokens.accent, tokens.accentContrast)).toBeGreaterThanOrEqual(4.5); } } } } }); it('has a default preset whose id marks "no override"', () => { expect(THEME_PRESETS[0]!.id).toBe(DEFAULT_THEME_PRESET_ID); }); }); /** Test-local HSL→hex so the sweep does not trust the module under test. */ function hslInputHex(h: number, s: number, l: number): string { const hue = (p: number, q: number, t: number): number => { const x = ((t % 1) + 1) % 1; if (x < 1 / 6) return p + (q - p) * 6 * x; if (x < 1 / 2) return q; if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; const toHex = (c: number): string => Math.round(c * 255) .toString(16) .padStart(2, '0'); return `#${toHex(hue(p, q, h + 1 / 3))}${toHex(hue(p, q, h))}${toHex(hue(p, q, h - 1 / 3))}`; }