mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
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:
co-authored by
Steven Enamakel
parent
d320c43823
commit
bfaa261393
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user