diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 1d8d04f..3519a3f 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -51,6 +51,8 @@ import { } from '@shared/domain/runTimeline'; import { createSessionToolingSelection, + normalizeTheme, + type AppearanceTheme, type LspProfileDefinition, type McpServerDefinition, normalizeLspProfileDefinition, @@ -201,6 +203,12 @@ export class EryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async setTheme(theme: AppearanceTheme): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.theme = normalizeTheme(theme); + return this.persistAndBroadcast(workspace); + } + async deletePattern(patternId: string): Promise { if (isBuiltinPattern(patternId)) { throw new Error('Built-in patterns cannot be deleted.'); diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index ab3317c..4447443 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -16,6 +16,7 @@ import type { UpdateScratchpadSessionConfigInput, } from '@shared/contracts/ipc'; import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; +import type { AppearanceTheme } from '@shared/domain/tooling'; import { EryxAppService } from '@main/EryxAppService'; @@ -33,6 +34,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) => service.setPatternFavorite(input.patternId, input.isFavorite), ); + ipcMain.handle(ipcChannels.setTheme, (_event, theme: AppearanceTheme) => + service.setTheme(theme), + ); ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) => service.saveMcpServer(input.server), ); diff --git a/src/preload/index.ts b/src/preload/index.ts index 27ea71d..98cc286 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,7 @@ const api: ElectronApi = { savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input), deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId), setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input), + setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme), saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input), deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId), saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f218fed..929a6b8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -24,7 +24,7 @@ import { import type { PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; import { applyScratchpadSessionConfig } from '@shared/domain/session'; -import type { LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; +import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; import { createId, nowIso } from '@shared/utils/ids'; @@ -131,6 +131,27 @@ export default function App() { }; }, [api]); + // Apply theme to the document root + const themeSetting: AppearanceTheme = workspace?.settings.theme ?? 'dark'; + useEffect(() => { + function resolveEffective(pref: AppearanceTheme): 'dark' | 'light' { + if (pref === 'dark' || pref === 'light') return pref; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + const apply = () => { + document.documentElement.dataset.theme = resolveEffective(themeSetting); + }; + + apply(); + + if (themeSetting === 'system') { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener('change', apply); + return () => mq.removeEventListener('change', apply); + } + }, [themeSetting]); + // Derived state const selectedSession = useMemo( () => workspace?.sessions.find((s) => s.id === workspace.selectedSessionId), @@ -309,8 +330,10 @@ export default function App() { onSavePattern={async (pattern) => { await api.savePattern({ pattern }); }} + onSetTheme={(theme) => void api.setTheme(theme)} patterns={workspace.patterns} sidecarCapabilities={sidecarCapabilities} + theme={workspace.settings.theme} toolingSettings={workspace.settings.tooling} /> ) : null; diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 2ad39fa..d9e4e37 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useState, type HTMLAttributes, type ReactNode } from 'react'; -import { AlertCircle, ChevronLeft, ChevronRight, Code, Cpu, Info, Plus, Server, Trash, Workflow } from 'lucide-react'; +import { AlertCircle, ChevronLeft, ChevronRight, Code, Cpu, Info, Palette, Plus, Server, Trash, Workflow } from 'lucide-react'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { PatternEditor } from '@renderer/components/PatternEditor'; @@ -9,6 +9,7 @@ import type { PatternDefinition } from '@shared/domain/pattern'; import { normalizeLspProfileDefinition, normalizeMcpServerDefinition, + type AppearanceTheme, type LspProfileDefinition, type McpServerDefinition, type WorkspaceToolingSettings, @@ -21,6 +22,7 @@ interface SettingsPanelProps { availableModels: ReadonlyArray; patterns: PatternDefinition[]; sidecarCapabilities?: SidecarCapabilities; + theme: AppearanceTheme; toolingSettings: WorkspaceToolingSettings; isRefreshingCapabilities: boolean; onRefreshCapabilities: () => void; @@ -34,9 +36,10 @@ interface SettingsPanelProps { onSaveLspProfile: (profile: LspProfileDefinition) => Promise; onDeleteLspProfile: (profileId: string) => Promise; onNewLspProfile: () => LspProfileDefinition; + onSetTheme: (theme: AppearanceTheme) => void; } -type SettingsSection = 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles'; +type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles'; interface NavItem { id: SettingsSection; @@ -50,6 +53,12 @@ interface NavGroup { } const navGroups: NavGroup[] = [ + { + label: 'General', + items: [ + { id: 'appearance', label: 'Appearance', icon: }, + ], + }, { label: 'AI Provider', items: [ @@ -80,6 +89,7 @@ export function SettingsPanel({ availableModels, patterns, sidecarCapabilities, + theme, toolingSettings, isRefreshingCapabilities, onRefreshCapabilities, @@ -93,8 +103,9 @@ export function SettingsPanel({ onSaveLspProfile, onDeleteLspProfile, onNewLspProfile, + onSetTheme, }: SettingsPanelProps) { - const [activeSection, setActiveSection] = useState('connection'); + const [activeSection, setActiveSection] = useState('appearance'); const [editingPattern, setEditingPattern] = useState(null); const [editingMcpServer, setEditingMcpServer] = useState(null); const [editingLspProfile, setEditingLspProfile] = useState(null); @@ -224,6 +235,9 @@ export function SettingsPanel({
+ {activeSection === 'appearance' && ( + + )} {activeSection === 'connection' && ( void; +}) { + return ( +
+
+

Appearance

+

+ Choose how Eryx looks on your device +

+
+ +
+ {themeOptions.map((option) => { + const isSelected = option.value === theme; + return ( + + ); + })} +
+
+ ); +} + function ConnectionSection({ connection, modelCount, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 15db1db..a2755b8 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -26,6 +26,39 @@ -moz-osx-font-smoothing: grayscale; } +/* ── Light mode colour overrides ────────────────────────────── */ + +[data-theme="light"] { + color-scheme: light; + + /* Remap semantic surface tokens */ + --color-surface-0: #ffffff; + --color-surface-1: #f4f4f5; + --color-surface-2: #e4e4e7; + --color-surface-3: #d4d4d8; + --color-border: #e4e4e7; + --color-border-subtle: #f0f0f2; + --color-accent-muted: rgba(99, 102, 241, 0.10); + + /* Swap zinc scale: dark values become light equivalents. + Tailwind v4 exposes these as --color-zinc-{step}. */ + --color-zinc-50: #18181b; + --color-zinc-100: #27272a; + --color-zinc-200: #3f3f46; + --color-zinc-300: #52525b; + --color-zinc-400: #71717a; + --color-zinc-500: #71717a; + --color-zinc-600: #a1a1aa; + --color-zinc-700: #d4d4d8; + --color-zinc-800: #e4e4e7; + --color-zinc-900: #f4f4f5; + --color-zinc-950: #ffffff; + + /* Keep black/white correct for light mode */ + --color-black: #000000; + --color-white: #ffffff; +} + body { margin: 0; min-height: 100vh; @@ -53,12 +86,16 @@ textarea { } ::-webkit-scrollbar-thumb { - background: #3f3f46; + background: var(--color-surface-3); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: #52525b; + background: var(--scrollbar-hover, #52525b); +} + +[data-theme="light"] { + --scrollbar-hover: #a1a1aa; } /* Window drag regions for custom title bar */ @@ -134,7 +171,7 @@ textarea { .markdown-content h5, .markdown-content h6 { font-weight: 600; - color: #e4e4e7; + color: var(--color-zinc-200); margin-top: 1.25em; margin-bottom: 0.5em; line-height: 1.4; @@ -170,9 +207,9 @@ textarea { } .markdown-content blockquote { - border-left: 3px solid #3f3f46; + border-left: 3px solid var(--color-surface-3); padding-left: 1em; - color: #a1a1aa; + color: var(--color-zinc-400); margin-top: 0.75em; margin-bottom: 0.75em; } @@ -187,26 +224,26 @@ textarea { .markdown-content th, .markdown-content td { - border: 1px solid #27272a; + border: 1px solid var(--color-border); padding: 0.5em 0.75em; text-align: left; } .markdown-content th { - background: #18181b; + background: var(--color-surface-1); font-weight: 600; - color: #d4d4d8; + color: var(--color-zinc-300); } .markdown-content hr { border: none; - border-top: 1px solid #27272a; + border-top: 1px solid var(--color-border); margin: 1.5em 0; } .markdown-content strong { font-weight: 600; - color: #e4e4e7; + color: var(--color-zinc-200); } .markdown-content em { diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 9cd6829..a049117 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -8,6 +8,7 @@ export const ipcChannels = { savePattern: 'patterns:save', deletePattern: 'patterns:delete', setPatternFavorite: 'patterns:set-favorite', + setTheme: 'settings:set-theme', saveMcpServer: 'tooling:mcp:save', deleteMcpServer: 'tooling:mcp:delete', saveLspProfile: 'tooling:lsp:save', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 3fec19d..8a6ad84 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -7,6 +7,7 @@ import type { LspProfileDefinition, McpServerDefinition, SessionToolingSelection, + AppearanceTheme, } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; @@ -92,6 +93,7 @@ export interface ElectronApi { selectPattern(patternId?: string): Promise; selectSession(sessionId?: string): Promise; setPatternFavorite(input: SetPatternFavoriteInput): Promise; + setTheme(theme: AppearanceTheme): Promise; onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void; onSessionEvent(listener: (event: SessionEventRecord) => void): () => void; } diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index 5e2f526..ecacf2c 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -40,7 +40,10 @@ export interface WorkspaceToolingSettings { lspProfiles: LspProfileDefinition[]; } +export type AppearanceTheme = 'dark' | 'light' | 'system'; + export interface WorkspaceSettings { + theme: AppearanceTheme; tooling: WorkspaceToolingSettings; } @@ -51,6 +54,7 @@ export interface SessionToolingSelection { export function createWorkspaceSettings(): WorkspaceSettings { return { + theme: 'dark', tooling: { mcpServers: [], lspProfiles: [], @@ -65,8 +69,15 @@ export function createSessionToolingSelection(): SessionToolingSelection { }; } +const validThemes: ReadonlySet = new Set(['dark', 'light', 'system']); + +export function normalizeTheme(value?: string): AppearanceTheme { + return validThemes.has(value ?? '') ? (value as AppearanceTheme) : 'dark'; +} + export function normalizeWorkspaceSettings(settings?: Partial): WorkspaceSettings { return { + theme: normalizeTheme(settings?.theme), tooling: { mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition), lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition), diff --git a/tests/shared/tooling.test.ts b/tests/shared/tooling.test.ts index 7fa6c1c..ffb26cf 100644 --- a/tests/shared/tooling.test.ts +++ b/tests/shared/tooling.test.ts @@ -53,6 +53,7 @@ describe('tooling settings helpers', () => { }); expect(workspaceSettings).toEqual({ + theme: 'dark', tooling: { mcpServers: [ { diff --git a/tests/shared/workspace.test.ts b/tests/shared/workspace.test.ts index 4ab2ce7..0136c22 100644 --- a/tests/shared/workspace.test.ts +++ b/tests/shared/workspace.test.ts @@ -9,6 +9,7 @@ describe('workspace seed', () => { expect(workspace.projects).toEqual([]); expect(workspace.sessions).toEqual([]); expect(workspace.settings).toEqual({ + theme: 'dark', tooling: { mcpServers: [], lspProfiles: [],