fix(theme): polish Matrix + HAL 9000 (and other dark) presets to WCAG AA (#4897)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Mega Mind
2026-07-16 00:18:48 +03:00
committed by GitHub
co-authored by Steven Enamakel
parent d320c43823
commit bfaa261393
4 changed files with 238 additions and 6 deletions
+15 -1
View File
@@ -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);
});
});
+14
View File
@@ -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);
}
+187
View File
@@ -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<string, string> = {
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<string, string> {
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<string, string> {
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<string, string> = {};
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);
}
});
});
}
});
+22 -5
View File
@@ -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: {},