Skip to content

Commit 5f2edfd

Browse files
committed
Persist theme settings across restarts
1 parent 5a41db7 commit 5f2edfd

5 files changed

Lines changed: 139 additions & 42 deletions

File tree

packages/app/src/main/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { setupAutoUpdater, downloadUpdate, quitAndInstall } from './updater.js'
1414
import { openTerminal } from './terminal.js'
1515
import { getSessionResumeCommand } from '../shared/resumeCommand.js'
1616
import { resolveResumeWorkingDirectory } from './sessionResume.js'
17+
import { loadUIPreferences, saveThemeEditor, saveThemeSource } from './uiPreferences.js'
1718
import type Database from 'better-sqlite3'
1819
import type { SyncWorkerMessage } from './sync-worker.js'
1920

@@ -24,6 +25,8 @@ if (customUserDataDir) {
2425
}
2526
// macOS menu bar shows the first menu's label as the app name
2627
app.setName(isDevMode ? 'Spool DEV' : 'Spool')
28+
const uiPreferences = loadUIPreferences()
29+
nativeTheme.themeSource = uiPreferences.themeSource
2730
let focusExistingWindow = () => {}
2831

2932
const gotSingleInstanceLock = app.requestSingleInstanceLock()
@@ -320,7 +323,19 @@ ipcMain.handle('spool:get-theme', () => {
320323
})
321324

322325
ipcMain.handle('spool:set-theme', (_e, { theme }: { theme: 'system' | 'light' | 'dark' }) => {
326+
uiPreferences.themeSource = theme
323327
nativeTheme.themeSource = theme
328+
saveThemeSource(theme)
329+
return { ok: true }
330+
})
331+
332+
ipcMain.handle('spool:get-theme-editor-state', () => {
333+
return uiPreferences.themeEditor
334+
})
335+
336+
ipcMain.handle('spool:set-theme-editor-state', (_e, { state }: { state: import('../renderer/theme/editorTypes.js').ThemeEditorStateV1 }) => {
337+
uiPreferences.themeEditor = state
338+
saveThemeEditor(state)
324339
return { ok: true }
325340
})
326341

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
import { homedir } from 'node:os'
4+
import {
5+
normalizeThemeEditorState,
6+
type ThemeEditorStateV1,
7+
type ThemeSource,
8+
} from '../renderer/theme/editorTypes.js'
9+
10+
interface UIConfigFile {
11+
themeSource?: unknown
12+
themeEditor?: unknown
13+
}
14+
15+
const UI_CONFIG_PATH = join(homedir(), '.spool', 'ui.json')
16+
17+
export interface UIPreferences {
18+
themeSource: ThemeSource
19+
themeEditor: ThemeEditorStateV1 | null
20+
}
21+
22+
function normalizeThemeSource(raw: unknown): ThemeSource {
23+
return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : 'system'
24+
}
25+
26+
function readUIConfig(): UIConfigFile {
27+
try {
28+
if (!existsSync(UI_CONFIG_PATH)) return {}
29+
return JSON.parse(readFileSync(UI_CONFIG_PATH, 'utf8')) as UIConfigFile
30+
} catch {
31+
return {}
32+
}
33+
}
34+
35+
function writeUIConfig(config: UIConfigFile): void {
36+
mkdirSync(join(homedir(), '.spool'), { recursive: true })
37+
writeFileSync(UI_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8')
38+
}
39+
40+
export function loadUIPreferences(): UIPreferences {
41+
const config = readUIConfig()
42+
return {
43+
themeSource: normalizeThemeSource(config.themeSource),
44+
themeEditor: normalizeThemeEditorState(config.themeEditor),
45+
}
46+
}
47+
48+
export function saveThemeSource(themeSource: ThemeSource): void {
49+
const config = readUIConfig()
50+
writeUIConfig({ ...config, themeSource })
51+
}
52+
53+
export function saveThemeEditor(themeEditor: ThemeEditorStateV1): void {
54+
const config = readUIConfig()
55+
writeUIConfig({ ...config, themeEditor })
56+
}

packages/app/src/preload/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { contextBridge, ipcRenderer } from 'electron'
22
import type { FragmentResult, Session, Message, StatusInfo, SyncResult, SearchResult, ConnectorStatus, AuthStatus, SchedulerStatus } from '@spool/core'
33
import type { SearchSortOrder } from '../shared/searchSort.js'
4+
import type { ThemeEditorStateV1 } from '../renderer/theme/editorTypes.js'
45

56
export interface AgentInfo {
67
id: string
@@ -122,6 +123,12 @@ const api = {
122123
setTheme: (theme: 'system' | 'light' | 'dark'): Promise<{ ok: boolean }> =>
123124
ipcRenderer.invoke('spool:set-theme', { theme }),
124125

126+
getThemeEditorState: (): Promise<ThemeEditorStateV1 | null> =>
127+
ipcRenderer.invoke('spool:get-theme-editor-state'),
128+
129+
setThemeEditorState: (state: ThemeEditorStateV1): Promise<{ ok: boolean }> =>
130+
ipcRenderer.invoke('spool:set-theme-editor-state', { state }),
131+
125132
// ── Connectors ──
126133

127134
connectors: {

packages/app/src/renderer/theme/editorTypes.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
export const THEME_EDITOR_STORAGE_KEY = 'spool_theme_editor'
44
export const LEGACY_DARK_PALETTE_KEY = 'spool_dark_palette'
5+
export type ThemeSource = 'system' | 'light' | 'dark'
56

67
export interface ThemeSideConfig {
78
/** Last selected preset; switches to `custom` when user edits colors. */
@@ -30,6 +31,47 @@ export type ThemePresetId = (typeof THEME_PRESETS)[number]
3031
export const LIGHT_PRESETS = THEME_PRESETS
3132
export const DARK_PRESETS = THEME_PRESETS
3233

34+
const KNOWN_PRESETS = new Set<string>(THEME_PRESETS)
35+
36+
export function normalizePresetId(raw: string): string {
37+
const preset = raw === 'forest' ? 'everforest' : raw
38+
return KNOWN_PRESETS.has(preset) ? preset : 'custom'
39+
}
40+
41+
export function normalizeThemeSide(
42+
partial: Partial<ThemeSideConfig> | undefined,
43+
fallback: ThemeSideConfig,
44+
): ThemeSideConfig {
45+
if (!partial || typeof partial !== 'object') return { ...fallback }
46+
47+
const presetRaw = typeof partial.preset === 'string' ? partial.preset : fallback.preset
48+
return {
49+
preset: normalizePresetId(presetRaw),
50+
accent: typeof partial.accent === 'string' ? partial.accent : fallback.accent,
51+
background: typeof partial.background === 'string' ? partial.background : fallback.background,
52+
foreground: typeof partial.foreground === 'string' ? partial.foreground : fallback.foreground,
53+
uiFont: typeof partial.uiFont === 'string' ? partial.uiFont : fallback.uiFont,
54+
codeFont: typeof partial.codeFont === 'string' ? partial.codeFont : fallback.codeFont,
55+
translucentChrome: typeof partial.translucentChrome === 'boolean' ? partial.translucentChrome : fallback.translucentChrome,
56+
contrast: typeof partial.contrast === 'number' && Number.isFinite(partial.contrast)
57+
? Math.max(0, Math.min(100, Math.round(partial.contrast)))
58+
: fallback.contrast,
59+
}
60+
}
61+
62+
export function normalizeThemeEditorState(raw: unknown): ThemeEditorStateV1 | null {
63+
if (!raw || typeof raw !== 'object') return null
64+
const record = raw as Record<string, unknown>
65+
if (record['v'] !== 1) return null
66+
67+
const defaults = defaultThemeEditorState()
68+
return {
69+
v: 1,
70+
light: normalizeThemeSide(record['light'] as Partial<ThemeSideConfig> | undefined, defaults.light),
71+
dark: normalizeThemeSide(record['dark'] as Partial<ThemeSideConfig> | undefined, defaults.dark),
72+
}
73+
}
74+
3375
export function defaultLightSide(): ThemeSideConfig {
3476
return {
3577
preset: 'spool',

packages/app/src/renderer/theme/persist.ts

Lines changed: 19 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,70 +3,46 @@ import {
33
type ThemeSideConfig,
44
THEME_EDITOR_STORAGE_KEY,
55
LEGACY_DARK_PALETTE_KEY,
6-
THEME_PRESETS,
76
defaultThemeEditorState,
7+
normalizeThemeEditorState,
8+
normalizeThemeSide,
89
} from './editorTypes.js'
910
import { darkPresetSeed } from './presetSeeds.js'
1011

11-
const KNOWN_PRESETS = new Set<string>(THEME_PRESETS)
12-
13-
function normalizePresetId(raw: string): string {
14-
const p = raw === 'forest' ? 'everforest' : raw
15-
return KNOWN_PRESETS.has(p) ? p : 'custom'
16-
}
17-
18-
export function normalizeSide(partial: Partial<ThemeSideConfig> | undefined, fallback: ThemeSideConfig): ThemeSideConfig {
19-
if (!partial || typeof partial !== 'object') return { ...fallback }
20-
const presetRaw = typeof partial.preset === 'string' ? partial.preset : fallback.preset
21-
return {
22-
preset: normalizePresetId(presetRaw),
23-
accent: typeof partial.accent === 'string' ? partial.accent : fallback.accent,
24-
background: typeof partial.background === 'string' ? partial.background : fallback.background,
25-
foreground: typeof partial.foreground === 'string' ? partial.foreground : fallback.foreground,
26-
uiFont: typeof partial.uiFont === 'string' ? partial.uiFont : fallback.uiFont,
27-
codeFont: typeof partial.codeFont === 'string' ? partial.codeFont : fallback.codeFont,
28-
translucentChrome: typeof partial.translucentChrome === 'boolean' ? partial.translucentChrome : fallback.translucentChrome,
29-
contrast: typeof partial.contrast === 'number' && Number.isFinite(partial.contrast)
30-
? Math.max(0, Math.min(100, Math.round(partial.contrast)))
31-
: fallback.contrast,
32-
}
33-
}
34-
35-
export function normalizeThemeEditorState(raw: unknown): ThemeEditorStateV1 | null {
36-
if (!raw || typeof raw !== 'object') return null
37-
const o = raw as Record<string, unknown>
38-
if (o['v'] !== 1) return null
39-
const base = defaultThemeEditorState()
40-
return {
41-
v: 1,
42-
light: normalizeSide(o['light'] as Partial<ThemeSideConfig>, base.light),
43-
dark: normalizeSide(o['dark'] as Partial<ThemeSideConfig>, base.dark),
44-
}
45-
}
46-
4712
/** Merge loose import (e.g. partial JSON) onto current state. */
4813
export function mergeThemeImportLoose(raw: unknown, current: ThemeEditorStateV1): ThemeEditorStateV1 | null {
4914
if (!raw || typeof raw !== 'object') return null
5015
const o = raw as Record<string, unknown>
5116
const next: ThemeEditorStateV1 = {
5217
v: 1,
53-
light: normalizeSide((o['light'] as Partial<ThemeSideConfig>) ?? {}, current.light),
54-
dark: normalizeSide((o['dark'] as Partial<ThemeSideConfig>) ?? {}, current.dark),
18+
light: normalizeThemeSide((o['light'] as Partial<ThemeSideConfig>) ?? {}, current.light),
19+
dark: normalizeThemeSide((o['dark'] as Partial<ThemeSideConfig>) ?? {}, current.dark),
5520
}
5621
return next
5722
}
5823

5924
export async function loadThemeEditorState(): Promise<ThemeEditorStateV1> {
6025
try {
61-
const raw = window.localStorage.getItem(THEME_EDITOR_STORAGE_KEY)
62-
if (raw) {
63-
const parsed = normalizeThemeEditorState(JSON.parse(raw))
26+
const stored = await window.spool?.getThemeEditorState?.()
27+
if (stored) {
28+
const parsed = normalizeThemeEditorState(stored)
6429
if (parsed) return parsed
6530
}
31+
32+
const rawLocal = window.localStorage.getItem(THEME_EDITOR_STORAGE_KEY)
33+
if (rawLocal) {
34+
const parsed = normalizeThemeEditorState(JSON.parse(rawLocal))
35+
if (parsed) {
36+
await window.spool?.setThemeEditorState?.(parsed)
37+
return parsed
38+
}
39+
}
40+
6641
const next = defaultThemeEditorState()
6742
const legacy = window.localStorage.getItem(LEGACY_DARK_PALETTE_KEY)
6843
if (legacy === 'forest') {
6944
next.dark = darkPresetSeed('everforest', next.dark)
45+
await window.spool?.setThemeEditorState?.(next)
7046
}
7147
return next
7248
} catch {
@@ -76,4 +52,5 @@ export async function loadThemeEditorState(): Promise<ThemeEditorStateV1> {
7652

7753
export async function saveThemeEditorState(state: ThemeEditorStateV1): Promise<void> {
7854
window.localStorage.setItem(THEME_EDITOR_STORAGE_KEY, JSON.stringify(state))
55+
await window.spool?.setThemeEditorState?.(state)
7956
}

0 commit comments

Comments
 (0)