mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
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:
@@ -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.');
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('tooling settings helpers', () => {
|
||||
});
|
||||
|
||||
expect(workspaceSettings).toEqual({
|
||||
theme: 'dark',
|
||||
tooling: {
|
||||
mcpServers: [
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('workspace seed', () => {
|
||||
expect(workspace.projects).toEqual([]);
|
||||
expect(workspace.sessions).toEqual([]);
|
||||
expect(workspace.settings).toEqual({
|
||||
theme: 'dark',
|
||||
tooling: {
|
||||
mcpServers: [],
|
||||
lspProfiles: [],
|
||||
|
||||
Reference in New Issue
Block a user