diff --git a/app/src/lib/theme/color.test.ts b/app/src/lib/theme/color.test.ts index d52419542..50e7d68f1 100644 --- a/app/src/lib/theme/color.test.ts +++ b/app/src/lib/theme/color.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { channelLuminance, channelsToHex, hexToChannels, isChannelTriple } from './color'; +import { + channelContrast, + channelLuminance, + channelsToHex, + hexToChannels, + isChannelTriple, +} from './color'; describe('theme colour helpers', () => { it('converts channel triples to hex', () => { @@ -38,4 +44,12 @@ describe('theme colour helpers', () => { expect(channelLuminance('0 0 0')).toBeCloseTo(0, 5); expect(channelLuminance('255 255 255')).toBeCloseTo(1, 5); }); + + it('computes WCAG contrast ratios (symmetric, 1…21)', () => { + expect(channelContrast('255 255 255', '0 0 0')).toBeCloseTo(21, 1); + expect(channelContrast('0 0 0', '255 255 255')).toBeCloseTo(21, 1); // symmetric + expect(channelContrast('128 128 128', '128 128 128')).toBeCloseTo(1, 5); // self + // sanity: a known AA-passing pair (stone-900 on white) is ≥ 4.5 + expect(channelContrast('23 23 23', '255 255 255')).toBeGreaterThan(4.5); + }); }); diff --git a/app/src/lib/theme/color.ts b/app/src/lib/theme/color.ts index 8a1f04db7..d6112d91d 100644 --- a/app/src/lib/theme/color.ts +++ b/app/src/lib/theme/color.ts @@ -63,3 +63,17 @@ export function channelLuminance(channels: string): number { const lin = parts.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; } + +/** + * WCAG contrast ratio between two channel triples, in `[1, 21]`. Symmetric in + * its arguments. AA wants ≥ 4.5:1 for body text and ≥ 3:1 for large text / UI. + * Used to warn on custom themes and to gate the built-in presets (see + * `presets.contrast.test.ts`). + */ +export function channelContrast(a: string, b: string): number { + const la = channelLuminance(a); + const lb = channelLuminance(b); + const hi = Math.max(la, lb); + const lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} diff --git a/app/src/lib/theme/presets.contrast.test.ts b/app/src/lib/theme/presets.contrast.test.ts new file mode 100644 index 000000000..ad5459e62 --- /dev/null +++ b/app/src/lib/theme/presets.contrast.test.ts @@ -0,0 +1,187 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { channelContrast } from './color'; +import { PRESET_THEMES } from './presets'; +import type { Theme } from './types'; + +/** + * WCAG AA gate for every shipped **dark** preset. + * + * A preset only carries the tokens it overrides; everything else falls through + * to the `:root.dark` defaults in `app/src/styles/tokens.css`. Those defaults are + * not importable as JS, so we mirror the relevant subset here and resolve each + * theme by merging its `colors` over this base — the same layering ThemeProvider + * does at runtime. + * + * This mirror is not trusted blindly: the `DARK_BASE parity` test below parses + * tokens.css and fails if any value here drifts from the CSS source of truth, so + * a future token edit can't leave this gate green while runtime colours change. + */ +const DARK_BASE: Record = { + surface: '23 23 23', + 'surface-canvas': '0 0 0', + 'surface-muted': '38 38 38', + 'surface-subtle': '38 38 38', + 'surface-strong': '38 38 38', + 'surface-hover': '38 38 38', + 'surface-overlay': '0 0 0', + content: '245 245 245', + 'content-secondary': '212 212 212', + 'content-muted': '163 163 163', + 'content-faint': '115 115 115', + 'content-inverted': '255 255 255', + 'primary-200': '191 219 254', + 'primary-300': '147 197 253', + 'primary-400': '96 165 250', + 'primary-500': '47 110 244', + 'primary-600': '37 99 235', + 'primary-700': '29 78 216', +}; + +const AA_TEXT = 4.5; // body text +const AA_LARGE = 3.0; // large text / UI elements / disabled-placeholder + +/** + * Every surface layer text can land on — base, canvas, recessed wells, and the + * hover/pressed states — plus `surface-overlay` (the modal scrim, tested as a + * solid fill, which is the conservative worst case since it renders at < full + * opacity over another surface). + */ +const SURFACES = [ + 'surface', + 'surface-canvas', + 'surface-muted', + 'surface-subtle', + 'surface-strong', + 'surface-hover', + 'surface-overlay', +] as const; + +/** Text tiers held to full body contrast against every surface. */ +const BODY_TIERS = ['content', 'content-secondary', 'content-muted'] as const; + +function resolve(theme: Theme): Record { + return { ...DARK_BASE, ...theme.colors }; +} + +/** + * Parse the `--token: R G B;` declarations inside a single CSS rule block + * (`selector { … }`) from tokens.css into a `{ token: 'R G B' }` map. The theme + * blocks contain no nested braces, so a non-greedy `{ … }` match is sufficient. + */ +function parseTokenBlock(css: string, selector: string): Record { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`)); + if (!match) throw new Error(`tokens.css: block not found for "${selector}"`); + const out: Record = {}; + for (const line of match[1].split('\n')) { + const decl = line.match(/^\s*--([a-z0-9-]+):\s*([^;]+);/i); + if (decl) out[decl[1]] = decl[2].trim(); + } + return out; +} + +// Effective dark values = `:root` defaults overlaid by `:root.dark` — the same +// cascade the browser applies. Accents (primary-*) live only in `:root` and are +// inherited under dark, exactly as DARK_BASE encodes them. +const tokensCss = readFileSync( + resolvePath(dirname(fileURLToPath(import.meta.url)), '../../styles/tokens.css'), + 'utf8' +); +const effectiveDarkTokens = { + ...parseTokenBlock(tokensCss, ':root'), + ...parseTokenBlock(tokensCss, ':root.dark'), +}; + +describe('DARK_BASE parity with tokens.css', () => { + // If this fails, a tokens.css edit drifted from the mirror above — update + // DARK_BASE (and re-check the AA gate) rather than muting this test. + for (const key of Object.keys(DARK_BASE)) { + it(`--${key} matches the CSS source of truth`, () => { + expect(effectiveDarkTokens[key], `token --${key}`).toBe(DARK_BASE[key]); + }); + } +}); + +const darkPresets = PRESET_THEMES.filter(t => t.isDark && t.builtIn); + +describe('preset dark themes meet WCAG AA', () => { + it('ships the expected dark presets', () => { + // Guards against a preset being renamed/dropped without updating this gate. + expect(darkPresets.map(t => t.id).sort()).toEqual( + ['dark', 'hal9000', 'matrix', 'ocean-dark', 'sepia-dark'].sort() + ); + }); + + for (const theme of darkPresets) { + describe(`${theme.name} (${theme.id})`, () => { + const t = resolve(theme); + + it('body/muted text ≥ 4.5:1 on every surface state', () => { + for (const surface of SURFACES) { + for (const tier of BODY_TIERS) { + const ratio = channelContrast(t[tier], t[surface]); + expect( + ratio, + `${theme.id}: ${tier} on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }); + + it('faint/placeholder text ≥ 3:1 on every surface state', () => { + for (const surface of SURFACES) { + const ratio = channelContrast(t['content-faint'], t[surface]); + expect( + ratio, + `${theme.id}: content-faint on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_LARGE); + } + }); + + it('primary button label ≥ 4.5:1 on its resting and active fills', () => { + // Button.tsx: bg-primary-500 (rest) / dark:active:bg-primary-600, label + // is text-content-inverted. The transient dark-mode hover fill + // (dark:hover:bg-primary-400) is deliberately NOT gated here: it lightens + // the fill app-wide, so even the untouched historical `dark` preset sits + // at ~2.5:1 white-on-primary-400. That is a shared Button behaviour, not a + // per-theme token, and fixing it needs a Button change, not a palette one. + for (const shade of ['primary-500', 'primary-600'] as const) { + const ratio = channelContrast(t['content-inverted'], t[shade]); + expect( + ratio, + `${theme.id}: content-inverted on ${shade} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + }); + + it('accent/link text ≥ 4.5:1 on surface and canvas', () => { + // Dark-mode accent text uses the lighter shades (dark:text-primary-200…400). + for (const shade of ['primary-200', 'primary-300', 'primary-400'] as const) { + for (const surface of ['surface', 'surface-canvas'] as const) { + const ratio = channelContrast(t[shade], t[surface]); + expect( + ratio, + `${theme.id}: ${shade} text on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }); + + it('primary-500 reads as a UI element ≥ 3:1 on every surface', () => { + // Focus ring (focus-visible:ring-primary-500), button fills, and control + // boundaries can sit on any surface layer, so hold the bar on all of them. + for (const surface of SURFACES) { + const ratio = channelContrast(t['primary-500'], t[surface]); + expect( + ratio, + `${theme.id}: primary-500 vs ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_LARGE); + } + }); + }); + } +}); diff --git a/app/src/lib/theme/presets.ts b/app/src/lib/theme/presets.ts index 0a5b21acd..5b20b7788 100644 --- a/app/src/lib/theme/presets.ts +++ b/app/src/lib/theme/presets.ts @@ -118,8 +118,14 @@ const OCEAN_DARK: Theme = { 'line-subtle': '36 48 76', content: '224 232 244', 'content-secondary': '182 196 220', - 'content-muted': '140 156 188', - 'content-faint': '104 118 150', + // Muted/faint raised so they clear AA (body 4.5:1, faint 3:1) even on the + // lightest elevated surfaces (surface-hover/strong `40 52 84`), not just the + // base surface. + 'content-muted': '152 168 200', + 'content-faint': '128 142 172', + // The primary fills here are light blue, so labels over them read as dark — + // a deep navy clears AA on primary-500/600 where white (2.5:1) failed. + 'content-inverted': '8 14 28', 'primary-500': '96 165 250', 'primary-600': '59 130 246', }, @@ -169,8 +175,13 @@ const SEPIA_DARK: Theme = { 'line-subtle': '48 40 30', content: '236 228 214', 'content-secondary': '198 184 162', - 'content-muted': '156 142 120', - 'content-faint': '120 106 86', + // Raised so muted (4.5:1) and faint (3:1) hold on the lighter recessed/hover + // sepia surfaces, not only the base surface. + 'content-muted': '176 160 136', + 'content-faint': '150 134 110', + // Tan primary fills → dark labels. A near-black brown clears AA on + // primary-500/600 where white (2.6:1) failed. + 'content-inverted': '26 22 17', ...BROWN_RAMP, 'primary-500': '200 150 90', 'primary-600': '176 126 70', @@ -198,7 +209,9 @@ const MATRIX_DARK: Theme = { content: '134 255 168', 'content-secondary': '78 210 122', 'content-muted': '58 158 92', - 'content-faint': '44 112 68', + // Raised from `44 112 68` so faint text clears 3:1 even on the brighter + // hover/strong green surfaces (was ~2.6:1); still clearly dimmer than muted. + 'content-faint': '52 132 82', 'content-inverted': '2 8 4', ...GREEN_RAMP, }, @@ -252,6 +265,10 @@ const HAL_DARK: Theme = { 'content-muted': '170 130 130', 'content-faint': '130 96 96', ...RED_RAMP, + // Deepen the resting primary (was `230 40 40`) so the white button label + // clears AA (4.5:1 → 5.2:1). The active fill (primary-600) also clears it, + // and accent text uses the lighter 300/400 shades, so the red identity holds. + 'primary-500': '214 30 30', }, gradient: { canvas: 'radial-gradient(circle at 50% 16%, rgb(84 10 10), rgb(8 4 4) 56%)' }, fonts: {},