/** * 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 }; }