#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:
parent
b799ad180b
commit
b1643bbe68
@ -33,5 +33,6 @@ export * from './ponds';
|
|||||||
export * from './public-api';
|
export * from './public-api';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
export * from './text-diff';
|
export * from './text-diff';
|
||||||
|
export * from './theme';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
export * from './watches';
|
export * from './watches';
|
||||||
|
|||||||
109
packages/shared/src/theme.test.ts
Normal file
109
packages/shared/src/theme.test.ts
Normal 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))}`;
|
||||||
|
}
|
||||||
177
packages/shared/src/theme.ts
Normal file
177
packages/shared/src/theme.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user