diff --git a/src/theme/__tests__/standard-theme.test.ts b/src/theme/__tests__/standard-theme.test.ts index 5f71ea12..e1543204 100644 --- a/src/theme/__tests__/standard-theme.test.ts +++ b/src/theme/__tests__/standard-theme.test.ts @@ -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'); }); }); diff --git a/src/theme/__tests__/theme-contrast.test.ts b/src/theme/__tests__/theme-contrast.test.ts new file mode 100644 index 00000000..f56bc0c8 --- /dev/null +++ b/src/theme/__tests__/theme-contrast.test.ts @@ -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); + } + }); +}); diff --git a/src/theme/builtin-themes.ts b/src/theme/builtin-themes.ts index c6fdbbcb..6b72bb05 100644 --- a/src/theme/builtin-themes.ts +++ b/src/theme/builtin-themes.ts @@ -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', diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts index 5b146b88..2ae71a10 100644 --- a/src/theme/tokens.ts +++ b/src/theme/tokens.ts @@ -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',