dorfteich/packages/shared/src/theme.test.ts
Claude Fable 5 b1643bbe68 #184: shared accent engine — WCAG-conforming tokens by construction
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
2026-07-29 08:59:27 +02:00

110 lines
3.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))}`;
}