From fe45a444d3d3da8ef67f25e49e609d85deaac043 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:29:18 +0200 Subject: [PATCH] feat: port the webmail's built-in colour themes to the Themes pane --- src/components/settings/ThemesSettings.tsx | 120 +++--- src/screens/SettingsScreen.tsx | 2 +- src/theme/builtin-themes.ts | 443 +++++++++++++++++++++ src/theme/colors.ts | 27 +- 4 files changed, 530 insertions(+), 62 deletions(-) create mode 100644 src/theme/builtin-themes.ts diff --git a/src/components/settings/ThemesSettings.tsx b/src/components/settings/ThemesSettings.tsx index 157aa8d9..817029c1 100644 --- a/src/components/settings/ThemesSettings.tsx +++ b/src/components/settings/ThemesSettings.tsx @@ -1,87 +1,92 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; -import { Upload, Check, Palette } from 'lucide-react-native'; -import { SettingsSection, SettingItem } from './settings-section'; -import Button from '../Button'; +import { Check } from 'lucide-react-native'; +import { SettingsSection } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { resolvePalette, useColors, useResolvedTheme } from '../../theme/colors'; +import { BUILTIN_THEMES } from '../../theme/builtin-themes'; import { useSettingsStore } from '../../stores/settings-store'; +import { useLocaleStore } from '../../stores/locale-store'; -interface Theme { +interface ThemeCard { id: string | null; name: string; - author: string; - builtIn?: boolean; - variants?: ('light' | 'dark')[]; + description: string; } -const BUILT_IN: Theme[] = [ - { id: null, name: 'Default', author: 'Bulwark', builtIn: true, variants: ['light', 'dark'] }, - { id: 'qui', name: 'Qui', author: 'Bulwark', builtIn: true, variants: ['dark'] }, - { id: 'sepia', name: 'Sepia', author: 'Bulwark', builtIn: true, variants: ['light'] }, -]; - +/** + * Built-in colour themes ported from the webmail (lib/builtin-themes.ts). + * Custom zip themes and the marketplace stay webmail-only: the native app has + * no CSS pipeline, only the token subset each theme overrides. + */ export function ThemesSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const scheme = useResolvedTheme(); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); const active = useSettingsStore((s) => s.activeThemeId); const update = useSettingsStore((s) => s.updateSetting); - const [themes] = useState(BUILT_IN); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); + const cards: ThemeCard[] = [ + { + id: null, + name: t('settings.themes.default_name', 'Bulwark'), + description: t('settings.themes.default_description', 'The default light and dark palettes.'), + }, + ...BUILTIN_THEMES.map((theme) => ({ id: theme.id, name: theme.name, description: theme.description })), + ]; + return ( - {themes.map((theme) => { - const isActive = active === theme.id; + {cards.map((card) => { + const isActive = (active ?? null) === card.id; + const preview = resolvePalette(scheme, card.id); return ( update('activeThemeId', theme.id)} + key={card.id ?? 'default'} + onPress={() => update('activeThemeId', card.id)} + accessibilityRole="radio" + accessibilityState={{ selected: isActive, checked: isActive }} + accessibilityLabel={card.name} style={[styles.card, isActive && styles.cardActive]} > - - + + + + + + + + {[preview.success, preview.warning, preview.error, preview.info].map((color, i) => ( + + ))} + + - {theme.name} + {card.name} {isActive && } - {theme.author} - {theme.variants && ( - - {theme.variants.map((v) => ( - - {v} - - ))} - - )} + {card.description} ); })} - - - - ); } @@ -105,30 +110,27 @@ function makeStyles(c: ThemePalette) { }, cardActive: { borderColor: c.primary, - backgroundColor: 'rgba(59, 130, 246, 0.05)', + backgroundColor: c.primaryBg, }, preview: { width: '100%', aspectRatio: 16 / 10, borderRadius: radius.md, - backgroundColor: c.muted, - alignItems: 'center', - justifyContent: 'center', + borderWidth: 1, + flexDirection: 'row', + overflow: 'hidden', }, + previewRail: { width: '22%', borderRightWidth: 1 }, + previewBody: { flex: 1, padding: spacing.sm, gap: 6, justifyContent: 'center' }, + previewBar: { height: 6, borderRadius: 3 }, + previewDots: { flexDirection: 'row', gap: 4, marginTop: 2 }, + previewDot: { width: 8, height: 8, borderRadius: 4 }, cardHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, cardName: { ...typography.bodyMedium, color: c.text, flex: 1 }, - cardAuthor: { ...typography.caption, color: c.mutedForeground }, - variants: { flexDirection: 'row', gap: 4, marginTop: 4 }, - variantPill: { - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: radius.xs, - backgroundColor: c.muted, - }, - variantText: { fontSize: 10, color: c.mutedForeground }, + cardDescription: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, }); } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index cad46d7d..bd2be286 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -108,7 +108,7 @@ const TABS: TabDef[] = [ // Advanced { id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true }, - { id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', experimental: true, implemented: true }, + { id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', implemented: true }, // Plugins run in the webmail only; the pane is an explainer reachable by // deep link, not from the list. Debug logging lives under About & Data. { id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: true, hidden: true }, diff --git a/src/theme/builtin-themes.ts b/src/theme/builtin-themes.ts new file mode 100644 index 00000000..28cb3fad --- /dev/null +++ b/src/theme/builtin-themes.ts @@ -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>; + +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; +} diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 6f6727e6..70205fce 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -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(); + +/** + * 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]); } /**