Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion app/src/lib/theme/color.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 changes: 14 additions & 0 deletions app/src/lib/theme/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 187 additions & 0 deletions app/src/lib/theme/presets.contrast.test.ts
Original file line number Diff line number Diff line change
@@ -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',
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const AA_TEXT = 4.5; // body text
const AA_LARGE = 3.0; // large text / UI elements / disabled-placeholder

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/**
* 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);
}
});
});
}
});
27 changes: 22 additions & 5 deletions app/src/lib/theme/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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: {},
Expand Down
Loading