feat: implement light mode with settings toggle

Add appearance theme support (dark/light/system) using CSS custom
property overrides. In light mode the Tailwind zinc scale is inverted
and semantic surface/border tokens are swapped, so all existing
utility classes pick up the new palette automatically.

- Add AppearanceTheme type and theme field to WorkspaceSettings
- Add setTheme IPC channel wiring (contracts, preload, handler, service)
- Add [data-theme='light'] CSS overrides for zinc scale, surface tokens,
  scrollbar, and markdown prose colours
- Add Appearance section to SettingsPanel with radio-button theme picker
- Apply data-theme attribute on document root via useEffect in App.tsx,
  with matchMedia listener for the system option

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 19:28:48 +01:00
co-authored by Copilot
parent 33c4cee0b4
commit 973e5eb25c
11 changed files with 174 additions and 14 deletions
+8
View File
@@ -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<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setTheme(theme: AppearanceTheme): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.settings.theme = normalizeTheme(theme);
return this.persistAndBroadcast(workspace);
}
async deletePattern(patternId: string): Promise<WorkspaceState> {
if (isBuiltinPattern(patternId)) {
throw new Error('Built-in patterns cannot be deleted.');
+4
View File
@@ -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),
);
+1
View File
@@ -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),
+24 -1
View File
@@ -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;
+74 -3
View File
@@ -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<ModelDefinition>;
patterns: PatternDefinition[];
sidecarCapabilities?: SidecarCapabilities;
theme: AppearanceTheme;
toolingSettings: WorkspaceToolingSettings;
isRefreshingCapabilities: boolean;
onRefreshCapabilities: () => void;
@@ -34,9 +36,10 @@ interface SettingsPanelProps {
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
onDeleteLspProfile: (profileId: string) => Promise<void>;
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: <Palette className="size-3.5" /> },
],
},
{
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<SettingsSection>('connection');
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
@@ -224,6 +235,9 @@ export function SettingsPanel({
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-8 py-6">
{activeSection === 'appearance' && (
<AppearanceSection theme={theme} onSetTheme={onSetTheme} />
)}
{activeSection === 'connection' && (
<ConnectionSection
connection={sidecarCapabilities?.connection}
@@ -260,6 +274,63 @@ export function SettingsPanel({
);
}
const themeOptions: { value: AppearanceTheme; label: string; description: string }[] = [
{ value: 'dark', label: 'Dark', description: 'Dark background with light text' },
{ value: 'light', label: 'Light', description: 'Light background with dark text' },
{ value: 'system', label: 'System', description: 'Follow your operating system setting' },
];
function AppearanceSection({
theme,
onSetTheme,
}: {
theme: AppearanceTheme;
onSetTheme: (theme: AppearanceTheme) => void;
}) {
return (
<div>
<div className="mb-1">
<h3 className="text-[13px] font-semibold text-zinc-200">Appearance</h3>
<p className="mt-0.5 text-[12px] text-zinc-500">
Choose how Eryx looks on your device
</p>
</div>
<div className="mt-5 space-y-1.5">
{themeOptions.map((option) => {
const isSelected = option.value === theme;
return (
<button
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition ${
isSelected
? 'border-indigo-500/50 bg-indigo-500/10'
: 'border-[var(--color-border)] hover:border-zinc-600 hover:bg-zinc-800/40'
}`}
key={option.value}
onClick={() => onSetTheme(option.value)}
type="button"
>
<div
className={`flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition ${
isSelected ? 'border-indigo-500' : 'border-zinc-600'
}`}
>
{isSelected && <div className="size-2 rounded-full bg-indigo-500" />}
</div>
<div>
<span className={`text-[13px] font-medium ${isSelected ? 'text-zinc-100' : 'text-zinc-300'}`}>
{option.label}
</span>
<p className="text-[12px] text-zinc-500">{option.description}</p>
</div>
</button>
);
})}
</div>
</div>
);
}
function ConnectionSection({
connection,
modelCount,
+47 -10
View File
@@ -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 {
+1
View File
@@ -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',
+2
View File
@@ -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<WorkspaceState>;
selectSession(sessionId?: string): Promise<WorkspaceState>;
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
}
+11
View File
@@ -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<string> = new Set<AppearanceTheme>(['dark', 'light', 'system']);
export function normalizeTheme(value?: string): AppearanceTheme {
return validThemes.has(value ?? '') ? (value as AppearanceTheme) : 'dark';
}
export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>): WorkspaceSettings {
return {
theme: normalizeTheme(settings?.theme),
tooling: {
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
+1
View File
@@ -53,6 +53,7 @@ describe('tooling settings helpers', () => {
});
expect(workspaceSettings).toEqual({
theme: 'dark',
tooling: {
mcpServers: [
{
+1
View File
@@ -9,6 +9,7 @@ describe('workspace seed', () => {
expect(workspace.projects).toEqual([]);
expect(workspace.sessions).toEqual([]);
expect(workspace.settings).toEqual({
theme: 'dark',
tooling: {
mcpServers: [],
lspProfiles: [],