#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
This commit is contained in:
Claude Fable 5 2026-07-29 08:59:27 +02:00
parent b799ad180b
commit b1643bbe68
3 changed files with 287 additions and 0 deletions

View File

@ -33,5 +33,6 @@ export * from './ponds';
export * from './public-api';
export * from './quotas';
export * from './text-diff';
export * from './theme';
export * from './tree';
export * from './watches';

View File

@ -0,0 +1,109 @@
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))}`;
}

View File

@ -0,0 +1,177 @@
/**
* Accent-theme engine (ADR 0018 stage B, issue #184). A color theme is ONE
* accent hex presets are curated hexes, a custom pick is any hex and
* `deriveAccentTokens` COMPUTES per-mode token values whose WCAG 2.1 AA
* contrast holds by construction, so no runtime validation or "save refused"
* flow exists. Dependency-free on purpose: the web app derives at pick time,
* and the no-JS shell could reuse this later.
*/
export type ThemeModeName = 'light' | 'dark';
/**
* Canonical neutral backgrounds per mode MUST mirror tokens.css (the
* fence in apps/web/src/theme/theme-contrast.test.ts asserts the sync).
* Derived accents are searched against all three, so they also clear the
* lighter/darker of the pair and text ON the accent stays readable.
*/
export const BASE_PALETTE: Record<
ThemeModeName,
{ bg: string; bgSubtle: string; surface: string }
> = {
light: { bg: '#ffffff', bgSubtle: '#f5f7fa', surface: '#ffffff' },
dark: { bg: '#10161d', bgSubtle: '#161d26', surface: '#1b2430' },
};
export interface ThemePreset {
/** Stable id, persisted in localStorage (`ui.theme.accent`). */
id: string;
/** The light-mode accent hex; both modes derive from this one value. */
accent: string;
}
/**
* Curated accents. `pond-green` is the product default and deliberately maps
* to NO override at all the hand-tuned tokens.css values stay in charge.
*/
export const DEFAULT_THEME_PRESET_ID = 'pond-green';
export const THEME_PRESETS: ThemePreset[] = [
{ id: DEFAULT_THEME_PRESET_ID, accent: '#2f6f4f' },
{ id: 'lake-blue', accent: '#2b5f8f' },
{ id: 'iris-violet', accent: '#6d4fa0' },
{ id: 'reed-teal', accent: '#1f6f6b' },
{ id: 'stone-slate', accent: '#55606c' },
];
const HEX_PATTERN = /^#?([0-9a-f]{6})$/i;
export function isHexColor(value: string): boolean {
return HEX_PATTERN.test(value.trim());
}
export function normalizeHexColor(value: string): string {
const match = HEX_PATTERN.exec(value.trim());
if (!match?.[1]) throw new Error(`not a #rrggbb color: ${value}`);
return `#${match[1].toLowerCase()}`;
}
/** WCAG 2.1 relative luminance of an sRGB hex color. */
export function relativeLuminance(hex: string): number {
const value = normalizeHexColor(hex);
const channels = [1, 3, 5].map((i) => {
const c = parseInt(value.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
/** WCAG 2.1 contrast ratio between two hex colors (1..21). */
export function contrastRatio(a: string, b: string): number {
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (hi! + 0.05) / (lo! + 0.05);
}
interface Hsl {
h: number;
s: number;
l: number;
}
function hexToHsl(hex: string): Hsl {
const value = normalizeHexColor(hex);
const r = parseInt(value.slice(1, 3), 16) / 255;
const g = parseInt(value.slice(3, 5), 16) / 255;
const b = parseInt(value.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
const d = max - min;
if (d === 0) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return { h, s, l };
}
function hslToHex({ h, s, l }: Hsl): string {
const hueToChannel = (p: number, q: number, t: number): number => {
let x = t;
if (x < 0) x += 1;
if (x > 1) x -= 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;
};
let r: number;
let g: number;
let b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hueToChannel(p, q, h + 1 / 3);
g = hueToChannel(p, q, h);
b = hueToChannel(p, q, h - 1 / 3);
}
const toHex = (c: number): string =>
Math.round(Math.min(1, Math.max(0, c)) * 255)
.toString(16)
.padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
export interface AccentTokens {
/** `--color-accent` for the mode; ≥ 4.5:1 against bg, bg-subtle, surface. */
accent: string;
/** `--color-accent-contrast` (text ON accent); ≥ 4.5:1 by symmetry. */
accentContrast: string;
}
const MIN_CONTRAST = 4.5;
function minContrastAgainstMode(hex: string, mode: ThemeModeName): number {
const backgrounds = BASE_PALETTE[mode];
return Math.min(
contrastRatio(hex, backgrounds.bg),
contrastRatio(hex, backgrounds.bgSubtle),
contrastRatio(hex, backgrounds.surface),
);
}
/**
* Derive the accent token pair for a mode from ONE user-chosen hex: keep hue
* and saturation, and only move lightness (binary search) until the color
* clears 4.5:1 against every canonical background of the mode light mode
* darkens, dark mode lightens. A hex that already passes is kept verbatim.
* Extremes always exist (pure black/white pass against both palettes), so
* the search converges for every input.
*
* `accentContrast` needs no search: 4.5:1 against the mode's bg makes the
* bg color itself (dark) resp. white (light) a conforming text color on the
* accent, matching the hand-tuned pairs in tokens.css.
*/
export function deriveAccentTokens(accentHex: string, mode: ThemeModeName): AccentTokens {
const normalized = normalizeHexColor(accentHex);
const accentContrast = mode === 'light' ? '#ffffff' : BASE_PALETTE.dark.bg;
if (minContrastAgainstMode(normalized, mode) >= MIN_CONTRAST) {
return { accent: normalized, accentContrast };
}
const { h, s } = hexToHsl(normalized);
// Lightness is monotonic in luminance at fixed hue/saturation, so the
// passing region is an interval ending at the mode's extreme; binary
// search finds the edge closest to the user's pick.
let passing = mode === 'light' ? 0 : 1;
let failing = hexToHsl(normalized).l;
for (let i = 0; i < 24; i += 1) {
const mid = (passing + failing) / 2;
if (minContrastAgainstMode(hslToHex({ h, s, l: mid }), mode) >= MIN_CONTRAST) passing = mid;
else failing = mid;
}
return { accent: hslToHex({ h, s, l: passing }), accentContrast };
}