From 00b0278ecb09fed4d02fe9cb7cc0029dd5299c38 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 14 Dec 2025 03:01:00 -0600 Subject: [PATCH] feat: add custom theme with live preview and import/export Add a fully customizable theme option to the settings theme picker: - New CustomThemeBuilder component with mini UI preview showing all 13 colors - Initialize from any existing theme as a starting point - Color pickers for each theme property (bgMain, accent, textMain, etc.) - Export theme to JSON file / Import from JSON file - Custom theme persists across app restarts via useSettings - Tab key navigation cycles through all themes including custom Claude ID: fa8f6ebc-92e4-49e3-acfa-ef202e664cf0 Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35 --- .../components/SettingsModal.test.tsx | 18 + src/main/themes.ts | 21 + src/renderer/App.tsx | 17 +- .../components/CustomThemeBuilder.tsx | 518 ++++++++++++++++++ src/renderer/components/SettingsModal.tsx | 39 +- src/renderer/constants/themes.ts | 24 + src/renderer/hooks/useSettings.ts | 31 +- src/shared/theme-types.ts | 4 +- 8 files changed, 660 insertions(+), 12 deletions(-) create mode 100644 src/renderer/components/CustomThemeBuilder.tsx diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index 96454c82..548e9a3a 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -41,6 +41,17 @@ vi.mock('../../../renderer/components/AICommandsPanel', () => ({ ), })); +// Mock CustomThemeBuilder +vi.mock('../../../renderer/components/CustomThemeBuilder', () => ({ + CustomThemeBuilder: ({ isSelected, onSelect }: { isSelected: boolean; onSelect: () => void }) => ( +
+ +
+ ), +})); + // Sample theme for testing const mockTheme: Theme = { id: 'dracula', @@ -55,6 +66,7 @@ const mockTheme: Theme = { textDim: '#6272a4', accent: '#bd93f9', accentDim: '#bd93f920', + accentText: '#ff79c6', accentForeground: '#ffffff', success: '#50fa7b', warning: '#ffb86c', @@ -75,6 +87,7 @@ const mockLightTheme: Theme = { textDim: '#586069', accent: '#0366d6', accentDim: '#0366d620', + accentText: '#0366d6', accentForeground: '#ffffff', success: '#28a745', warning: '#f59e0b', @@ -95,6 +108,7 @@ const mockVibeTheme: Theme = { textDim: '#a8a8a8', accent: '#e94560', accentDim: '#e9456020', + accentText: '#ff8dc7', accentForeground: '#ffffff', success: '#50fa7b', warning: '#ffb86c', @@ -121,6 +135,10 @@ const createDefaultProps = (overrides = {}) => ({ themes: mockThemes, activeThemeId: 'dracula', setActiveThemeId: vi.fn(), + customThemeColors: mockTheme.colors, + setCustomThemeColors: vi.fn(), + customThemeBaseId: 'dracula' as const, + setCustomThemeBaseId: vi.fn(), llmProvider: 'openrouter', setLlmProvider: vi.fn(), modelSlug: '', diff --git a/src/main/themes.ts b/src/main/themes.ts index 5faedffc..3356b0fa 100644 --- a/src/main/themes.ts +++ b/src/main/themes.ts @@ -330,6 +330,27 @@ export const THEMES: Record = { warning: '#cc0033', error: '#cc0033' } + }, + // Custom theme - user-configurable, defaults to Dracula + custom: { + id: 'custom', + name: 'Custom', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + border: '#44475a', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: 'rgba(189, 147, 249, 0.2)', + accentText: '#ff79c6', + accentForeground: '#282a36', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555' + } } }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f071a473..b5a2cc46 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -154,6 +154,8 @@ export default function MaestroConsole() { fontFamily, setFontFamily, fontSize, setFontSize, activeThemeId, setActiveThemeId, + customThemeColors, setCustomThemeColors, + customThemeBaseId, setCustomThemeBaseId, enterToSendAI, setEnterToSendAI, enterToSendTerminal, setEnterToSendTerminal, defaultSaveToHistory, setDefaultSaveToHistory, @@ -1578,7 +1580,16 @@ export default function MaestroConsole() { sessions.find(s => s.id === activeSessionId) || sessions[0] || null, [sessions, activeSessionId] ); - const theme = THEMES[activeThemeId]; + // Use custom colors when custom theme is selected, otherwise use the standard theme + const theme = useMemo(() => { + if (activeThemeId === 'custom') { + return { + ...THEMES.custom, + colors: customThemeColors + }; + } + return THEMES[activeThemeId]; + }, [activeThemeId, customThemeColors]); // Memoized cwd for git viewers (prevents re-renders from inline computation) const gitViewerCwd = useMemo(() => @@ -5449,6 +5460,10 @@ export default function MaestroConsole() { themes={THEMES} activeThemeId={activeThemeId} setActiveThemeId={setActiveThemeId} + customThemeColors={customThemeColors} + setCustomThemeColors={setCustomThemeColors} + customThemeBaseId={customThemeBaseId} + setCustomThemeBaseId={setCustomThemeBaseId} llmProvider={llmProvider} setLlmProvider={setLlmProvider} modelSlug={modelSlug} diff --git a/src/renderer/components/CustomThemeBuilder.tsx b/src/renderer/components/CustomThemeBuilder.tsx new file mode 100644 index 00000000..b4220e2c --- /dev/null +++ b/src/renderer/components/CustomThemeBuilder.tsx @@ -0,0 +1,518 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { Palette, Download, Upload, RotateCcw, Check, ChevronDown } from 'lucide-react'; +import type { Theme, ThemeColors, ThemeId } from '../types'; +import { THEMES, DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes'; + +interface CustomThemeBuilderProps { + theme: Theme; // Current active theme for styling the builder + customThemeColors: ThemeColors; + setCustomThemeColors: (colors: ThemeColors) => void; + customThemeBaseId: ThemeId; + setCustomThemeBaseId: (id: ThemeId) => void; + isSelected: boolean; + onSelect: () => void; +} + +// Color picker labels with descriptions +const COLOR_CONFIG: { key: keyof ThemeColors; label: string; description: string }[] = [ + { key: 'bgMain', label: 'Main Background', description: 'Primary content area' }, + { key: 'bgSidebar', label: 'Sidebar Background', description: 'Left & right panels' }, + { key: 'bgActivity', label: 'Activity Background', description: 'Hover, active states' }, + { key: 'border', label: 'Border', description: 'Dividers & outlines' }, + { key: 'textMain', label: 'Main Text', description: 'Primary text color' }, + { key: 'textDim', label: 'Dimmed Text', description: 'Secondary text' }, + { key: 'accent', label: 'Accent', description: 'Highlights, links' }, + { key: 'accentDim', label: 'Accent Dim', description: 'Accent with transparency' }, + { key: 'accentText', label: 'Accent Text', description: 'Text in accent contexts' }, + { key: 'accentForeground', label: 'Accent Foreground', description: 'Text ON accent' }, + { key: 'success', label: 'Success', description: 'Green states' }, + { key: 'warning', label: 'Warning', description: 'Yellow/orange states' }, + { key: 'error', label: 'Error', description: 'Red states' }, +]; + +// Mini UI Preview component +function MiniUIPreview({ colors }: { colors: ThemeColors }) { + return ( +
+ {/* Mini UI layout */} +
+ {/* Left sidebar */} +
+ {/* Session items */} +
+ S1 +
+
+ S2 +
+
+ S3 +
+
+ + {/* Main content */} +
+ {/* Header bar */} +
+ + AI Terminal + +
+ + {/* Chat area */} +
+ {/* User message */} +
+
+ User message +
+
+ {/* AI response */} +
+
+ AI response here +
+
+ {/* Status indicators */} +
+ ready + busy + error +
+
+ + {/* Input area */} +
+
+ Type a message... +
+
+ ↵ +
+
+
+ + {/* Right panel */} +
+
+ Files +
+
+
src/
+
app.tsx
+
index.ts
+
+
+
+
+ ); +} + +// Color input with label +function ColorInput({ + colorKey, + label, + description, + value, + onChange, + theme +}: { + colorKey: keyof ThemeColors; + label: string; + description: string; + value: string; + onChange: (key: keyof ThemeColors, value: string) => void; + theme: Theme; +}) { + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + + // Handle rgba/hsla by showing the color picker for the base color + const isComplexColor = value.includes('rgba') || value.includes('hsla'); + const displayValue = isComplexColor ? value : value; + + return ( +
+
+ onChange(colorKey, e.target.value)} + className="w-8 h-8 rounded cursor-pointer border-2" + style={{ borderColor: theme.colors.border }} + title={label} + /> + {isComplexColor && ( +
+ α +
+ )} +
+
+
+ {label} +
+
+ {description} +
+
+
+ {isEditing ? ( + onChange(colorKey, e.target.value)} + onBlur={() => setIsEditing(false)} + onKeyDown={(e) => e.key === 'Enter' && setIsEditing(false)} + className="w-32 px-1.5 py-0.5 rounded text-xs font-mono border" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: theme.colors.border, + color: theme.colors.textMain + }} + autoFocus + /> + ) : ( + + )} +
+
+ ); +} + +export function CustomThemeBuilder({ + theme, + customThemeColors, + setCustomThemeColors, + customThemeBaseId, + setCustomThemeBaseId, + isSelected, + onSelect +}: CustomThemeBuilderProps) { + const [showBaseSelector, setShowBaseSelector] = useState(false); + const fileInputRef = useRef(null); + + // Get all themes except 'custom' for base selection + const baseThemes = Object.values(THEMES).filter(t => t.id !== 'custom'); + + const handleColorChange = useCallback((key: keyof ThemeColors, value: string) => { + setCustomThemeColors({ + ...customThemeColors, + [key]: value + }); + }, [customThemeColors, setCustomThemeColors]); + + const handleInitializeFromBase = useCallback((baseId: ThemeId) => { + const baseTheme = THEMES[baseId]; + if (baseTheme) { + setCustomThemeColors({ ...baseTheme.colors }); + setCustomThemeBaseId(baseId); + } + setShowBaseSelector(false); + }, [setCustomThemeColors, setCustomThemeBaseId]); + + const handleReset = useCallback(() => { + setCustomThemeColors({ ...DEFAULT_CUSTOM_THEME_COLORS }); + setCustomThemeBaseId('dracula'); + }, [setCustomThemeColors, setCustomThemeBaseId]); + + const handleExport = useCallback(() => { + const exportData = { + name: 'Custom Theme', + baseTheme: customThemeBaseId, + colors: customThemeColors, + exportedAt: new Date().toISOString() + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'maestro-custom-theme.json'; + a.click(); + URL.revokeObjectURL(url); + }, [customThemeColors, customThemeBaseId]); + + const handleImport = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target?.result as string); + if (data.colors && typeof data.colors === 'object') { + // Validate all required color keys exist + const requiredKeys = COLOR_CONFIG.map(c => c.key); + const hasAllKeys = requiredKeys.every(key => key in data.colors); + + if (hasAllKeys) { + setCustomThemeColors(data.colors); + if (data.baseTheme && THEMES[data.baseTheme as ThemeId]) { + setCustomThemeBaseId(data.baseTheme); + } + } else { + console.error('Invalid theme file: missing required color keys'); + } + } + } catch (err) { + console.error('Failed to parse theme file:', err); + } + }; + reader.readAsText(file); + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [setCustomThemeColors, setCustomThemeBaseId]); + + return ( +
+ {/* Custom Theme Header */} +
+ + Custom Theme +
+ + {/* Theme Selection Button + Controls */} +
+ {/* Clickable Header to Select Theme */} + + + {/* Builder Controls (always visible but styled differently when not selected) */} +
+ {/* Mini Preview */} +
+
+ Preview +
+ +
+ + {/* Action Buttons */} +
+ {/* Initialize From Base Theme */} +
+ + + {showBaseSelector && ( +
+ {baseThemes.map(t => ( + + ))} +
+ )} +
+ + {/* Export */} + + + {/* Import */} + + + + {/* Reset */} + +
+ + {/* Color Editors */} +
+ Colors +
+
+ {COLOR_CONFIG.map(({ key, label, description }) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 0419c542..bccf6b99 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef, memo } from 'react'; import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download } from 'lucide-react'; -import type { AgentConfig, Theme, Shortcut, ShellInfo, CustomAICommand } from '../types'; +import type { AgentConfig, Theme, ThemeColors, ThemeId, Shortcut, ShellInfo, CustomAICommand } from '../types'; +import { CustomThemeBuilder } from './CustomThemeBuilder'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { AICommandsPanel } from './AICommandsPanel'; @@ -23,6 +24,10 @@ interface SettingsModalProps { themes: Record; activeThemeId: string; setActiveThemeId: (id: string) => void; + customThemeColors: ThemeColors; + setCustomThemeColors: (colors: ThemeColors) => void; + customThemeBaseId: ThemeId; + setCustomThemeBaseId: (id: ThemeId) => void; llmProvider: string; setLlmProvider: (provider: string) => void; modelSlug: string; @@ -431,8 +436,9 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro if (!isOpen) return null; - // Group themes by mode for the ThemePicker + // Group themes by mode for the ThemePicker (exclude 'custom' theme - it's handled separately) const groupedThemes = Object.values(themes).reduce((acc: Record, t: Theme) => { + if (t.id === 'custom') return acc; // Skip custom theme in regular grouping if (!acc[t.mode]) acc[t.mode] = []; acc[t.mode].push(t); return acc; @@ -442,21 +448,23 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro if (e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); - // Create ordered array: dark themes first, then light, then vibe (cycling back to dark) + // Create ordered array: dark themes first, then light, then vibe, then custom (cycling back to dark) const allThemes = [...(groupedThemes['dark'] || []), ...(groupedThemes['light'] || []), ...(groupedThemes['vibe'] || [])]; - const currentIndex = allThemes.findIndex((t: Theme) => t.id === props.activeThemeId); + // Add 'custom' as the last item in the cycle + const allThemeIds = [...allThemes.map(t => t.id), 'custom']; + const currentIndex = allThemeIds.findIndex((id: string) => id === props.activeThemeId); let newThemeId: string; if (e.shiftKey) { // Shift+Tab: go backwards - const prevIndex = currentIndex === 0 ? allThemes.length - 1 : currentIndex - 1; - newThemeId = allThemes[prevIndex].id; + const prevIndex = currentIndex === 0 ? allThemeIds.length - 1 : currentIndex - 1; + newThemeId = allThemeIds[prevIndex]; } else { // Tab: go forward - const nextIndex = (currentIndex + 1) % allThemes.length; - newThemeId = allThemes[nextIndex].id; + const nextIndex = (currentIndex + 1) % allThemeIds.length; + newThemeId = allThemeIds[nextIndex]; } - props.setActiveThemeId(newThemeId); + props.setActiveThemeId(newThemeId as ThemeId); // Scroll the newly selected theme button into view setTimeout(() => { @@ -508,6 +516,19 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro ))} + + {/* Custom Theme Builder */} +
+ props.setActiveThemeId('custom')} + /> +
); diff --git a/src/renderer/constants/themes.ts b/src/renderer/constants/themes.ts index 2c331154..80b112b8 100644 --- a/src/renderer/constants/themes.ts +++ b/src/renderer/constants/themes.ts @@ -323,5 +323,29 @@ export const THEMES: Record = { warning: '#cc0033', error: '#cc0033' } + }, + // Custom theme - user-configurable, defaults to Dracula + custom: { + id: 'custom', + name: 'Custom', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + border: '#44475a', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: 'rgba(189, 147, 249, 0.2)', + accentText: '#ff79c6', + accentForeground: '#282a36', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555' + } } }; + +// Default custom theme colors (Dracula-based) +export const DEFAULT_CUSTOM_THEME_COLORS = THEMES.dracula.colors; diff --git a/src/renderer/hooks/useSettings.ts b/src/renderer/hooks/useSettings.ts index a41d5ecb..1370540c 100644 --- a/src/renderer/hooks/useSettings.ts +++ b/src/renderer/hooks/useSettings.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; -import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats, LeaderboardRegistration } from '../types'; +import type { LLMProvider, ThemeId, ThemeColors, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats, LeaderboardRegistration } from '../types'; +import { DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes'; import { DEFAULT_SHORTCUTS } from '../constants/shortcuts'; // Default global stats @@ -114,6 +115,10 @@ export interface UseSettingsReturn { // UI settings activeThemeId: ThemeId; setActiveThemeId: (value: ThemeId) => void; + customThemeColors: ThemeColors; + setCustomThemeColors: (value: ThemeColors) => void; + customThemeBaseId: ThemeId; + setCustomThemeBaseId: (value: ThemeId) => void; enterToSendAI: boolean; setEnterToSendAI: (value: boolean) => void; enterToSendTerminal: boolean; @@ -242,6 +247,8 @@ export function useSettings(): UseSettingsReturn { // UI Config const [activeThemeId, setActiveThemeIdState] = useState('dracula'); + const [customThemeColors, setCustomThemeColorsState] = useState(DEFAULT_CUSTOM_THEME_COLORS); + const [customThemeBaseId, setCustomThemeBaseIdState] = useState('dracula'); const [enterToSendAI, setEnterToSendAIState] = useState(false); // AI mode defaults to Command+Enter const [enterToSendTerminal, setEnterToSendTerminalState] = useState(true); // Terminal defaults to Enter const [defaultSaveToHistory, setDefaultSaveToHistoryState] = useState(true); // History toggle defaults to on @@ -350,6 +357,16 @@ export function useSettings(): UseSettingsReturn { window.maestro.settings.set('activeThemeId', value); }, []); + const setCustomThemeColors = useCallback((value: ThemeColors) => { + setCustomThemeColorsState(value); + window.maestro.settings.set('customThemeColors', value); + }, []); + + const setCustomThemeBaseId = useCallback((value: ThemeId) => { + setCustomThemeBaseIdState(value); + window.maestro.settings.set('customThemeBaseId', value); + }, []); + const setEnterToSendAI = useCallback((value: boolean) => { setEnterToSendAIState(value); window.maestro.settings.set('enterToSendAI', value); @@ -844,6 +861,8 @@ export function useSettings(): UseSettingsReturn { const savedShowHiddenFiles = await window.maestro.settings.get('showHiddenFiles'); const savedShortcuts = await window.maestro.settings.get('shortcuts'); const savedActiveThemeId = await window.maestro.settings.get('activeThemeId'); + const savedCustomThemeColors = await window.maestro.settings.get('customThemeColors'); + const savedCustomThemeBaseId = await window.maestro.settings.get('customThemeBaseId'); const savedTerminalWidth = await window.maestro.settings.get('terminalWidth'); const savedLogLevel = await window.maestro.logger.getLogLevel(); const savedMaxLogBuffer = await window.maestro.logger.getMaxLogBuffer(); @@ -882,6 +901,8 @@ export function useSettings(): UseSettingsReturn { if (savedMarkdownEditMode !== undefined) setMarkdownEditModeState(savedMarkdownEditMode); if (savedShowHiddenFiles !== undefined) setShowHiddenFilesState(savedShowHiddenFiles); if (savedActiveThemeId !== undefined) setActiveThemeIdState(savedActiveThemeId); + if (savedCustomThemeColors !== undefined) setCustomThemeColorsState(savedCustomThemeColors); + if (savedCustomThemeBaseId !== undefined) setCustomThemeBaseIdState(savedCustomThemeBaseId); if (savedTerminalWidth !== undefined) setTerminalWidthState(savedTerminalWidth); if (savedLogLevel !== undefined) setLogLevelState(savedLogLevel); if (savedMaxLogBuffer !== undefined) setMaxLogBufferState(savedMaxLogBuffer); @@ -1035,6 +1056,10 @@ export function useSettings(): UseSettingsReturn { setCustomFonts, activeThemeId, setActiveThemeId, + customThemeColors, + setCustomThemeColors, + customThemeBaseId, + setCustomThemeBaseId, enterToSendAI, setEnterToSendAI, enterToSendTerminal, @@ -1116,6 +1141,8 @@ export function useSettings(): UseSettingsReturn { fontSize, customFonts, activeThemeId, + customThemeColors, + customThemeBaseId, enterToSendAI, enterToSendTerminal, defaultSaveToHistory, @@ -1153,6 +1180,8 @@ export function useSettings(): UseSettingsReturn { setFontSize, setCustomFonts, setActiveThemeId, + setCustomThemeColors, + setCustomThemeBaseId, setEnterToSendAI, setEnterToSendTerminal, setDefaultSaveToHistory, diff --git a/src/shared/theme-types.ts b/src/shared/theme-types.ts index 726fd8e9..adf2bbdc 100644 --- a/src/shared/theme-types.ts +++ b/src/shared/theme-types.ts @@ -28,7 +28,8 @@ export type ThemeId = | 'pedurple' | 'maestros-choice' | 'dre-synth' - | 'inquest'; + | 'inquest' + | 'custom'; /** * Theme mode indicating the overall brightness/style @@ -103,6 +104,7 @@ export function isValidThemeId(id: string): id is ThemeId { 'maestros-choice', 'dre-synth', 'inquest', + 'custom', ]; return validIds.includes(id as ThemeId); }