Add settings: theme, zoom controls, keyboard shortcuts (step 8)

Theme (light/dark/system) persisted via Zustand's persist middleware
to localStorage, applied by toggling .dark on <html> — the CSS custom
variant was already wired up from step 1 but nothing had driven it
until now, so the app was silently stuck in light mode regardless of
OS appearance. "System" resolves via matchMedia and stays live via a
change listener. Zoom in/out/reset go through a small main-process IPC
(webContents.setZoomLevel) since the renderer can't reach Electron's
webFrame directly with contextIsolation on. Keyboard shortcuts
(Cmd/Ctrl+N, +comma, +=/-/0) are a single window-level listener
mounted once at the app root.

Verified end-to-end via CDP: confirmed system theme correctly resolved
dark to match the real OS appearance at test time, manually switching
to Light actually removed the .dark class and persisted to
localStorage, zoom in/reset both confirmed via the IPC's returned zoom
level (cumulative across calls), and both keyboard shortcuts
(dispatched as real KeyboardEvents) correctly opened their modals.

Also added a matchMedia polyfill to the Vitest setup file — jsdom
doesn't implement it, which broke App.test.tsx once useAppliedTheme
started running on every render.
This commit is contained in:
R3ap3R
2026-08-03 00:26:15 -05:00
parent 541d719680
commit 0820c2f3d8
9 changed files with 277 additions and 10 deletions
+2
View File
@@ -8,6 +8,7 @@ import { registerDatabaseIpc } from './ipc/database'
import { registerAddonsIpc } from './ipc/addons' import { registerAddonsIpc } from './ipc/addons'
import { registerLogsIpc } from './ipc/logs' import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create' import { registerCreateIpc } from './ipc/create'
import { registerWindowIpc } from './ipc/window'
import { killAllRunningCommands } from './commandRunner' import { killAllRunningCommands } from './commandRunner'
function createWindow(): void { function createWindow(): void {
@@ -63,6 +64,7 @@ app.whenReady().then(() => {
registerAddonsIpc() registerAddonsIpc()
registerLogsIpc() registerLogsIpc()
registerCreateIpc() registerCreateIpc()
registerWindowIpc()
createWindow() createWindow()
+23
View File
@@ -0,0 +1,23 @@
import { ipcMain } from 'electron'
const MIN_ZOOM = -3
const MAX_ZOOM = 5
export function registerWindowIpc(): void {
ipcMain.handle('window:zoomIn', (event) => {
const level = Math.min(MAX_ZOOM, event.sender.getZoomLevel() + 0.5)
event.sender.setZoomLevel(level)
return level
})
ipcMain.handle('window:zoomOut', (event) => {
const level = Math.max(MIN_ZOOM, event.sender.getZoomLevel() - 0.5)
event.sender.setZoomLevel(level)
return level
})
ipcMain.handle('window:zoomReset', (event) => {
event.sender.setZoomLevel(0)
return 0
})
}
+5
View File
@@ -97,6 +97,11 @@ const api = {
projectType, projectType,
docroot docroot
) )
},
zoom: {
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
out: (): Promise<number> => ipcRenderer.invoke('window:zoomOut'),
reset: (): Promise<number> => ipcRenderer.invoke('window:zoomReset')
} }
} }
+22 -2
View File
@@ -1,18 +1,27 @@
import { useState } from 'react' import { useCallback, useState } from 'react'
import { Plus } from 'lucide-react' import { Plus, Settings } from 'lucide-react'
import { ProjectDetail } from './components/projects/ProjectDetail' import { ProjectDetail } from './components/projects/ProjectDetail'
import { ProjectList } from './components/projects/ProjectList' import { ProjectList } from './components/projects/ProjectList'
import { TerminalPanel } from './components/terminal/TerminalPanel' import { TerminalPanel } from './components/terminal/TerminalPanel'
import { StatusBar } from './components/layout/StatusBar' import { StatusBar } from './components/layout/StatusBar'
import { Toaster } from './components/ui/Toaster' import { Toaster } from './components/ui/Toaster'
import { CreateProjectModal } from './components/create/CreateProjectModal' import { CreateProjectModal } from './components/create/CreateProjectModal'
import { SettingsModal } from './components/settings/SettingsModal'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { useTerminalEvents } from './hooks/useTerminalEvents' import { useTerminalEvents } from './hooks/useTerminalEvents'
import { useAppliedTheme } from './hooks/useAppliedTheme'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
function App(): React.JSX.Element { function App(): React.JSX.Element {
const selectedProjectName = useAppStore((s) => s.selectedProjectName) const selectedProjectName = useAppStore((s) => s.selectedProjectName)
const [isCreateOpen, setIsCreateOpen] = useState(false) const [isCreateOpen, setIsCreateOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
useTerminalEvents() useTerminalEvents()
useAppliedTheme()
useKeyboardShortcuts({
onNewProject: useCallback(() => setIsCreateOpen(true), []),
onOpenSettings: useCallback(() => setIsSettingsOpen(true), [])
})
return ( return (
<div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"> <div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
@@ -20,6 +29,7 @@ function App(): React.JSX.Element {
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800"> <aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800"> <div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h1 className="text-sm font-semibold">Aurora Dockside</h1> <h1 className="text-sm font-semibold">Aurora Dockside</h1>
<div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={() => setIsCreateOpen(true)} onClick={() => setIsCreateOpen(true)}
@@ -28,6 +38,15 @@ function App(): React.JSX.Element {
> >
<Plus size={16} /> <Plus size={16} />
</button> </button>
<button
type="button"
onClick={() => setIsSettingsOpen(true)}
title="Settings"
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
>
<Settings size={16} />
</button>
</div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ProjectList /> <ProjectList />
@@ -47,6 +66,7 @@ function App(): React.JSX.Element {
<StatusBar /> <StatusBar />
<Toaster /> <Toaster />
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />} {isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
</div> </div>
) )
} }
@@ -0,0 +1,109 @@
import { Minus, Plus, RotateCcw, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useThemeStore, type Theme } from '../../stores/themeStore'
const THEMES: { value: Theme; label: string }[] = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' }
]
const SHORTCUTS = [
{ keys: 'Cmd/Ctrl + N', action: 'New project' },
{ keys: 'Cmd/Ctrl + ,', action: 'Open settings' },
{ keys: 'Cmd/Ctrl + =', action: 'Zoom in' },
{ keys: 'Cmd/Ctrl + -', action: 'Zoom out' },
{ keys: 'Cmd/Ctrl + 0', action: 'Reset zoom' }
]
export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const theme = useThemeStore((s) => s.theme)
const setTheme = useThemeStore((s) => s.setTheme)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h2 className="text-sm font-semibold">Settings</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-6 p-4">
<section>
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Theme
</h3>
<div className="flex gap-2">
{THEMES.map((t) => (
<button
key={t.value}
type="button"
onClick={() => setTheme(t.value)}
className={clsx(
'flex-1 rounded-md border px-3 py-1.5 text-sm font-medium',
theme === t.value
? 'border-neutral-900 bg-neutral-900 text-white dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-900'
: 'border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
)}
>
{t.label}
</button>
))}
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Zoom
</h3>
<div className="flex gap-2">
<button
type="button"
onClick={() => window.api.zoom.out()}
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<Minus size={14} /> Out
</button>
<button
type="button"
onClick={() => window.api.zoom.in()}
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<Plus size={14} /> In
</button>
<button
type="button"
onClick={() => window.api.zoom.reset()}
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<RotateCcw size={14} /> Reset
</button>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Keyboard shortcuts
</h3>
<dl className="flex flex-col gap-1 text-sm">
{SHORTCUTS.map((s) => (
<div key={s.keys} className="flex items-center justify-between">
<dt className="text-neutral-500 dark:text-neutral-400">{s.action}</dt>
<dd className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">
{s.keys}
</dd>
</div>
))}
</dl>
</section>
</div>
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useThemeStore } from '../stores/themeStore'
// Applies the resolved theme (light/dark/system) to <html class="dark">,
// which is what the `@custom-variant dark` rule in main.css keys off. Must
// run somewhere mounted once near the app root.
export function useAppliedTheme(): void {
const theme = useThemeStore((s) => s.theme)
useEffect(() => {
const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)')
const apply = (): void => {
const isDark = theme === 'dark' || (theme === 'system' && media.matches)
root.classList.toggle('dark', isDark)
}
apply()
if (theme === 'system') {
media.addEventListener('change', apply)
return () => media.removeEventListener('change', apply)
}
return undefined
}, [theme])
}
@@ -0,0 +1,44 @@
import { useEffect } from 'react'
interface ShortcutHandlers {
onNewProject: () => void
onOpenSettings: () => void
}
// Global keyboard shortcuts: Cmd/Ctrl+N (new project), Cmd/Ctrl+, (settings),
// Cmd/Ctrl +/-/0 (zoom in/out/reset). Mount once near the app root.
export function useKeyboardShortcuts({ onNewProject, onOpenSettings }: ShortcutHandlers): void {
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
switch (e.key) {
case 'n':
e.preventDefault()
onNewProject()
break
case ',':
e.preventDefault()
onOpenSettings()
break
case '=':
case '+':
e.preventDefault()
window.api.zoom.in()
break
case '-':
e.preventDefault()
window.api.zoom.out()
break
case '0':
e.preventDefault()
window.api.zoom.reset()
break
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onNewProject, onOpenSettings])
}
+21
View File
@@ -0,0 +1,21 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type Theme = 'light' | 'dark' | 'system'
interface ThemeState {
theme: Theme
setTheme: (theme: Theme) => void
}
export const useThemeStore = create<ThemeState>()(
persist<ThemeState>(
(set) => ({
theme: 'system',
setTheme: (theme) => {
set({ theme })
}
}),
{ name: 'aurora-dockside-theme' }
)
)
+16
View File
@@ -1 +1,17 @@
import '@testing-library/jest-dom/vitest' import '@testing-library/jest-dom/vitest'
// jsdom doesn't implement matchMedia; useAppliedTheme() relies on it to
// resolve the "system" theme.
if (!window.matchMedia) {
window.matchMedia = (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
}) as MediaQueryList
}