diff --git a/src/theme/__tests__/builtin-themes.test.ts b/src/theme/__tests__/builtin-themes.test.ts new file mode 100644 index 00000000..2d6ba35c --- /dev/null +++ b/src/theme/__tests__/builtin-themes.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { BUILTIN_THEMES, getBuiltinTheme } from '../builtin-themes'; +import { resolvePalette } from '../colors'; +import { DARK_COLORS, LIGHT_COLORS } from '../tokens'; + +// Solid fills: RN paints screens, sheets and cards with these and has no +// backdrop blur, so a translucent value would show the content underneath. +const SOLID_FILLS = [ + 'background', 'surface', 'surfaceHover', 'surfaceActive', 'card', + 'secondary', 'muted', 'accent', 'primaryBg', 'primaryBgHover', 'popover', +] as const; + +describe('built-in themes', () => { + it('ships every webmail built-in theme except Flat fields, in webmail order', () => { + expect(BUILTIN_THEMES.map((t) => t.id)).toEqual([ + 'builtin-qui', + 'builtin-nord', + 'builtin-catppuccin', + 'builtin-solarized', + 'builtin-roundcube-elastic', + 'builtin-aurora-glass', + ]); + }); + + it('defines the same tokens for light and dark in every theme', () => { + for (const theme of BUILTIN_THEMES) { + expect(Object.keys(theme.light).sort(), theme.id).toEqual(Object.keys(theme.dark).sort()); + } + }); + + it('keeps solid-fill tokens opaque', () => { + for (const theme of BUILTIN_THEMES) { + for (const scheme of ['light', 'dark'] as const) { + for (const key of SOLID_FILLS) { + expect(theme[scheme][key], `${theme.id} ${scheme} ${key}`).toMatch(/^#[0-9a-f]{6}$/i); + } + } + } + }); + + it('applies the Roundcube Elastic tokens in light and dark', () => { + const light = resolvePalette('light', 'builtin-roundcube-elastic'); + expect(light.primary).toBe('#37beff'); + expect(light.text).toBe('#27353a'); + expect(light.background).toBe('#ffffff'); + const dark = resolvePalette('dark', 'builtin-roundcube-elastic'); + expect(dark.background).toBe('#21292c'); + expect(dark.surface).toBe('#2c373a'); + expect(dark.unread).toBe('#b88a00'); + // Tokens the webmail theme doesn't define keep the base palette. + expect(dark.starred).toBe(DARK_COLORS.starred); + expect(dark.calendar).toBe(DARK_COLORS.calendar); + }); + + it('flattens the Aurora Glass dark glass tokens onto its background', () => { + const dark = resolvePalette('dark', 'builtin-aurora-glass'); + expect(dark.primary).toBe('#8b7bff'); + expect(dark.background).toBe('#06070f'); + // rgba(255, 255, 255, 0.06) over #06070f + expect(dark.surface).toBe('#15161d'); + // rgba(139, 123, 255, 0.16) over #06070f + expect(dark.accent).toBe('#1b1a35'); + // Overlays may stay translucent, as in the other themes. + expect(dark.selection).toBe('rgba(139, 123, 255, 0.22)'); + const light = resolvePalette('light', 'builtin-aurora-glass'); + expect(light.background).toBe('#eef1fb'); + expect(light.tags).toBe(LIGHT_COLORS.tags); + }); + + it('falls back to the base palette for an unknown or cleared theme id', () => { + expect(getBuiltinTheme('builtin-flat-fields')).toBeNull(); + expect(resolvePalette('dark', 'builtin-flat-fields')).toBe(DARK_COLORS); + expect(resolvePalette('light', null)).toBe(LIGHT_COLORS); + }); +}); diff --git a/src/theme/builtin-themes.ts b/src/theme/builtin-themes.ts index 28cb3fad..c6fdbbcb 100644 --- a/src/theme/builtin-themes.ts +++ b/src/theme/builtin-themes.ts @@ -2,6 +2,20 @@ // 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. +// +// Mapping (webmail --color-* -> ThemePalette): primary -> primary, +// primaryDark, textLink, navActive; ring -> primaryLight, borderFocus; +// accent -> primaryBg, primaryBgHover, surfaceHover; secondary -> surface; +// muted -> surfaceActive; foreground -> text; background -> textInverse; +// muted-foreground -> textSecondary, textMuted, read, navInactive; +// destructive -> error, navBadge; border -> borderLight; the rest by name. +// Translucent values of the solid-fill tokens (surfaces, cards, accent, +// popover) are composited onto the theme background, because RN has no +// backdrop blur and would show whatever lies underneath (Aurora Glass dark). +// +// Only the colour tokens are ported. The webmail's theme skins (fonts, +// radii, Elastic's dark task rail, Aurora's gradient mesh and frosted panes) +// and the "Flat fields" design-system theme have no native counterpart. import type { ThemePalette } from './tokens'; export type PaletteOverride = Partial>; @@ -435,6 +449,216 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ chart5: '#6c71c4', }, }, + { + id: 'builtin-roundcube-elastic', + name: 'Roundcube Elastic', + description: "Colours of Roundcube's Elastic skin: the #37beff blue on white or dark slate", + light: { + primary: '#37beff', + primaryDark: '#37beff', + primaryLight: '#37beff', + primaryBg: '#e6f6ff', + primaryBgHover: '#e6f6ff', + primaryForeground: '#ffffff', + background: '#ffffff', + surface: '#f4f4f4', + surfaceHover: '#e6f6ff', + surfaceActive: '#f4f4f4', + card: '#ffffff', + cardForeground: '#27353a', + text: '#27353a', + textSecondary: '#737677', + textMuted: '#737677', + textInverse: '#ffffff', + textLink: '#37beff', + border: '#dddddd', + borderLight: '#dddddd', + borderFocus: '#37beff', + success: '#41b849', + successForeground: '#ffffff', + warning: '#ffd452', + warningForeground: '#27353a', + error: '#ff5552', + errorForeground: '#ffffff', + info: '#37beff', + infoForeground: '#ffffff', + unread: '#ffd452', + read: '#737677', + navActive: '#37beff', + navInactive: '#737677', + navBadge: '#ff5552', + secondary: '#f4f4f4', + secondaryForeground: '#27353a', + muted: '#f4f4f4', + mutedForeground: '#737677', + accent: '#e6f6ff', + accentForeground: '#0070a8', + selection: '#ebf8ff', + selectionForeground: '#27353a', + popover: '#ffffff', + popoverForeground: '#27353a', + chart1: '#37beff', + chart2: '#41b849', + chart3: '#ffd452', + chart4: '#ff5552', + chart5: '#9b59b6', + }, + dark: { + primary: '#37beff', + primaryDark: '#37beff', + primaryLight: '#37beff', + primaryBg: '#374549', + primaryBgHover: '#374549', + primaryForeground: '#ffffff', + background: '#21292c', + surface: '#2c373a', + surfaceHover: '#374549', + surfaceActive: '#2c373a', + card: '#1a2225', + cardForeground: '#ffffff', + text: '#ffffff', + textSecondary: '#b3bec1', + textMuted: '#b3bec1', + textInverse: '#21292c', + textLink: '#37beff', + border: '#4d6066', + borderLight: '#4d6066', + borderFocus: '#37beff', + success: '#41b849', + successForeground: '#ffffff', + warning: '#ffd452', + warningForeground: '#21292c', + error: '#ff5552', + errorForeground: '#ffffff', + info: '#37beff', + infoForeground: '#ffffff', + unread: '#b88a00', + read: '#b3bec1', + navActive: '#37beff', + navInactive: '#b3bec1', + navBadge: '#ff5552', + secondary: '#2c373a', + secondaryForeground: '#ffffff', + muted: '#2c373a', + mutedForeground: '#b3bec1', + accent: '#374549', + accentForeground: '#37beff', + selection: '#374549', + selectionForeground: '#37beff', + popover: '#161b1d', + popoverForeground: '#ffffff', + chart1: '#37beff', + chart2: '#41b849', + chart3: '#ffd452', + chart4: '#ff5552', + chart5: '#b48ead', + }, + }, + { + id: 'builtin-aurora-glass', + name: 'Aurora Glass', + description: 'Vibrant indigo and cyan accents on a cool lavender or near-black canvas', + light: { + primary: '#6d5cff', + primaryDark: '#6d5cff', + primaryLight: '#6d5cff', + primaryBg: '#e0e7ff', + primaryBgHover: '#e0e7ff', + primaryForeground: '#ffffff', + background: '#eef1fb', + surface: '#e3e7f7', + surfaceHover: '#e0e7ff', + surfaceActive: '#e7eaf6', + card: '#ffffff', + cardForeground: '#14162e', + text: '#14162e', + textSecondary: '#5b6080', + textMuted: '#5b6080', + textInverse: '#eef1fb', + textLink: '#6d5cff', + border: 'rgba(13, 18, 45, 0.10)', + borderLight: 'rgba(13, 18, 45, 0.10)', + borderFocus: '#6d5cff', + success: '#10b981', + successForeground: '#ffffff', + warning: '#f59e0b', + warningForeground: '#14162e', + error: '#f43f5e', + errorForeground: '#ffffff', + info: '#06b6d4', + infoForeground: '#ffffff', + unread: '#6d5cff', + read: '#5b6080', + navActive: '#6d5cff', + navInactive: '#5b6080', + navBadge: '#f43f5e', + secondary: '#e3e7f7', + secondaryForeground: '#14162e', + muted: '#e7eaf6', + mutedForeground: '#5b6080', + accent: '#e0e7ff', + accentForeground: '#4338ca', + selection: 'rgba(109, 92, 255, 0.14)', + selectionForeground: '#4338ca', + popover: '#ffffff', + popoverForeground: '#14162e', + chart1: '#6d5cff', + chart2: '#06b6d4', + chart3: '#ec4899', + chart4: '#f59e0b', + chart5: '#10b981', + }, + dark: { + primary: '#8b7bff', + primaryDark: '#8b7bff', + primaryLight: '#8b7bff', + primaryBg: '#1b1a35', + primaryBgHover: '#1b1a35', + primaryForeground: '#ffffff', + background: '#06070f', + surface: '#15161d', + surfaceHover: '#1b1a35', + surfaceActive: '#12131b', + card: '#101119', + cardForeground: '#eef0ff', + text: '#eef0ff', + textSecondary: '#9aa0c4', + textMuted: '#9aa0c4', + textInverse: '#06070f', + textLink: '#8b7bff', + border: 'rgba(255, 255, 255, 0.08)', + borderLight: 'rgba(255, 255, 255, 0.08)', + borderFocus: '#8b7bff', + success: '#34d399', + successForeground: '#052e1f', + warning: '#fbbf24', + warningForeground: '#1a1405', + error: '#fb7185', + errorForeground: '#1a0b10', + info: '#22d3ee', + infoForeground: '#04222a', + unread: '#8b7bff', + read: '#9aa0c4', + navActive: '#8b7bff', + navInactive: '#9aa0c4', + navBadge: '#fb7185', + secondary: '#15161d', + secondaryForeground: '#eef0ff', + muted: '#12131b', + mutedForeground: '#9aa0c4', + accent: '#1b1a35', + accentForeground: '#c4bbff', + selection: 'rgba(139, 123, 255, 0.22)', + selectionForeground: '#c4bbff', + popover: '#12132a', + popoverForeground: '#eef0ff', + chart1: '#8b7bff', + chart2: '#22d3ee', + chart3: '#f472b6', + chart4: '#fbbf24', + chart5: '#34d399', + }, + }, ]; export function getBuiltinTheme(id: string | null | undefined): BuiltinTheme | null {