Skip to content

Commit 32a7dfc

Browse files
frenchie4111claude
andauthored
Add theming system with 9 color themes (#4)
* Add theming system with 9 color themes Introduces a CSS-variable based theme system with semantic tokens (panel, surface, border, fg, muted, success, warning, etc.) that the major panels now use instead of hardcoded Tailwind neutral classes. Themes are persisted in config.json and switched via a new Appearance section in Settings. Bundled themes: Dark (default), Dracula, Nord, Gruvbox Dark, Tokyo Night, Catppuccin Mocha, One Dark, Solarized Dark, Solarized Light. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Lighten panel surfaces so sidebars read as distinct from main area Previously --color-panel matched --color-app in every theme, so the sidebar and right pane blended into the terminal. Nudging panel one step toward panel-raised (or darker for the light theme) gives the chrome a subtle lift without making it feel busy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update package-lock.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dcc63ae commit 32a7dfc

15 files changed

Lines changed: 570 additions & 189 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
saveConfig,
1111
saveConfigSync,
1212
DEFAULT_CLAUDE_COMMAND,
13+
DEFAULT_THEME,
14+
AVAILABLE_THEMES,
15+
THEME_APP_BG,
1316
saveTerminalHistory,
1417
loadTerminalHistory,
1518
clearTerminalHistory,
@@ -41,7 +44,7 @@ function createWindow(repoRoot?: string): BrowserWindow {
4144
icon: join(__dirname, '../../resources/icon.png'),
4245
titleBarStyle: 'hiddenInset',
4346
trafficLightPosition: { x: 12, y: 12 },
44-
backgroundColor: '#0a0a0a',
47+
backgroundColor: THEME_APP_BG[config.theme || DEFAULT_THEME] || THEME_APP_BG[DEFAULT_THEME],
4548
webPreferences: {
4649
preload: join(__dirname, '../preload/index.js'),
4750
contextIsolation: true,
@@ -208,6 +211,30 @@ function registerIpcHandlers(): void {
208211
return DEFAULT_CLAUDE_COMMAND
209212
})
210213

214+
ipcMain.handle('config:getTheme', () => {
215+
return config.theme || DEFAULT_THEME
216+
})
217+
218+
ipcMain.handle('config:setTheme', (_, theme: string) => {
219+
if (!AVAILABLE_THEMES.includes(theme as (typeof AVAILABLE_THEMES)[number])) {
220+
return false
221+
}
222+
if (theme === DEFAULT_THEME) {
223+
delete config.theme
224+
} else {
225+
config.theme = theme
226+
}
227+
saveConfig(config)
228+
for (const win of BrowserWindow.getAllWindows()) {
229+
if (!win.isDestroyed()) win.webContents.send('config:themeChanged', theme)
230+
}
231+
return true
232+
})
233+
234+
ipcMain.handle('config:getAvailableThemes', () => {
235+
return AVAILABLE_THEMES
236+
})
237+
211238
// Persisted terminal tabs / active tab ids
212239
ipcMain.handle('config:getTerminalTabs', () => {
213240
return {

src/main/persistence.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,35 @@ interface Config {
2020
terminalTabs?: Record<string, PersistedTab[]>
2121
// Active tab id per worktree path
2222
activeTabId?: Record<string, string>
23+
// Selected color theme id
24+
theme?: string
25+
}
26+
27+
export const DEFAULT_THEME = 'dark'
28+
export const AVAILABLE_THEMES = [
29+
'dark',
30+
'dracula',
31+
'nord',
32+
'gruvbox-dark',
33+
'tokyo-night',
34+
'catppuccin-mocha',
35+
'one-dark',
36+
'solarized-dark',
37+
'solarized-light'
38+
] as const
39+
40+
/** App background hex for each theme — used for the Electron window backgroundColor
41+
* so the first paint matches the theme instead of flashing default dark. */
42+
export const THEME_APP_BG: Record<string, string> = {
43+
'dark': '#0a0a0a',
44+
'dracula': '#282a36',
45+
'nord': '#2e3440',
46+
'gruvbox-dark': '#282828',
47+
'tokyo-night': '#1a1b26',
48+
'catppuccin-mocha': '#1e1e2e',
49+
'one-dark': '#282c34',
50+
'solarized-dark': '#002b36',
51+
'solarized-light': '#fdf6e3'
2352
}
2453

2554
export const DEFAULT_CLAUDE_COMMAND = 'claude --continue || (echo "Creating new Claude session for this worktree..." && claude)'

src/preload/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ contextBridge.exposeInMainWorld('api', {
3333
getClaudeCommand: () => ipcRenderer.invoke('config:getClaudeCommand'),
3434
setClaudeCommand: (command: string) => ipcRenderer.invoke('config:setClaudeCommand', command),
3535
getDefaultClaudeCommand: () => ipcRenderer.invoke('config:getDefaultClaudeCommand'),
36+
getTheme: () => ipcRenderer.invoke('config:getTheme'),
37+
setTheme: (theme: string) => ipcRenderer.invoke('config:setTheme', theme),
38+
getAvailableThemes: () => ipcRenderer.invoke('config:getAvailableThemes'),
39+
onThemeChanged: (callback: (theme: string) => void) => {
40+
const handler = (_event: Electron.IpcRendererEvent, theme: string): void => {
41+
callback(theme)
42+
}
43+
ipcRenderer.on('config:themeChanged', handler)
44+
return () => ipcRenderer.removeListener('config:themeChanged', handler)
45+
},
3646
getTerminalTabs: () => ipcRenderer.invoke('config:getTerminalTabs'),
3747
setTerminalTabs: (tabs: unknown, activeTabId: unknown) =>
3848
ipcRenderer.invoke('config:setTerminalTabs', tabs, activeTabId),

src/renderer/App.tsx

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,17 @@ export default function App(): JSX.Element {
129129
return cleanup
130130
}, [])
131131

132+
// Load theme on mount and apply to <html data-theme="...">
133+
useEffect(() => {
134+
window.api.getTheme().then((theme) => {
135+
document.documentElement.dataset.theme = theme
136+
})
137+
const cleanup = window.api.onThemeChanged((theme) => {
138+
document.documentElement.dataset.theme = theme
139+
})
140+
return cleanup
141+
}, [])
142+
132143
// Open Settings from the menu (Cmd+,)
133144
useEffect(() => {
134145
const cleanup = window.api.onOpenSettings(() => setShowSettings(true))
@@ -518,11 +529,11 @@ export default function App(): JSX.Element {
518529
<div className="drag-region h-10 shrink-0" />
519530
<div className="flex flex-1 items-center justify-center">
520531
<div className="text-center">
521-
<h1 className="text-2xl font-semibold text-neutral-200 mb-4">Harness</h1>
522-
<p className="text-neutral-500 mb-6">Select a git repository to get started</p>
532+
<h1 className="text-2xl font-semibold text-fg-bright mb-4">Harness</h1>
533+
<p className="text-dim mb-6">Select a git repository to get started</p>
523534
<button
524535
onClick={handleSelectRepo}
525-
className="px-6 py-3 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-neutral-200 transition-colors cursor-pointer"
536+
className="px-6 py-3 bg-surface hover:bg-surface-hover rounded-lg text-fg-bright transition-colors cursor-pointer"
526537
>
527538
Open Repository
528539
</button>
@@ -536,21 +547,21 @@ export default function App(): JSX.Element {
536547
<div className="flex h-full flex-col">
537548
{/* Hooks consent banner */}
538549
{hooksConsent === 'pending' && (
539-
<div className="bg-amber-950 border-b border-amber-800 pl-20 pr-4 py-2.5 drag-region flex items-center gap-3 shrink-0">
540-
<span className="text-amber-200 text-sm flex-1">
550+
<div className="bg-warning/15 border-b border-warning/30 pl-20 pr-4 py-2.5 drag-region flex items-center gap-3 shrink-0">
551+
<span className="text-warning text-sm flex-1">
541552
Claude Harness can install hooks in your worktrees to reliably detect Claude's status
542553
(waiting, processing, needs approval). This adds entries to each worktree's{' '}
543-
<code className="bg-amber-900 px-1 rounded text-xs">.claude/settings.local.json</code>.
554+
<code className="bg-warning/20 px-1 rounded text-xs">.claude/settings.local.json</code>.
544555
</span>
545556
<button
546557
onClick={handleAcceptHooks}
547-
className="px-3 py-1 bg-amber-700 hover:bg-amber-600 rounded text-sm text-amber-100 transition-colors shrink-0 cursor-pointer no-drag"
558+
className="px-3 py-1 bg-warning/30 hover:bg-warning/40 rounded text-sm text-warning transition-colors shrink-0 cursor-pointer no-drag"
548559
>
549560
Enable
550561
</button>
551562
<button
552563
onClick={handleDeclineHooks}
553-
className="px-3 py-1 text-amber-400 hover:text-amber-200 text-sm transition-colors shrink-0 cursor-pointer no-drag"
564+
className="px-3 py-1 text-warning/80 hover:text-warning text-sm transition-colors shrink-0 cursor-pointer no-drag"
554565
>
555566
Skip
556567
</button>
@@ -559,19 +570,19 @@ export default function App(): JSX.Element {
559570

560571
{/* GitHub setup banner */}
561572
{hasGithubToken === false && !githubBannerDismissed && (
562-
<div className="bg-blue-950 border-b border-blue-800 pl-20 pr-4 py-2.5 drag-region flex items-center gap-3 shrink-0">
563-
<span className="text-blue-200 text-sm flex-1">
573+
<div className="bg-info/15 border-b border-info/30 pl-20 pr-4 py-2.5 drag-region flex items-center gap-3 shrink-0">
574+
<span className="text-info text-sm flex-1">
564575
Connect a GitHub token to see PR status and open pull requests from Harness.
565576
</span>
566577
<button
567578
onClick={() => setShowSettings(true)}
568-
className="px-3 py-1 bg-blue-700 hover:bg-blue-600 rounded text-sm text-blue-100 transition-colors shrink-0 cursor-pointer no-drag"
579+
className="px-3 py-1 bg-info/30 hover:bg-info/40 rounded text-sm text-info transition-colors shrink-0 cursor-pointer no-drag"
569580
>
570581
Set up GitHub
571582
</button>
572583
<button
573584
onClick={handleDismissGithubBanner}
574-
className="px-3 py-1 text-blue-400 hover:text-blue-200 text-sm transition-colors shrink-0 cursor-pointer no-drag"
585+
className="px-3 py-1 text-info/80 hover:text-info text-sm transition-colors shrink-0 cursor-pointer no-drag"
575586
>
576587
Dismiss
577588
</button>
@@ -621,12 +632,12 @@ export default function App(): JSX.Element {
621632
)
622633
})}
623634
{!activeWorktreeId && worktrees.length > 0 && (
624-
<div className="flex-1 flex items-center justify-center text-neutral-500">
635+
<div className="flex-1 flex items-center justify-center text-dim">
625636
Select a worktree to begin
626637
</div>
627638
)}
628639
{/* Right panel */}
629-
<div className="w-64 shrink-0 h-full flex flex-col border-l border-neutral-800 bg-neutral-950">
640+
<div className="w-64 shrink-0 h-full flex flex-col border-l border-border bg-panel">
630641
<PRStatusPanel pr={activeWorktreeId ? prStatuses[activeWorktreeId] : null} />
631642
<div className="flex-1 min-h-0">
632643
<ChangedFilesPanel worktreePath={activeWorktreeId} onOpenDiff={handleOpenDiff} />

src/renderer/components/ChangedFilesPanel.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ const STATUS_LABEL: Record<ChangedFile['status'], string> = {
1616
}
1717

1818
const STATUS_COLOR: Record<ChangedFile['status'], string> = {
19-
added: 'text-green-400',
20-
modified: 'text-amber-400',
21-
deleted: 'text-red-400',
22-
renamed: 'text-blue-400',
23-
untracked: 'text-neutral-500'
19+
added: 'text-success',
20+
modified: 'text-warning',
21+
deleted: 'text-danger',
22+
renamed: 'text-info',
23+
untracked: 'text-dim'
2424
}
2525

2626
export function ChangedFilesPanel({ worktreePath, onOpenDiff }: ChangedFilesPanelProps): JSX.Element {
@@ -52,15 +52,15 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff }: ChangedFilesPane
5252
const unstagedFiles = files.filter((f) => !f.staged)
5353

5454
return (
55-
<div className="flex flex-col h-full bg-neutral-950">
55+
<div className="flex flex-col h-full bg-panel">
5656
{/* Header */}
57-
<div className="drag-region flex items-center justify-between h-10 px-3 border-b border-neutral-800 shrink-0">
58-
<span className="no-drag text-xs font-medium text-neutral-400 uppercase tracking-wide">
57+
<div className="drag-region flex items-center justify-between h-10 px-3 border-b border-border shrink-0">
58+
<span className="no-drag text-xs font-medium text-muted uppercase tracking-wide">
5959
Changed Files
6060
</span>
6161
<button
6262
onClick={refresh}
63-
className="no-drag text-neutral-600 hover:text-neutral-300 transition-colors cursor-pointer"
63+
className="no-drag text-faint hover:text-fg transition-colors cursor-pointer"
6464
title="Refresh"
6565
>
6666
<RefreshCw size={12} />
@@ -70,16 +70,16 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff }: ChangedFilesPane
7070
{/* File list */}
7171
<div className="flex-1 overflow-y-auto min-h-0 text-xs">
7272
{!worktreePath && (
73-
<div className="p-3 text-neutral-600">No worktree selected</div>
73+
<div className="p-3 text-faint">No worktree selected</div>
7474
)}
7575

7676
{worktreePath && files.length === 0 && !loading && (
77-
<div className="p-3 text-neutral-600">No changes</div>
77+
<div className="p-3 text-faint">No changes</div>
7878
)}
7979

8080
{stagedFiles.length > 0 && (
8181
<div>
82-
<div className="px-3 py-1.5 text-[10px] font-medium text-neutral-500 uppercase tracking-wider bg-neutral-900/50">
82+
<div className="px-3 py-1.5 text-[10px] font-medium text-dim uppercase tracking-wider bg-panel-raised/50">
8383
Staged
8484
</div>
8585
{stagedFiles.map((file) => (
@@ -90,7 +90,7 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff }: ChangedFilesPane
9090

9191
{unstagedFiles.length > 0 && (
9292
<div>
93-
<div className="px-3 py-1.5 text-[10px] font-medium text-neutral-500 uppercase tracking-wider bg-neutral-900/50">
93+
<div className="px-3 py-1.5 text-[10px] font-medium text-dim uppercase tracking-wider bg-panel-raised/50">
9494
{stagedFiles.length > 0 ? 'Unstaged' : 'Changes'}
9595
</div>
9696
{unstagedFiles.map((file) => (
@@ -102,7 +102,7 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff }: ChangedFilesPane
102102

103103
{/* Footer summary */}
104104
{files.length > 0 && (
105-
<div className="px-3 py-1.5 border-t border-neutral-800 text-[10px] text-neutral-600 shrink-0">
105+
<div className="px-3 py-1.5 border-t border-border text-[10px] text-faint shrink-0">
106106
{files.length} file{files.length !== 1 ? 's' : ''} changed
107107
</div>
108108
)}
@@ -117,13 +117,13 @@ function FileRow({ file, onClick }: { file: ChangedFile; onClick: () => void }):
117117
const name = lastSlash >= 0 ? file.path.slice(lastSlash + 1) : file.path
118118

119119
return (
120-
<div className="flex items-center gap-2 px-3 py-1 hover:bg-neutral-900 cursor-pointer group" onClick={onClick}>
120+
<div className="flex items-center gap-2 px-3 py-1 hover:bg-panel-raised cursor-pointer group" onClick={onClick}>
121121
<span className={`shrink-0 w-3 font-mono ${STATUS_COLOR[file.status]}`}>
122122
{STATUS_LABEL[file.status]}
123123
</span>
124124
<span className="truncate min-w-0">
125-
{dir && <span className="text-neutral-600">{dir}</span>}
126-
<span className="text-neutral-300">{name}</span>
125+
{dir && <span className="text-faint">{dir}</span>}
126+
<span className="text-fg">{name}</span>
127127
</span>
128128
</div>
129129
)

src/renderer/components/DiffView.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,19 @@ function parseDiff(raw: string): DiffLine[] {
4646
}
4747

4848
const LINE_STYLES: Record<DiffLine['type'], string> = {
49-
add: 'bg-green-950/40 text-green-300',
50-
remove: 'bg-red-950/40 text-red-300',
51-
context: 'text-neutral-400',
52-
header: 'text-neutral-500 italic',
53-
hunk: 'text-blue-400 bg-blue-950/20'
49+
add: 'bg-success/15 text-success',
50+
remove: 'bg-danger/15 text-danger',
51+
context: 'text-muted',
52+
header: 'text-dim italic',
53+
hunk: 'text-info bg-info/10'
5454
}
5555

5656
const GUTTER_STYLES: Record<DiffLine['type'], string> = {
57-
add: 'text-green-700',
58-
remove: 'text-red-700',
59-
context: 'text-neutral-700',
57+
add: 'text-success/70',
58+
remove: 'text-danger/70',
59+
context: 'text-faint',
6060
header: '',
61-
hunk: 'text-blue-800'
61+
hunk: 'text-info/70'
6262
}
6363

6464
export function DiffView({ worktreePath, filePath, staged }: DiffViewProps): JSX.Element {
@@ -79,15 +79,15 @@ export function DiffView({ worktreePath, filePath, staged }: DiffViewProps): JSX
7979

8080
if (loading) {
8181
return (
82-
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
82+
<div className="flex items-center justify-center h-full text-faint text-sm">
8383
Loading diff...
8484
</div>
8585
)
8686
}
8787

8888
if (!diff) {
8989
return (
90-
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
90+
<div className="flex items-center justify-center h-full text-faint text-sm">
9191
No diff available
9292
</div>
9393
)
@@ -96,15 +96,15 @@ export function DiffView({ worktreePath, filePath, staged }: DiffViewProps): JSX
9696
const lines = parseDiff(diff)
9797

9898
return (
99-
<div className="h-full overflow-auto bg-[#0a0a0a]">
99+
<div className="h-full overflow-auto bg-app">
100100
<div className="font-mono text-xs leading-5 min-w-fit">
101101
{lines.map((line, i) => (
102102
<div key={i} className={`flex ${LINE_STYLES[line.type]}`}>
103103
{/* Gutter */}
104104
<span className={`shrink-0 w-10 text-right pr-2 select-none ${GUTTER_STYLES[line.type]}`}>
105105
{line.type === 'add' || line.type === 'context' ? line.newLine : ''}
106106
</span>
107-
<span className={`shrink-0 w-10 text-right pr-2 select-none border-r border-neutral-800/50 ${GUTTER_STYLES[line.type]}`}>
107+
<span className={`shrink-0 w-10 text-right pr-2 select-none border-r border-border/50 ${GUTTER_STYLES[line.type]}`}>
108108
{line.type === 'remove' || line.type === 'context' ? line.oldLine : ''}
109109
</span>
110110
{/* Sign */}

0 commit comments

Comments
 (0)