mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: raise grey text to 4.5:1 contrast in every built-in theme
This commit is contained in:
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user