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.
+
+
@@ -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(),
});