diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index 8b5a691a..9dc07bf1 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -1,6 +1,8 @@ import { ipcMain, nativeTheme, BrowserWindow, shell, dialog } from "electron"; import Store from "electron-store"; import { + AppearanceConfigSchema, + type AppearanceConfig, type Config, type EAConfig, type IpcResponse, @@ -657,6 +659,51 @@ export function registerSettingsIpc(): void { }, ); + // Get appearance config + ipcMain.handle("appearance:get", async (): Promise> => { + try { + const config = getConfig(); + return { + success: true, + data: config.appearance ?? AppearanceConfigSchema.parse({}), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + // Set appearance config — saves to store, broadcasts to renderer + ipcMain.handle( + "appearance:set", + async (_, rawAppearance: unknown): Promise> => { + try { + const parsed = AppearanceConfigSchema.safeParse(rawAppearance); + if (!parsed.success) { + return { success: false, error: `Invalid appearance config: ${parsed.error.message}` }; + } + const appearance = parsed.data; + + const currentConfig = getConfig(); + getStore().set("config", { ...currentConfig, appearance }); + + // Broadcast to all renderer windows + for (const w of BrowserWindow.getAllWindows()) { + w.webContents.send("appearance:changed", appearance); + } + + return { success: true, data: undefined }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + ); + // Test OpenClaw connection by running `openclaw health` ipcMain.handle("settings:test-openclaw-connection", async (): Promise> => { const { execFile } = await import("node:child_process"); diff --git a/src/preload/index.ts b/src/preload/index.ts index 0fc45a85..5cbc71ac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -557,6 +557,22 @@ const api = { }, }, + // Appearance customization + appearance: { + get: (): Promise => ipcRenderer.invoke("appearance:get"), + set: (config: Record): Promise => + ipcRenderer.invoke("appearance:set", config), + onChange: (callback: (data: Record) => void): void => { + ipcRenderer.on( + "appearance:changed", + (_: Electron.IpcRendererEvent, data: Record) => callback(data), + ); + }, + removeAllListeners: (): void => { + ipcRenderer.removeAllListeners("appearance:changed"); + }, + }, + // Auth events (token expiry, extension re-auth) auth: { onTokenExpired: ( diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 687c591a..b7b4c9d4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -27,6 +27,7 @@ import { DraftEditLearnedToast } from "./components/DraftEditLearnedToast"; import { AnalysisOverrideLearnedToast } from "./components/AnalysisOverrideLearnedToast"; import { SnoozeMenu } from "./components/SnoozeMenu"; import { FindBar } from "./components/FindBar"; +import { useAppearance } from "./hooks/useAppearance"; import { registerBundledExtensions } from "./extensions"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { @@ -665,6 +666,9 @@ export default function App() { syncProgress, } = useAppStore(); + // Apply theme CSS variables (presets, accent, vibrancy, font scale) + useAppearance(); + // Initialize keyboard shortcuts useKeyboardShortcuts({ onToggleShortcutHelp: () => setShowShortcuts((prev) => !prev), diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index e755601f..ec0dcb4c 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -17,8 +17,10 @@ import { type McpServerConfig, type ModelConfig, type ModelTier, + type AppearanceConfig, type CliToolConfig, } from "../../shared/types"; +import { THEME_PRESET_LIST, ACCENT_SWATCHES } from "../../shared/theme-presets"; import { useAppStore, type Account, type SettingsTab } from "../store"; import { reconfigurePostHog, trackEvent } from "../services/posthog"; import { SplitConfigEditor } from "./SplitConfigEditor"; @@ -51,6 +53,8 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { setUndoSendDelay, currentAccountId, highlightMemoryIds, + appearance, + setAppearance, } = useAppStore(); const [isAddingAccount, setIsAddingAccount] = useState(false); const [addAccountPhase, setAddAccountPhase] = useState("Connecting..."); @@ -353,6 +357,18 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { } }; + // Update appearance — applies locally + persists via IPC + const updateAppearance = async (patch: Partial) => { + const updated = { ...appearance, ...patch }; + setAppearance(updated); + await window.api.appearance.set(updated); + }; + + // Live preview only (no IPC) — used during color picker drag + const previewAppearance = (patch: Partial) => { + setAppearance({ ...appearance, ...patch }); + }; + const handleDensityChange = async (density: InboxDensity) => { setInboxDensity(density); await window.api.settings.set({ inboxDensity: density }); @@ -852,48 +868,153 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { Configure how Exo generates draft replies.

- {/* Appearance / Theme Toggle */} -
-
-

Appearance

-

- Choose your preferred color theme. + {/* Appearance */} +

+ {/* Light / Dark / System toggle */} +
+

Mode

+
+ {(["light", "dark", "system"] as const).map((mode) => ( + + ))} +
+
+ + {/* Theme presets */} +
+

Theme

+

+ Color palettes for surfaces and accents.

+
+ {THEME_PRESET_LIST.map((preset) => ( + + ))} +
-
- - - + + {/* Accent color picker */} +
+

+ Accent Color +

+

+ Override the theme accent with a custom color. +

+
+ {/* "Auto" = use preset default */} + + + {/* Preset accent swatches */} + {ACCENT_SWATCHES.map((swatch) => ( +
@@ -1404,7 +1525,21 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { {accountError && (
- {accountError} +

{accountError}

+ {accountError.includes("Access denied") && ( +

+ Add your email as a test user in{" "} + + Google Cloud Console → Audience + + , then try again. +

+ )}
)} diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts new file mode 100644 index 00000000..32136340 --- /dev/null +++ b/src/renderer/hooks/useAppearance.ts @@ -0,0 +1,88 @@ +import { useEffect } from "react"; +import { useAppStore } from "../store"; +import { THEME_PRESETS } from "../../shared/theme-presets"; +import { AppearanceConfigSchema, type AppearanceConfig } from "../../shared/types"; +import type { ThemeColors } from "../../shared/theme-presets"; + +// Convert "#2563eb" → "37 99 235", fallback to blue if invalid +function hexToRgbTriplet(hex: string): string { + const h = hex.replace(/^#/, ""); + const valid = /^[0-9a-fA-F]{6}$/.test(h); + const n = parseInt(valid ? h : "2563eb", 16); + return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`; +} + +// Lighten or darken an RGB triplet by a factor (-1 to 1) +function adjustBrightness(triplet: string, factor: number): string { + const [r, g, b] = triplet.split(" ").map(Number); + const adjust = (c: number) => + Math.min(255, Math.max(0, Math.round(factor > 0 ? c + (255 - c) * factor : c * (1 + factor)))); + return `${adjust(r)} ${adjust(g)} ${adjust(b)}`; +} + +// Apply the full set of CSS variables to +function applyThemeVariables(appearance: AppearanceConfig, isDark: boolean): void { + const preset = THEME_PRESETS[appearance.themePreset] ?? THEME_PRESETS.default; + const colors: ThemeColors = isDark ? preset.dark : preset.light; + const root = document.documentElement; + + // Surface & text colors from preset + root.style.setProperty("--bg-base", colors.bgBase); + root.style.setProperty("--bg-surface", colors.bgSurface); + root.style.setProperty("--bg-elevated", colors.bgElevated); + root.style.setProperty("--border-default", colors.borderDefault); + root.style.setProperty("--text-primary", colors.textPrimary); + root.style.setProperty("--text-secondary", colors.textSecondary); + + // Accent — custom color overrides the preset + if (appearance.accentColor) { + const rgb = hexToRgbTriplet(appearance.accentColor); + root.style.setProperty("--accent", rgb); + root.style.setProperty("--accent-hover", adjustBrightness(rgb, isDark ? 0.25 : -0.15)); + root.style.setProperty("--accent-soft", adjustBrightness(rgb, isDark ? -0.6 : 0.7)); + } else { + root.style.setProperty("--accent", colors.accent); + root.style.setProperty("--accent-hover", colors.accentHover); + root.style.setProperty("--accent-soft", colors.accentSoft); + } +} + +/** + * Reads appearance config from the store, applies CSS variables to , + * and listens for changes from the main process. + */ +export function useAppearance(): void { + const appearance = useAppStore((s) => s.appearance); + const setAppearance = useAppStore((s) => s.setAppearance); + const resolvedTheme = useAppStore((s) => s.resolvedTheme); + + // Fetch persisted config on mount + useEffect(() => { + window.api.appearance + .get() + .then((result: { success: boolean; data?: unknown }) => { + if (result.success && result.data) { + const parsed = AppearanceConfigSchema.safeParse(result.data); + if (parsed.success) setAppearance(parsed.data); + } + }) + .catch(() => { + // Appearance fetch failed — keep defaults + }); + + // Listen for changes broadcast from main process (e.g. from another window) + window.api.appearance.onChange((data: Record) => { + const parsed = AppearanceConfigSchema.safeParse(data); + if (parsed.success) setAppearance(parsed.data); + }); + + return () => { + window.api.appearance.removeAllListeners(); + }; + }, [setAppearance]); + + // Re-apply CSS variables whenever appearance config or resolved theme changes + useEffect(() => { + applyThemeVariables(appearance, resolvedTheme === "dark"); + }, [appearance, resolvedTheme]); +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7d29c875..2bde956c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; import { clearPendingLabelUpdates } from "../hooks-bridge"; import { applyOptimisticReads, addOptimisticReads } from "../optimistic-reads"; import type { + AppearanceConfig, DashboardEmail, ComposeMode, OutboxStats, @@ -248,6 +249,9 @@ interface AppState { themePreference: ThemePreference; resolvedTheme: "light" | "dark"; + // Appearance customization + appearance: AppearanceConfig; + // Inbox density state inboxDensity: InboxDensity; @@ -420,6 +424,7 @@ interface AppState { // Theme actions setThemePreference: (preference: ThemePreference) => void; setResolvedTheme: (theme: "light" | "dark") => void; + setAppearance: (config: AppearanceConfig) => void; // Inbox density actions setInboxDensity: (density: InboxDensity) => void; @@ -599,6 +604,12 @@ export const useAppStore = create((set, get) => ({ themePreference: "system", resolvedTheme: "light", + // Appearance defaults + appearance: { + themePreset: "default", + accentColor: null, + }, + // Inbox density state inboxDensity: "compact", @@ -1040,6 +1051,7 @@ export const useAppStore = create((set, get) => ({ // Theme actions setThemePreference: (preference) => set({ themePreference: preference }), setResolvedTheme: (theme) => set({ resolvedTheme: theme }), + setAppearance: (config) => set({ appearance: config }), // Inbox density actions setInboxDensity: (density) => set({ inboxDensity: density }), diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index 8c18eb11..bedba9d9 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -2,7 +2,183 @@ @tailwind components; @tailwind utilities; -/* Custom scrollbar styles */ +/* ============================================================ + Theme variable system + + CSS custom properties set by useAppearance hook at runtime. + Default values here match the current Tailwind gray/blue palette + so the app looks identical without any appearance config. + ============================================================ */ + +@layer base { + :root { + /* Surface colors (space-separated RGB for use with rgb() / rgba()) */ + --bg-base: 243 244 246; + --bg-surface: 255 255 255; + --bg-elevated: 255 255 255; + --border-default: 229 231 235; + + /* Text */ + --text-primary: 17 24 39; + --text-secondary: 75 85 99; + + /* Accent (interactive elements) */ + --accent: 37 99 235; + --accent-hover: 29 78 216; + --accent-soft: 219 234 254; + } + + .dark { + --bg-base: 17 24 39; + --bg-surface: 31 41 55; + --bg-elevated: 55 65 81; + --border-default: 55 65 81; + --text-primary: 243 244 246; + --text-secondary: 156 163 175; + --accent: 59 130 246; + --accent-hover: 96 165 250; + --accent-soft: 30 58 138; + } +} + + +/* --- Surface color overrides --- + Re-skin Tailwind's hardcoded grays so theme presets work + without touching any component files. */ + +/* bg-gray-100 → base in light, elevated in dark (matches dark:bg-gray-700 intent) */ +.bg-gray-100 { + background-color: rgb(var(--bg-base)) !important; +} +.dark .bg-gray-100 { + background-color: rgb(var(--bg-elevated)) !important; +} + +/* dark:bg-gray-900 (dark base) */ +.dark .bg-gray-900 { + background-color: rgb(var(--bg-base)) !important; +} + +/* bg-white (light surface — sidebar, cards, titlebar) */ +.bg-white { + background-color: rgb(var(--bg-surface)) !important; +} + +/* dark:bg-gray-800 (dark surface) */ +.dark .bg-gray-800 { + background-color: rgb(var(--bg-surface)) !important; +} + +/* dark:bg-gray-700 (dark elevated — dropdowns, inputs) */ +.dark .bg-gray-700 { + background-color: rgb(var(--bg-elevated)) !important; +} + +/* border overrides */ +.border-gray-200 { + border-color: rgb(var(--border-default)) !important; +} +.dark .border-gray-700, +.dark .border-gray-600 { + border-color: rgb(var(--border-default)) !important; +} +.divide-gray-200 > :not([hidden]) ~ :not([hidden]) { + border-color: rgb(var(--border-default)) !important; +} +.dark .divide-gray-700 > :not([hidden]) ~ :not([hidden]) { + border-color: rgb(var(--border-default)) !important; +} + +/* --- Accent color overrides --- + Re-skin Tailwind's hardcoded blues for interactive elements. */ + +/* Primary accent backgrounds */ +.bg-blue-600 { + background-color: rgb(var(--accent)) !important; +} +.dark .bg-blue-500 { + background-color: rgb(var(--accent)) !important; +} +.hover\:bg-blue-700:hover { + background-color: rgb(var(--accent-hover)) !important; +} +.dark .hover\:bg-blue-600:hover, +.dark .dark\:hover\:bg-blue-600:hover { + background-color: rgb(var(--accent-hover)) !important; +} + +/* Soft accent backgrounds */ +.bg-blue-100 { + background-color: rgb(var(--accent-soft) / 0.3) !important; +} +.bg-blue-50 { + background-color: rgb(var(--accent-soft) / 0.15) !important; +} +.dark .bg-blue-900\/30, +.dark .bg-blue-900\/40, +.dark .bg-blue-900\/60, +.dark .bg-blue-900\/20 { + background-color: rgb(var(--accent-soft) / 0.3) !important; +} +.hover\:bg-blue-100:hover { + background-color: rgb(var(--accent-soft) / 0.4) !important; +} +.dark .hover\:bg-blue-900\/40:hover, +.dark .dark\:hover\:bg-blue-900\/40:hover { + background-color: rgb(var(--accent-soft) / 0.4) !important; +} +.dark .hover\:bg-blue-900\/50:hover, +.dark .dark\:hover\:bg-blue-900\/50:hover { + background-color: rgb(var(--accent-soft) / 0.5) !important; +} + +/* Accent text */ +.text-blue-600 { + color: rgb(var(--accent)) !important; +} +.dark .text-blue-400, +.dark .dark\:text-blue-400 { + color: rgb(var(--accent-hover)) !important; +} +.text-blue-700 { + color: rgb(var(--accent)) !important; +} +.dark .text-blue-300, +.dark .dark\:text-blue-300 { + color: rgb(var(--accent-hover)) !important; +} +.text-blue-800 { + color: rgb(var(--accent)) !important; +} +.dark .text-blue-200, +.dark .dark\:text-blue-200 { + color: rgb(var(--accent-hover)) !important; +} +.hover\:text-blue-500:hover, +.hover\:text-blue-800:hover { + color: rgb(var(--accent-hover)) !important; +} + +/* Accent borders & rings */ +.border-blue-500, +.border-blue-600 { + border-color: rgb(var(--accent)) !important; +} +.dark .border-blue-500, +.dark .dark\:border-blue-500 { + border-color: rgb(var(--accent)) !important; +} +.ring-blue-500, +.focus\:ring-blue-500:focus { + --tw-ring-color: rgb(var(--accent)) !important; +} + +/* Checkbox/toggle accent */ +.peer-checked\:bg-blue-600 { + background-color: rgb(var(--accent)) !important; +} + +/* Scrollbar */ ::-webkit-scrollbar { width: 8px; height: 8px; @@ -13,21 +189,12 @@ } ::-webkit-scrollbar-thumb { - background: #cbd5e1; border-radius: 4px; + background: rgb(var(--text-secondary) / 0.3) !important; } ::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} - -/* Dark mode scrollbar */ -.dark ::-webkit-scrollbar-thumb { - background: #4b5563; -} - -.dark ::-webkit-scrollbar-thumb:hover { - background: #6b7280; + background: rgb(var(--text-secondary) / 0.5) !important; } /* Titlebar drag region */ diff --git a/src/shared/theme-presets.ts b/src/shared/theme-presets.ts new file mode 100644 index 00000000..40234820 --- /dev/null +++ b/src/shared/theme-presets.ts @@ -0,0 +1,191 @@ +import type { ThemePresetId } from "./types"; + +// All color values are space-separated RGB triplets for use with rgb() / rgba() +export interface ThemeColors { + bgBase: string; + bgSurface: string; + bgElevated: string; + borderDefault: string; + textPrimary: string; + textSecondary: string; + accent: string; + accentHover: string; + accentSoft: string; +} + +export interface ThemePreset { + id: ThemePresetId; + name: string; + light: ThemeColors; + dark: ThemeColors; + + // Preview colors for the settings UI swatch + preview: { surface: string; accent: string }; +} + +// Default — matches the current Tailwind gray/blue palette exactly +const DEFAULT_PRESET: ThemePreset = { + id: "default", + name: "Default", + preview: { surface: "#f3f4f6", accent: "#2563eb" }, + light: { + bgBase: "243 244 246", + bgSurface: "255 255 255", + bgElevated: "255 255 255", + borderDefault: "229 231 235", + textPrimary: "17 24 39", + textSecondary: "75 85 99", + accent: "37 99 235", + accentHover: "29 78 216", + accentSoft: "219 234 254", + }, + dark: { + bgBase: "17 24 39", + bgSurface: "31 41 55", + bgElevated: "55 65 81", + borderDefault: "55 65 81", + textPrimary: "243 244 246", + textSecondary: "156 163 175", + accent: "59 130 246", + accentHover: "96 165 250", + accentSoft: "30 58 138", + }, +}; + +// Midnight — deep navy surfaces, violet accent +const MIDNIGHT_PRESET: ThemePreset = { + id: "midnight", + name: "Midnight", + preview: { surface: "#0f172a", accent: "#8b5cf6" }, + light: { + bgBase: "241 245 249", + bgSurface: "248 250 252", + bgElevated: "255 255 255", + borderDefault: "226 232 240", + textPrimary: "15 23 42", + textSecondary: "71 85 105", + accent: "139 92 246", + accentHover: "124 58 237", + accentSoft: "237 233 254", + }, + dark: { + bgBase: "15 23 42", + bgSurface: "30 41 59", + bgElevated: "51 65 85", + borderDefault: "51 65 85", + textPrimary: "241 245 249", + textSecondary: "148 163 184", + accent: "139 92 246", + accentHover: "167 139 250", + accentSoft: "76 29 149", + }, +}; + +// Nord — polar night surfaces, frost blue accent +const NORD_PRESET: ThemePreset = { + id: "nord", + name: "Nord", + preview: { surface: "#2e3440", accent: "#88c0d0" }, + light: { + bgBase: "236 239 244", + bgSurface: "242 244 248", + bgElevated: "255 255 255", + borderDefault: "216 222 233", + textPrimary: "46 52 64", + textSecondary: "76 86 106", + accent: "94 129 172", + accentHover: "76 108 148", + accentSoft: "222 233 244", + }, + dark: { + bgBase: "46 52 64", + bgSurface: "59 66 82", + bgElevated: "67 76 94", + borderDefault: "67 76 94", + textPrimary: "236 239 244", + textSecondary: "216 222 233", + accent: "136 192 208", + accentHover: "143 188 187", + accentSoft: "59 80 97", + }, +}; + +// Solarized — warm base tones, yellow accent +const SOLARIZED_PRESET: ThemePreset = { + id: "solarized", + name: "Solarized", + preview: { surface: "#002b36", accent: "#b58900" }, + light: { + bgBase: "238 232 213", + bgSurface: "253 246 227", + bgElevated: "255 255 255", + borderDefault: "220 213 194", + textPrimary: "0 43 54", + textSecondary: "88 110 117", + accent: "181 137 0", + accentHover: "152 115 0", + accentSoft: "248 237 196", + }, + dark: { + bgBase: "0 43 54", + bgSurface: "7 54 66", + bgElevated: "29 78 89", + borderDefault: "29 78 89", + textPrimary: "238 232 213", + textSecondary: "147 161 161", + accent: "181 137 0", + accentHover: "209 166 41", + accentSoft: "60 52 12", + }, +}; + +// Rose — warm surfaces, pink accent +const ROSE_PRESET: ThemePreset = { + id: "rose", + name: "Rose", + preview: { surface: "#1c1017", accent: "#f43f5e" }, + light: { + bgBase: "255 241 242", + bgSurface: "255 255 255", + bgElevated: "255 255 255", + borderDefault: "252 231 233", + textPrimary: "28 16 23", + textSecondary: "113 63 79", + accent: "244 63 94", + accentHover: "225 29 72", + accentSoft: "255 228 230", + }, + dark: { + bgBase: "28 16 23", + bgSurface: "44 24 36", + bgElevated: "64 36 52", + borderDefault: "64 36 52", + textPrimary: "255 241 242", + textSecondary: "194 153 168", + accent: "251 113 133", + accentHover: "253 164 175", + accentSoft: "100 20 44", + }, +}; + +export const THEME_PRESETS: Record = { + default: DEFAULT_PRESET, + midnight: MIDNIGHT_PRESET, + nord: NORD_PRESET, + solarized: SOLARIZED_PRESET, + rose: ROSE_PRESET, +}; + +export const THEME_PRESET_LIST: ThemePreset[] = Object.values(THEME_PRESETS); + +// Accent color swatches for the picker UI +export const ACCENT_SWATCHES = [ + { name: "Blue", hex: "#2563eb", rgb: "37 99 235" }, + { name: "Violet", hex: "#7c3aed", rgb: "124 58 237" }, + { name: "Pink", hex: "#db2777", rgb: "219 39 119" }, + { name: "Rose", hex: "#f43f5e", rgb: "244 63 94" }, + { name: "Orange", hex: "#ea580c", rgb: "234 88 12" }, + { name: "Green", hex: "#16a34a", rgb: "22 163 74" }, + { name: "Teal", hex: "#0d9488", rgb: "13 148 136" }, + { name: "Cyan", hex: "#0891b2", rgb: "8 145 178" }, +] as const; diff --git a/src/shared/types.ts b/src/shared/types.ts index 5a21e9fe..120f8cb5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -358,6 +358,20 @@ export function resolveModelId(tier: ModelTier): string { return MODEL_TIER_IDS[tier]; } +// Appearance customization +export const ThemePresetSchema = z.enum(["default", "midnight", "nord", "solarized", "rose"]); +export type ThemePresetId = z.infer; + +export const AppearanceConfigSchema = z.object({ + themePreset: ThemePresetSchema.default("default"), + accentColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .default(null), +}); +export type AppearanceConfig = z.infer; + // Config schema export const ConfigSchema = z.object({ maxEmails: z.number().default(50), @@ -405,6 +419,7 @@ export const ConfigSchema = z.object({ gatewayToken: z.string().default(""), }) .optional(), + appearance: AppearanceConfigSchema.optional(), configVersion: z.number().optional(), });