fix: raise grey text to 4.5:1 contrast in every built-in theme

This commit is contained in:
Linus Rath
2026-10-31 03:42:24 +01:00
parent 55d008a2e8
commit 1152299583
4 changed files with 123 additions and 49 deletions
+4 -3
View File
@@ -2,14 +2,15 @@ import { describe, it, expect } from 'vitest';
import { DARK_COLORS, LIGHT_COLORS } from '../tokens';
// The standard theme's roles from branding/APP.md ("Tokens used here"),
// which follow the webmail's app/globals.css.
// which follow the webmail's app/globals.css (muted-foreground raised to
// 4.5:1 in 9f6c7b99c).
describe('standard theme tokens match the webmail', () => {
it('light', () => {
expect(LIGHT_COLORS.background).toBe('#ffffff');
expect(LIGHT_COLORS.text).toBe('#0f172a');
expect(LIGHT_COLORS.popover).toBe('#ffffff');
expect(LIGHT_COLORS.border).toBe('#e2e8f0');
expect(LIGHT_COLORS.mutedForeground).toBe('#64748b');
expect(LIGHT_COLORS.mutedForeground).toBe('#59687c');
});
it('dark: white primary with near-black text on neutral greys', () => {
@@ -19,7 +20,7 @@ describe('standard theme tokens match the webmail', () => {
expect(DARK_COLORS.primaryForeground).toBe('#171717');
expect(DARK_COLORS.popover).toBe('#1c1c1c');
expect(DARK_COLORS.border).toBe('rgba(128, 128, 128, 0.3)');
expect(DARK_COLORS.mutedForeground).toBe('#a3a3a3');
expect(DARK_COLORS.mutedForeground).toBe('#adadad');
expect(DARK_COLORS.muted).toBe('#262626');
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { DARK_COLORS, LIGHT_COLORS, type ThemePalette } from '../tokens';
import { BUILTIN_THEMES } from '../builtin-themes';
// Text contrast of every built-in theme, light and dark (WCAG 1.4.3, webmail
// 9f6c7b99c and lib/__tests__/theme-contrast.test.ts). Grey text used to sit
// at 2.6:1 on white (textMuted #94a3b8) and 3.2:1 on dark surfaces (#737373
// on #262626); it carries dates, previews, hints and placeholders all over
// the app.
type Rgb = [number, number, number];
function parse(color: string): { rgb: Rgb; a: number } {
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);
if (hex) {
const v = hex[1].length === 3 ? hex[1].split('').map((ch) => ch + ch).join('') : hex[1];
return { rgb: [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16)) as Rgb, a: 1 };
}
const rgba = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(color);
if (!rgba) throw new Error(`unparsed colour ${color}`);
return { rgb: [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])], a: rgba[4] === undefined ? 1 : Number(rgba[4]) };
}
/** Opaque rgb of `color`, laid over `under` when it has alpha. */
function solid(color: string, under: Rgb): Rgb {
const c = parse(color);
return c.rgb.map((v, i) => v * c.a + under[i] * (1 - c.a)) as Rgb;
}
function luminance([r, g, b]: Rgb): number {
const [lr, lg, lb] = [r, g, b].map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
}
function ratio(fg: string, ground: string, page: string): number {
const bg = solid(ground, solid(page, [255, 255, 255]));
const l1 = luminance(solid(fg, bg));
const l2 = luminance(bg);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
const palettes: Array<[string, ThemePalette]> = [
['default light', LIGHT_COLORS],
['default dark', DARK_COLORS],
...BUILTIN_THEMES.flatMap((theme): Array<[string, ThemePalette]> => [
[`${theme.name} light`, { ...LIGHT_COLORS, ...theme.light } as ThemePalette],
[`${theme.name} dark`, { ...DARK_COLORS, ...theme.dark } as ThemePalette],
]),
];
const TEXT = ['text', 'textSecondary', 'textMuted', 'mutedForeground'] as const;
// The grounds text is drawn on: the page, cards, sheets and popovers, and
// the grey fills of rows, chips and inputs.
const GROUNDS = ['background', 'surface', 'card', 'popover', 'secondary', 'muted'] as const;
describe.each(palettes)('%s', (_name, palette) => {
it.each(TEXT)('%s reaches 4.5:1 on every ground', (fg) => {
for (const ground of GROUNDS) {
expect(ratio(palette[fg], palette[ground], palette.background), `${fg} ${palette[fg]} on ${ground} ${palette[ground]}`)
.toBeGreaterThanOrEqual(4.5);
}
});
});
+39 -36
View File
@@ -2,6 +2,9 @@
// scripts in the parity work (2026-08-29). Each theme overrides the subset of
// ThemePalette that the CSS defines; everything else keeps the base palette.
// Regenerate rather than hand-edit when the webmail themes change.
// The text tokens (foreground, card/popover foreground, muted-foreground)
// were taken again after the webmail raised them to 4.5:1 (9f6c7b99c);
// src/theme/__tests__/theme-contrast.test.ts checks them.
//
// Mapping (webmail --color-* -> ThemePalette): primary -> primary,
// primaryDark, textLink, navActive; ring -> primaryLight, borderFocus;
@@ -47,8 +50,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
card: '#ffffff',
cardForeground: '#09090b',
text: '#09090b',
textSecondary: '#71717a',
textMuted: '#71717a',
textSecondary: '#66666e',
textMuted: '#66666e',
textInverse: '#ffffff',
textLink: '#3b82f6',
border: '#e4e4e7',
@@ -63,14 +66,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
info: '#3b82f6',
infoForeground: '#ffffff',
unread: '#3b82f6',
read: '#71717a',
read: '#66666e',
navActive: '#3b82f6',
navInactive: '#71717a',
navInactive: '#66666e',
navBadge: '#ef4444',
secondary: '#f4f4f5',
secondaryForeground: '#18181b',
muted: '#f4f4f5',
mutedForeground: '#71717a',
mutedForeground: '#66666e',
accent: '#dbeafe',
accentForeground: '#1d4ed8',
selection: '#dbeafe',
@@ -257,8 +260,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
card: '#eff1f5',
cardForeground: '#4c4f69',
text: '#4c4f69',
textSecondary: '#6c6f85',
textMuted: '#6c6f85',
textSecondary: '#5c5f77',
textMuted: '#5c5f77',
textInverse: '#eff1f5',
textLink: '#8839ef',
border: '#ccd0da',
@@ -273,14 +276,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
info: '#1e66f5',
infoForeground: '#eff1f5',
unread: '#8839ef',
read: '#6c6f85',
read: '#5c5f77',
navActive: '#8839ef',
navInactive: '#6c6f85',
navInactive: '#5c5f77',
navBadge: '#d20f39',
secondary: '#e6e9ef',
secondaryForeground: '#4c4f69',
muted: '#dce0e8',
mutedForeground: '#6c6f85',
mutedForeground: '#5c5f77',
accent: '#8839ef',
accentForeground: '#eff1f5',
selection: '#dce0e8',
@@ -307,8 +310,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
card: '#313244',
cardForeground: '#cdd6f4',
text: '#cdd6f4',
textSecondary: '#a6adc8',
textMuted: '#a6adc8',
textSecondary: '#b3b9d0',
textMuted: '#b3b9d0',
textInverse: '#1e1e2e',
textLink: '#cba6f7',
border: '#45475a',
@@ -323,14 +326,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
info: '#89b4fa',
infoForeground: '#1e1e2e',
unread: '#cba6f7',
read: '#a6adc8',
read: '#b3b9d0',
navActive: '#cba6f7',
navInactive: '#a6adc8',
navInactive: '#b3b9d0',
navBadge: '#f38ba8',
secondary: '#313244',
secondaryForeground: '#cdd6f4',
muted: '#313244',
mutedForeground: '#a6adc8',
mutedForeground: '#b3b9d0',
accent: '#45475a',
accentForeground: '#cba6f7',
selection: 'rgba(203, 166, 247, 0.2)',
@@ -360,10 +363,10 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
surfaceHover: '#268bd2',
surfaceActive: '#eee8d5',
card: '#fdf6e3',
cardForeground: '#657b83',
text: '#657b83',
textSecondary: '#93a1a1',
textMuted: '#93a1a1',
cardForeground: '#073642',
text: '#073642',
textSecondary: '#4f6369',
textMuted: '#4f6369',
textInverse: '#fdf6e3',
textLink: '#268bd2',
border: '#eee8d5',
@@ -378,20 +381,20 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
info: '#268bd2',
infoForeground: '#fdf6e3',
unread: '#268bd2',
read: '#93a1a1',
read: '#4f6369',
navActive: '#268bd2',
navInactive: '#93a1a1',
navInactive: '#4f6369',
navBadge: '#dc322f',
secondary: '#eee8d5',
secondaryForeground: '#586e75',
muted: '#eee8d5',
mutedForeground: '#93a1a1',
mutedForeground: '#4f6369',
accent: '#268bd2',
accentForeground: '#fdf6e3',
selection: '#eee8d5',
selectionForeground: '#268bd2',
popover: '#fdf6e3',
popoverForeground: '#657b83',
popoverForeground: '#073642',
chart1: '#268bd2',
chart2: '#859900',
chart3: '#b58900',
@@ -410,10 +413,10 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
surfaceHover: '#073642',
surfaceActive: '#073642',
card: '#073642',
cardForeground: '#93a1a1',
text: '#839496',
textSecondary: '#586e75',
textMuted: '#586e75',
cardForeground: '#aab7b7',
text: '#aab7b7',
textSecondary: '#9aa7a7',
textMuted: '#9aa7a7',
textInverse: '#002b36',
textLink: '#268bd2',
border: '#073642',
@@ -428,20 +431,20 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
info: '#268bd2',
infoForeground: '#002b36',
unread: '#268bd2',
read: '#586e75',
read: '#9aa7a7',
navActive: '#268bd2',
navInactive: '#586e75',
navInactive: '#9aa7a7',
navBadge: '#dc322f',
secondary: '#073642',
secondaryForeground: '#93a1a1',
muted: '#073642',
mutedForeground: '#586e75',
mutedForeground: '#9aa7a7',
accent: '#073642',
accentForeground: '#268bd2',
selection: 'rgba(38, 139, 210, 0.2)',
selectionForeground: '#268bd2',
popover: '#073642',
popoverForeground: '#93a1a1',
popoverForeground: '#aab7b7',
chart1: '#268bd2',
chart2: '#859900',
chart3: '#b58900',
@@ -467,8 +470,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
card: '#ffffff',
cardForeground: '#27353a',
text: '#27353a',
textSecondary: '#737677',
textMuted: '#737677',
textSecondary: '#646768',
textMuted: '#646768',
textInverse: '#ffffff',
textLink: '#37beff',
border: '#dddddd',
@@ -483,14 +486,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [
info: '#37beff',
infoForeground: '#ffffff',
unread: '#ffd452',
read: '#737677',
read: '#646768',
navActive: '#37beff',
navInactive: '#737677',
navInactive: '#646768',
navBadge: '#ff5552',
secondary: '#f4f4f4',
secondaryForeground: '#27353a',
muted: '#f4f4f4',
mutedForeground: '#737677',
mutedForeground: '#646768',
accent: '#e6f6ff',
accentForeground: '#0070a8',
selection: '#ebf8ff',
+14 -10
View File
@@ -136,8 +136,10 @@ export const DARK_COLORS: ThemePalette = {
cardForeground: '#fafafa',
text: '#fafafa',
textSecondary: '#a3a3a3',
textMuted: '#737373',
// The webmail's dark muted-foreground (9f6c7b99c): 4.5:1 or more on the
// background and on the #262626 surfaces. #737373 was 3.2:1 on those.
textSecondary: '#adadad',
textMuted: '#adadad',
textInverse: '#0a0a0a',
// Links stay blue (webmail: info) so they read as links on a white-primary
// theme.
@@ -161,7 +163,7 @@ export const DARK_COLORS: ThemePalette = {
infoForeground: '#ffffff',
unread: '#60a5fa',
read: '#a3a3a3',
read: '#adadad',
starred: '#fbbf24',
flagged: '#ef4444',
draft: '#a78bfa',
@@ -179,13 +181,13 @@ export const DARK_COLORS: ThemePalette = {
tags: TAGS,
navActive: '#fafafa',
navInactive: '#a3a3a3',
navInactive: '#adadad',
navBadge: '#ef4444',
secondary: '#262626',
secondaryForeground: '#fafafa',
muted: '#262626',
mutedForeground: '#a3a3a3',
mutedForeground: '#adadad',
accent: '#1e3a8a',
accentForeground: '#dbeafe',
@@ -232,8 +234,10 @@ export const LIGHT_COLORS: ThemePalette = {
cardForeground: '#0f172a',
text: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
// The webmail's light muted-foreground (9f6c7b99c): 4.5:1 or more on white
// and on the slate fills. #94a3b8 was 2.6:1 on white.
textSecondary: '#59687c',
textMuted: '#59687c',
textInverse: '#ffffff',
textLink: '#1d4ed8',
@@ -255,7 +259,7 @@ export const LIGHT_COLORS: ThemePalette = {
infoForeground: '#ffffff',
unread: '#3b82f6',
read: '#64748b',
read: '#59687c',
starred: '#eab308',
flagged: '#ef4444',
draft: '#a855f7',
@@ -273,13 +277,13 @@ export const LIGHT_COLORS: ThemePalette = {
tags: LIGHT_TAGS,
navActive: '#3b82f6',
navInactive: '#64748b',
navInactive: '#59687c',
navBadge: '#ef4444',
secondary: '#f8fafc',
secondaryForeground: '#0f172a',
muted: '#f1f5f9',
mutedForeground: '#64748b',
mutedForeground: '#59687c',
accent: '#dbeafe',
accentForeground: '#1e40af',