feat: port the webmail's built-in colour themes to the Themes pane

This commit is contained in:
Linus Rath
2026-08-29 18:29:18 +02:00
parent bea3fc4c42
commit fe45a444d3
4 changed files with 530 additions and 62 deletions
+443
View File
@@ -0,0 +1,443 @@
// GENERATED from the webmail's lib/builtin-themes.ts CSS variables by
// 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.
import type { ThemePalette } from './tokens';
export type PaletteOverride = Partial<Omit<ThemePalette, 'calendar' | 'tags'>>;
export interface BuiltinTheme {
id: string;
name: string;
description: string;
light: PaletteOverride;
dark: PaletteOverride;
}
export const BUILTIN_THEMES: BuiltinTheme[] = [
{
id: 'builtin-qui',
name: 'Qui',
description: 'Clean, modern zinc-and-blue theme inspired by autobrr/qui',
light: {
primary: '#3b82f6',
primaryDark: '#3b82f6',
primaryLight: '#3b82f6',
primaryBg: '#dbeafe',
primaryBgHover: '#dbeafe',
primaryForeground: '#ffffff',
background: '#ffffff',
surface: '#f4f4f5',
surfaceHover: '#dbeafe',
surfaceActive: '#f4f4f5',
card: '#ffffff',
cardForeground: '#09090b',
text: '#09090b',
textSecondary: '#71717a',
textMuted: '#71717a',
textInverse: '#ffffff',
textLink: '#3b82f6',
border: '#e4e4e7',
borderLight: '#e4e4e7',
borderFocus: '#3b82f6',
success: '#22c55e',
successForeground: '#ffffff',
warning: '#eab308',
warningForeground: '#ffffff',
error: '#ef4444',
errorForeground: '#ffffff',
info: '#3b82f6',
infoForeground: '#ffffff',
unread: '#3b82f6',
read: '#71717a',
navActive: '#3b82f6',
navInactive: '#71717a',
navBadge: '#ef4444',
secondary: '#f4f4f5',
secondaryForeground: '#18181b',
muted: '#f4f4f5',
mutedForeground: '#71717a',
accent: '#dbeafe',
accentForeground: '#1d4ed8',
selection: '#dbeafe',
selectionForeground: '#1d4ed8',
popover: '#ffffff',
popoverForeground: '#09090b',
chart1: '#3b82f6',
chart2: '#22c55e',
chart3: '#f59e0b',
chart4: '#ef4444',
chart5: '#8b5cf6',
},
dark: {
primary: '#3b82f6',
primaryDark: '#3b82f6',
primaryLight: '#3b82f6',
primaryBg: '#172554',
primaryBgHover: '#172554',
primaryForeground: '#ffffff',
background: '#09090b',
surface: '#18181b',
surfaceHover: '#172554',
surfaceActive: '#18181b',
card: '#141414',
cardForeground: '#fafafa',
text: '#fafafa',
textSecondary: '#a1a1aa',
textMuted: '#a1a1aa',
textInverse: '#09090b',
textLink: '#3b82f6',
border: '#27272a',
borderLight: '#27272a',
borderFocus: '#3b82f6',
success: '#16a34a',
successForeground: '#ffffff',
warning: '#ca8a04',
warningForeground: '#ffffff',
error: '#ef4444',
errorForeground: '#fafafa',
info: '#60a5fa',
infoForeground: '#ffffff',
unread: '#60a5fa',
read: '#a1a1aa',
navActive: '#3b82f6',
navInactive: '#a1a1aa',
navBadge: '#ef4444',
secondary: '#18181b',
secondaryForeground: '#fafafa',
muted: '#18181b',
mutedForeground: '#a1a1aa',
accent: '#172554',
accentForeground: '#93c5fd',
selection: 'rgba(59, 130, 246, 0.25)',
selectionForeground: '#93c5fd',
popover: '#18181b',
popoverForeground: '#fafafa',
chart1: '#60a5fa',
chart2: '#4ade80',
chart3: '#fbbf24',
chart4: '#f87171',
chart5: '#a78bfa',
},
},
{
id: 'builtin-nord',
name: 'Nord',
description: 'Arctic, north-bluish color palette inspired by nordtheme.com',
light: {
primary: '#5e81ac',
primaryDark: '#5e81ac',
primaryLight: '#81a1c1',
primaryBg: '#81a1c1',
primaryBgHover: '#81a1c1',
primaryForeground: '#eceff4',
background: '#eceff4',
surface: '#e5e9f0',
surfaceHover: '#81a1c1',
surfaceActive: '#d8dee9',
card: '#eceff4',
cardForeground: '#2e3440',
text: '#2e3440',
textSecondary: '#4c566a',
textMuted: '#4c566a',
textInverse: '#eceff4',
textLink: '#5e81ac',
border: '#d8dee9',
borderLight: '#d8dee9',
borderFocus: '#81a1c1',
success: '#a3be8c',
successForeground: '#2e3440',
warning: '#ebcb8b',
warningForeground: '#2e3440',
error: '#bf616a',
errorForeground: '#eceff4',
info: '#81a1c1',
infoForeground: '#eceff4',
unread: '#5e81ac',
read: '#4c566a',
navActive: '#5e81ac',
navInactive: '#4c566a',
navBadge: '#bf616a',
secondary: '#e5e9f0',
secondaryForeground: '#2e3440',
muted: '#d8dee9',
mutedForeground: '#4c566a',
accent: '#81a1c1',
accentForeground: '#2e3440',
selection: '#d8dee9',
selectionForeground: '#5e81ac',
popover: '#eceff4',
popoverForeground: '#2e3440',
chart1: '#5e81ac',
chart2: '#a3be8c',
chart3: '#ebcb8b',
chart4: '#bf616a',
chart5: '#b48ead',
},
dark: {
primary: '#88c0d0',
primaryDark: '#88c0d0',
primaryLight: '#88c0d0',
primaryBg: '#434c5e',
primaryBgHover: '#434c5e',
primaryForeground: '#2e3440',
background: '#2e3440',
surface: '#3b4252',
surfaceHover: '#434c5e',
surfaceActive: '#3b4252',
card: '#3b4252',
cardForeground: '#eceff4',
text: '#eceff4',
textSecondary: '#d8dee9',
textMuted: '#d8dee9',
textInverse: '#2e3440',
textLink: '#88c0d0',
border: '#3b4252',
borderLight: '#3b4252',
borderFocus: '#88c0d0',
success: '#a3be8c',
successForeground: '#2e3440',
warning: '#ebcb8b',
warningForeground: '#2e3440',
error: '#bf616a',
errorForeground: '#eceff4',
info: '#88c0d0',
infoForeground: '#2e3440',
unread: '#88c0d0',
read: '#d8dee9',
navActive: '#88c0d0',
navInactive: '#d8dee9',
navBadge: '#bf616a',
secondary: '#3b4252',
secondaryForeground: '#eceff4',
muted: '#3b4252',
mutedForeground: '#d8dee9',
accent: '#434c5e',
accentForeground: '#88c0d0',
selection: 'rgba(136, 192, 208, 0.2)',
selectionForeground: '#88c0d0',
popover: '#3b4252',
popoverForeground: '#eceff4',
chart1: '#88c0d0',
chart2: '#a3be8c',
chart3: '#ebcb8b',
chart4: '#bf616a',
chart5: '#b48ead',
},
},
{
id: 'builtin-catppuccin',
name: 'Catppuccin',
description: 'Soothing pastel theme with Latte (light) and Mocha (dark) variants',
light: {
primary: '#8839ef',
primaryDark: '#8839ef',
primaryLight: '#8839ef',
primaryBg: '#8839ef',
primaryBgHover: '#8839ef',
primaryForeground: '#eff1f5',
background: '#eff1f5',
surface: '#e6e9ef',
surfaceHover: '#8839ef',
surfaceActive: '#dce0e8',
card: '#eff1f5',
cardForeground: '#4c4f69',
text: '#4c4f69',
textSecondary: '#6c6f85',
textMuted: '#6c6f85',
textInverse: '#eff1f5',
textLink: '#8839ef',
border: '#ccd0da',
borderLight: '#ccd0da',
borderFocus: '#8839ef',
success: '#40a02b',
successForeground: '#eff1f5',
warning: '#df8e1d',
warningForeground: '#eff1f5',
error: '#d20f39',
errorForeground: '#eff1f5',
info: '#1e66f5',
infoForeground: '#eff1f5',
unread: '#8839ef',
read: '#6c6f85',
navActive: '#8839ef',
navInactive: '#6c6f85',
navBadge: '#d20f39',
secondary: '#e6e9ef',
secondaryForeground: '#4c4f69',
muted: '#dce0e8',
mutedForeground: '#6c6f85',
accent: '#8839ef',
accentForeground: '#eff1f5',
selection: '#dce0e8',
selectionForeground: '#8839ef',
popover: '#eff1f5',
popoverForeground: '#4c4f69',
chart1: '#8839ef',
chart2: '#40a02b',
chart3: '#df8e1d',
chart4: '#d20f39',
chart5: '#1e66f5',
},
dark: {
primary: '#cba6f7',
primaryDark: '#cba6f7',
primaryLight: '#cba6f7',
primaryBg: '#45475a',
primaryBgHover: '#45475a',
primaryForeground: '#1e1e2e',
background: '#1e1e2e',
surface: '#313244',
surfaceHover: '#45475a',
surfaceActive: '#313244',
card: '#313244',
cardForeground: '#cdd6f4',
text: '#cdd6f4',
textSecondary: '#a6adc8',
textMuted: '#a6adc8',
textInverse: '#1e1e2e',
textLink: '#cba6f7',
border: '#45475a',
borderLight: '#45475a',
borderFocus: '#cba6f7',
success: '#a6e3a1',
successForeground: '#1e1e2e',
warning: '#f9e2af',
warningForeground: '#1e1e2e',
error: '#f38ba8',
errorForeground: '#1e1e2e',
info: '#89b4fa',
infoForeground: '#1e1e2e',
unread: '#cba6f7',
read: '#a6adc8',
navActive: '#cba6f7',
navInactive: '#a6adc8',
navBadge: '#f38ba8',
secondary: '#313244',
secondaryForeground: '#cdd6f4',
muted: '#313244',
mutedForeground: '#a6adc8',
accent: '#45475a',
accentForeground: '#cba6f7',
selection: 'rgba(203, 166, 247, 0.2)',
selectionForeground: '#cba6f7',
popover: '#313244',
popoverForeground: '#cdd6f4',
chart1: '#cba6f7',
chart2: '#a6e3a1',
chart3: '#f9e2af',
chart4: '#f38ba8',
chart5: '#89b4fa',
},
},
{
id: 'builtin-solarized',
name: 'Solarized',
description: 'Precision colors for machines and people by Ethan Schoonover',
light: {
primary: '#268bd2',
primaryDark: '#268bd2',
primaryLight: '#268bd2',
primaryBg: '#268bd2',
primaryBgHover: '#268bd2',
primaryForeground: '#fdf6e3',
background: '#fdf6e3',
surface: '#eee8d5',
surfaceHover: '#268bd2',
surfaceActive: '#eee8d5',
card: '#fdf6e3',
cardForeground: '#657b83',
text: '#657b83',
textSecondary: '#93a1a1',
textMuted: '#93a1a1',
textInverse: '#fdf6e3',
textLink: '#268bd2',
border: '#eee8d5',
borderLight: '#eee8d5',
borderFocus: '#268bd2',
success: '#859900',
successForeground: '#fdf6e3',
warning: '#b58900',
warningForeground: '#fdf6e3',
error: '#dc322f',
errorForeground: '#fdf6e3',
info: '#268bd2',
infoForeground: '#fdf6e3',
unread: '#268bd2',
read: '#93a1a1',
navActive: '#268bd2',
navInactive: '#93a1a1',
navBadge: '#dc322f',
secondary: '#eee8d5',
secondaryForeground: '#586e75',
muted: '#eee8d5',
mutedForeground: '#93a1a1',
accent: '#268bd2',
accentForeground: '#fdf6e3',
selection: '#eee8d5',
selectionForeground: '#268bd2',
popover: '#fdf6e3',
popoverForeground: '#657b83',
chart1: '#268bd2',
chart2: '#859900',
chart3: '#b58900',
chart4: '#dc322f',
chart5: '#6c71c4',
},
dark: {
primary: '#268bd2',
primaryDark: '#268bd2',
primaryLight: '#268bd2',
primaryBg: '#073642',
primaryBgHover: '#073642',
primaryForeground: '#002b36',
background: '#002b36',
surface: '#073642',
surfaceHover: '#073642',
surfaceActive: '#073642',
card: '#073642',
cardForeground: '#93a1a1',
text: '#839496',
textSecondary: '#586e75',
textMuted: '#586e75',
textInverse: '#002b36',
textLink: '#268bd2',
border: '#073642',
borderLight: '#073642',
borderFocus: '#268bd2',
success: '#859900',
successForeground: '#002b36',
warning: '#b58900',
warningForeground: '#002b36',
error: '#dc322f',
errorForeground: '#fdf6e3',
info: '#268bd2',
infoForeground: '#002b36',
unread: '#268bd2',
read: '#586e75',
navActive: '#268bd2',
navInactive: '#586e75',
navBadge: '#dc322f',
secondary: '#073642',
secondaryForeground: '#93a1a1',
muted: '#073642',
mutedForeground: '#586e75',
accent: '#073642',
accentForeground: '#268bd2',
selection: 'rgba(38, 139, 210, 0.2)',
selectionForeground: '#268bd2',
popover: '#073642',
popoverForeground: '#93a1a1',
chart1: '#268bd2',
chart2: '#859900',
chart3: '#b58900',
chart4: '#dc322f',
chart5: '#6c71c4',
},
},
];
export function getBuiltinTheme(id: string | null | undefined): BuiltinTheme | null {
if (!id) return null;
return BUILTIN_THEMES.find((t) => t.id === id) ?? null;
}
+25 -2
View File
@@ -1,24 +1,47 @@
import { useMemo } from 'react';
import { useColorScheme } from 'react-native';
import { useSettingsStore } from '../stores/settings-store';
import { LIGHT_COLORS, DARK_COLORS, type ThemePalette } from './tokens';
import { getBuiltinTheme } from './builtin-themes';
export type { ThemePalette };
const paletteCache = new Map<string, ThemePalette>();
/**
* Base light/dark palette with the active built-in theme's overrides applied
* (Settings → Themes). Memoised per (scheme, theme) pair so `useColors()`
* keeps returning a stable object for `useMemo(() => makeStyles(c), [c])`.
*/
export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null): ThemePalette {
const base = scheme === 'light' ? LIGHT_COLORS : DARK_COLORS;
const theme = getBuiltinTheme(themeId);
if (!theme) return base;
const key = `${scheme}:${theme.id}`;
const cached = paletteCache.get(key);
if (cached) return cached;
const merged: ThemePalette = { ...base, ...(scheme === 'light' ? theme.light : theme.dark) };
paletteCache.set(key, merged);
return merged;
}
/**
* Returns the active palette for the current render. Resolves the user's
* theme preference ('light' | 'dark' | 'system') against the OS scheme.
* theme preference ('light' | 'dark' | 'system') against the OS scheme and
* applies the selected built-in theme.
*
* Prefer this hook in new and migrated components. Static `import { colors }`
* still works for not-yet-migrated code (it always returns the dark palette).
*/
export function useColors(): ThemePalette {
const themePref = useSettingsStore((s) => s.theme);
const activeThemeId = useSettingsStore((s) => s.activeThemeId);
const systemScheme = useColorScheme();
const resolved =
themePref === 'system'
? systemScheme === 'light' ? 'light' : 'dark'
: themePref;
return resolved === 'light' ? LIGHT_COLORS : DARK_COLORS;
return useMemo(() => resolvePalette(resolved, activeThemeId), [resolved, activeThemeId]);
}
/**