From b1643bbe6856263f76cc9eeab4c2f1f25f4ee102 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 29 Jul 2026 08:59:27 +0200 Subject: [PATCH] =?UTF-8?q?#184:=20shared=20accent=20engine=20=E2=80=94=20?= =?UTF-8?q?WCAG-conforming=20tokens=20by=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX --- packages/shared/src/index.ts | 1 + packages/shared/src/theme.test.ts | 109 ++++++++++++++++++ packages/shared/src/theme.ts | 177 ++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 packages/shared/src/theme.test.ts create mode 100644 packages/shared/src/theme.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3d06a0f..6414990 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/theme.test.ts b/packages/shared/src/theme.test.ts new file mode 100644 index 0000000..7537c5f --- /dev/null +++ b/packages/shared/src/theme.test.ts @@ -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))}`; +} diff --git a/packages/shared/src/theme.ts b/packages/shared/src/theme.ts new file mode 100644 index 0000000..1ae482c --- /dev/null +++ b/packages/shared/src/theme.ts @@ -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 }; +}