Dependency-free packages/shared/src/theme.ts: relativeLuminance / contrastRatio (WCAG 2.1), deriveAccentTokens(hex, mode) keeps hue and saturation and binary-searches lightness until the accent clears 4.5:1 against the mode's bg, bg-subtle AND surface (a passing hex is kept verbatim; accent-contrast follows by symmetry). THEME_PRESETS (pond green = default), BASE_PALETTE as the canonical backgrounds. A sweep test (36 hues x 3 saturations x 3 lightnesses x both modes) fences the by-construction guarantee for arbitrary input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
110 lines
3.9 KiB
TypeScript
110 lines
3.9 KiB
TypeScript
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))}`;
|
||
}
|