From a31ae4b8322beb546740226d28bafd4021a81de0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 8 Jun 2026 12:29:03 -0500 Subject: [PATCH 1/4] feat(desktop): assignable themes per profile The desktop skin was a single global preference, so every profile shared one look. Make the theme assignment per profile: picking a theme assigns it to the profile that's currently live, and switching profiles paints that profile's own skin. A profile with no assignment inherits the global default, so single-profile installs and existing setups are unchanged. - themes/context.tsx: per-profile skin record in localStorage; ThemeProvider follows $activeGatewayProfile; boot paint uses the last active profile's theme to avoid a flash on a non-default relaunch; setTheme assigns to the live profile (default profile also seeds the legacy global fallback). - settings/appearance-settings.tsx: caption noting the theme is saved per profile, shown only when more than one profile exists. - i18n: themeProfileNote string across en/zh/zh-hant/ja. - themes/profile-theme.test.ts: resolution + inheritance coverage. --- .../src/app/settings/appearance-settings.tsx | 87 +++++++++++-------- apps/desktop/src/i18n/en.ts | 3 +- apps/desktop/src/i18n/ja.ts | 3 +- apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 3 +- apps/desktop/src/i18n/zh.ts | 3 +- apps/desktop/src/themes/context.tsx | 83 +++++++++++++++++- apps/desktop/src/themes/profile-theme.test.ts | 37 ++++++++ 8 files changed, 177 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/themes/profile-theme.test.ts diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index eb2489209cfd..ae145c8c6120 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -6,6 +6,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Check, Palette } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { useTheme } from '@/themes/context' import { BUILTIN_THEMES } from '@/themes/presets' @@ -57,8 +58,17 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) + const profiles = useStore($profiles) + const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const a = t.settings.appearance + // Themes save per profile. Surface that only when the user actually has more + // than one profile (single-profile installs never see the distinction). + const showProfileNote = profiles.length > 1 + + const activeProfileName = + profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey + const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label })) const toolOptions = [ @@ -98,43 +108,50 @@ export function AppearanceSettings() { - {availableThemes.map(theme => { - const active = themeName === theme.name - - return ( - - ) - })} - + + ) + })} + + {showProfileNote && ( +

+ {a.themeProfileNote(activeProfileName)} +

+ )} + } description={a.themeDesc} title={a.themeTitle} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 62ddb4fd5815..7eedaee25242 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -292,7 +292,8 @@ export const en: Translations = { technical: 'Technical', technicalDesc: 'Include raw tool args/results and low-level details.', themeTitle: 'Theme', - themeDesc: 'Desktop palettes only. The selected mode is applied on top.' + themeDesc: 'Desktop palettes only. The selected mode is applied on top.', + themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.` }, fieldLabels: FIELD_LABELS, fieldDescriptions: FIELD_DESCRIPTIONS, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f2f4f5effa4e..5e5865fb9005 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -215,7 +215,8 @@ export const ja = defineLocale({ technical: 'テクニカル', technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。', themeTitle: 'テーマ', - themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。' + themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。', + themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。` }, fieldLabels: defineFieldCopy({ model: 'デフォルトモデル', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 55f0691b2e18..5a4b9743a20c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -219,6 +219,7 @@ export interface Translations { technicalDesc: string themeTitle: string themeDesc: string + themeProfileNote: (profile: string) => string } fieldLabels: Record fieldDescriptions: Record diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 0556540d5c66..38c2ad00f9d2 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -209,7 +209,8 @@ export const zhHant = defineLocale({ technical: '技術', technicalDesc: '包含原始工具參數、結果與底層細節。', themeTitle: '主題', - themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。' + themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。', + themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。` }, fieldLabels: defineFieldCopy({ model: '預設模型', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index e3610272696c..82d3c478d3ac 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -287,7 +287,8 @@ export const zh: Translations = { technical: '技术', technicalDesc: '包含原始工具参数/结果及底层细节。', themeTitle: '主题', - themeDesc: '仅桌面端调色板。所选模式叠加其上。' + themeDesc: '仅桌面端调色板。所选模式叠加其上。', + themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。` }, fieldLabels: defineFieldCopy({ model: '默认模型', diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 62d71869ba1b..896e484055c2 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -9,15 +9,27 @@ * The two are persisted independently. Shift+X toggles light/dark. */ +import { useStore } from '@nanostores/react' import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query' +import { persistStringRecord, storedStringRecord } from '@/lib/storage' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets' import type { DesktopTheme, DesktopThemeColors } from './types' +// Legacy global skin (pre per-profile themes). Still the inheritance fallback +// for any profile without its own assignment, so single-profile users and old +// installs are unaffected. const SKIN_KEY = 'hermes-desktop-theme-v2' const MODE_KEY = 'hermes-desktop-mode-v1' +// Per-profile skin assignments: { [profileKey]: skinName }. A profile inherits +// the global default until it's given its own theme. +const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1' +// Last active profile, recorded so the boot-time paint can pick that profile's +// theme before the gateway reports which profile actually launched. +const LAST_PROFILE_KEY = 'hermes-desktop-active-profile-v1' const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold']) export type ThemeMode = 'light' | 'dark' | 'system' @@ -30,6 +42,55 @@ const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color- const normalizeSkin = (name: string | null | undefined): string => name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME +// ─── Per-profile skin persistence ────────────────────────────────────────── + +const readGlobalSkin = (): string | null => { + try { + return window.localStorage.getItem(SKIN_KEY) + } catch { + return null + } +} + +/** The skin to render for `profileKey`: its own assignment, else the global + * default (legacy `SKIN_KEY`), else the built-in default. */ +export function resolveSkinForProfile(profileKey: string): string { + const assigned = storedStringRecord(PROFILE_SKINS_KEY)[profileKey] + + return normalizeSkin(assigned ?? readGlobalSkin()) +} + +/** Assign `skin` to `profileKey`. The default profile also updates the legacy + * global skin so other profiles keep inheriting a sensible fallback and the + * boot paint stays consistent for single-profile installs. */ +export function assignSkinToProfile(profileKey: string, skin: string): void { + persistStringRecord(PROFILE_SKINS_KEY, { ...storedStringRecord(PROFILE_SKINS_KEY), [profileKey]: skin }) + + if (profileKey === 'default') { + try { + window.localStorage.setItem(SKIN_KEY, skin) + } catch { + // Storage is best-effort; the per-profile record above is the source of truth. + } + } +} + +const readBootProfileKey = (): string => { + try { + return normalizeProfileKey(window.localStorage.getItem(LAST_PROFILE_KEY)) + } catch { + return 'default' + } +} + +const rememberActiveProfileKey = (profileKey: string): void => { + try { + window.localStorage.setItem(LAST_PROFILE_KEY, profileKey) + } catch { + // Best-effort; a missing value just means the next boot paints the default. + } +} + // ─── Color math (for synthesised light variants of dark-only skins) ──────── function hexToRgb(hex: string): [number, number, number] | null { @@ -231,9 +292,10 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { } } -// Boot-time paint to avoid a flash before mounts. +// Boot-time paint to avoid a flash before mounts. Use the last +// active profile's theme so a non-default profile relaunch paints its own skin. if (typeof window !== 'undefined') { - const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY)) + const skin = resolveSkinForProfile(readBootProfileKey()) const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light' const resolved = resolveMode(mode) applyTheme(deriveTheme(skin, resolved), resolved) @@ -264,14 +326,25 @@ const ThemeContext = createContext({ }) export function ThemeProvider({ children }: { children: ReactNode }) { + // Themes are assigned per profile; the active profile drives which skin shows. + // Single-profile users only ever see "default", so their behavior is unchanged. + const profileKey = normalizeProfileKey(useStore($activeGatewayProfile)) + const [themeName, setThemeNameState] = useState(() => - typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY)) + typeof window === 'undefined' ? DEFAULT_SKIN_NAME : resolveSkinForProfile(readBootProfileKey()) ) const [mode, setModeState] = useState(() => typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light') ) + // Follow profile switches: paint the profile's assigned theme and remember it + // for the next boot's first paint. + useEffect(() => { + rememberActiveProfileKey(profileKey) + setThemeNameState(resolveSkinForProfile(profileKey)) + }, [profileKey]) + const systemDark = useMediaQuery('(prefers-color-scheme: dark)') const resolvedMode = resolveMode(mode, systemDark) const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode]) @@ -281,7 +354,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const setTheme = useCallback((name: string) => { const next = normalizeSkin(name) setThemeNameState(next) - window.localStorage.setItem(SKIN_KEY, next) + // Assign to whichever profile is live right now (read fresh so the callback + // stays stable across profile switches). + assignSkinToProfile(normalizeProfileKey($activeGatewayProfile.get()), next) }, []) const setMode = useCallback((next: ThemeMode) => { diff --git a/apps/desktop/src/themes/profile-theme.test.ts b/apps/desktop/src/themes/profile-theme.test.ts new file mode 100644 index 000000000000..f0b15328d46e --- /dev/null +++ b/apps/desktop/src/themes/profile-theme.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { assignSkinToProfile, resolveSkinForProfile } from './context' +import { DEFAULT_SKIN_NAME } from './presets' + +describe('per-profile theme resolution', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('falls back to the built-in default when nothing is assigned', () => { + expect(resolveSkinForProfile('default')).toBe(DEFAULT_SKIN_NAME) + expect(resolveSkinForProfile('work')).toBe(DEFAULT_SKIN_NAME) + }) + + it('keeps each profile on its own assigned skin', () => { + assignSkinToProfile('work', 'ember') + assignSkinToProfile('default', 'midnight') + + expect(resolveSkinForProfile('work')).toBe('ember') + expect(resolveSkinForProfile('default')).toBe('midnight') + }) + + it('lets an unassigned profile inherit the default profile as the global fallback', () => { + // Setting the default profile also seeds the legacy global skin, so any + // profile without its own assignment inherits it rather than the built-in. + assignSkinToProfile('default', 'mono') + + expect(resolveSkinForProfile('never-themed')).toBe('mono') + }) + + it('normalizes an unknown stored skin back to the default', () => { + assignSkinToProfile('work', 'not-a-real-skin') + + expect(resolveSkinForProfile('work')).toBe(DEFAULT_SKIN_NAME) + }) +}) From 4cb627a6048a2c6f4e05ab4acbec9262d6dd10c0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 8 Jun 2026 12:33:53 -0500 Subject: [PATCH 2/4] feat(desktop): make light/dark mode per profile too The command palette / theme picker sets skin + mode together on each pick, so leaving mode global meant a profile couldn't actually remember the full look it was given (e.g. "Ember Dark" in one profile would render Ember Light if another profile last flipped the global mode). Mirror the per-profile skin record for light/dark mode: ThemeProvider resolves and applies the active profile's mode on switch, the boot paint uses it, and setMode assigns to the live profile (default profile also seeds the legacy global mode fallback). --- apps/desktop/src/themes/context.tsx | 64 +++++++++++++++---- apps/desktop/src/themes/profile-theme.test.ts | 38 ++++++++++- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 896e484055c2..36a848cc3b83 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -24,9 +24,10 @@ import type { DesktopTheme, DesktopThemeColors } from './types' // installs are unaffected. const SKIN_KEY = 'hermes-desktop-theme-v2' const MODE_KEY = 'hermes-desktop-mode-v1' -// Per-profile skin assignments: { [profileKey]: skinName }. A profile inherits -// the global default until it's given its own theme. +// Per-profile skin + light/dark mode assignments: { [profileKey]: value }. A +// profile inherits the global default until it's given its own appearance. const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1' +const PROFILE_MODES_KEY = 'hermes-desktop-profile-modes-v1' // Last active profile, recorded so the boot-time paint can pick that profile's // theme before the gateway reports which profile actually launched. const LAST_PROFILE_KEY = 'hermes-desktop-active-profile-v1' @@ -75,6 +76,40 @@ export function assignSkinToProfile(profileKey: string, skin: string): void { } } +const normalizeMode = (value: string | null | undefined): ThemeMode => + value === 'light' || value === 'dark' || value === 'system' ? value : 'light' + +const readGlobalMode = (): string | null => { + try { + return window.localStorage.getItem(MODE_KEY) + } catch { + return null + } +} + +/** The light/dark mode to render for `profileKey`: its own assignment, else the + * global default (legacy `MODE_KEY`), else light. */ +export function resolveModeForProfile(profileKey: string): ThemeMode { + const assigned = storedStringRecord(PROFILE_MODES_KEY)[profileKey] + + return normalizeMode(assigned ?? readGlobalMode()) +} + +/** Assign `mode` to `profileKey`. The default profile also updates the legacy + * global mode so other profiles keep inheriting a sensible fallback and the + * boot paint stays consistent for single-profile installs. */ +export function assignModeToProfile(profileKey: string, mode: ThemeMode): void { + persistStringRecord(PROFILE_MODES_KEY, { ...storedStringRecord(PROFILE_MODES_KEY), [profileKey]: mode }) + + if (profileKey === 'default') { + try { + window.localStorage.setItem(MODE_KEY, mode) + } catch { + // Storage is best-effort; the per-profile record above is the source of truth. + } + } +} + const readBootProfileKey = (): string => { try { return normalizeProfileKey(window.localStorage.getItem(LAST_PROFILE_KEY)) @@ -293,11 +328,12 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { } // Boot-time paint to avoid a flash before mounts. Use the last -// active profile's theme so a non-default profile relaunch paints its own skin. +// active profile's appearance so a non-default profile relaunch paints its own +// skin + light/dark mode. if (typeof window !== 'undefined') { - const skin = resolveSkinForProfile(readBootProfileKey()) - const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light' - const resolved = resolveMode(mode) + const bootProfile = readBootProfileKey() + const skin = resolveSkinForProfile(bootProfile) + const resolved = resolveMode(resolveModeForProfile(bootProfile)) applyTheme(deriveTheme(skin, resolved), resolved) } @@ -326,8 +362,9 @@ const ThemeContext = createContext({ }) export function ThemeProvider({ children }: { children: ReactNode }) { - // Themes are assigned per profile; the active profile drives which skin shows. - // Single-profile users only ever see "default", so their behavior is unchanged. + // Skin + mode are assigned per profile; the active profile drives which + // appearance shows. Single-profile users only ever see "default", so their + // behavior is unchanged. const profileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const [themeName, setThemeNameState] = useState(() => @@ -335,14 +372,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) { ) const [mode, setModeState] = useState(() => - typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light') + typeof window === 'undefined' ? 'light' : resolveModeForProfile(readBootProfileKey()) ) - // Follow profile switches: paint the profile's assigned theme and remember it - // for the next boot's first paint. + // Follow profile switches: paint the profile's assigned skin + mode and + // remember it for the next boot's first paint. useEffect(() => { rememberActiveProfileKey(profileKey) setThemeNameState(resolveSkinForProfile(profileKey)) + setModeState(resolveModeForProfile(profileKey)) }, [profileKey]) const systemDark = useMediaQuery('(prefers-color-scheme: dark)') @@ -361,7 +399,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const setMode = useCallback((next: ThemeMode) => { setModeState(next) - window.localStorage.setItem(MODE_KEY, next) + // Assign to whichever profile is live right now (read fresh so the callback + // stays stable across profile switches). + assignModeToProfile(normalizeProfileKey($activeGatewayProfile.get()), next) }, []) // The light/dark toggle (Shift+X by default) is owned by the keybind runtime diff --git a/apps/desktop/src/themes/profile-theme.test.ts b/apps/desktop/src/themes/profile-theme.test.ts index f0b15328d46e..a0266eb48822 100644 --- a/apps/desktop/src/themes/profile-theme.test.ts +++ b/apps/desktop/src/themes/profile-theme.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { assignSkinToProfile, resolveSkinForProfile } from './context' +import { + assignModeToProfile, + assignSkinToProfile, + resolveModeForProfile, + resolveSkinForProfile +} from './context' import { DEFAULT_SKIN_NAME } from './presets' describe('per-profile theme resolution', () => { @@ -35,3 +40,34 @@ describe('per-profile theme resolution', () => { expect(resolveSkinForProfile('work')).toBe(DEFAULT_SKIN_NAME) }) }) + +describe('per-profile light/dark mode resolution', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('falls back to light when nothing is assigned', () => { + expect(resolveModeForProfile('default')).toBe('light') + expect(resolveModeForProfile('work')).toBe('light') + }) + + it('keeps each profile on its own assigned mode', () => { + assignModeToProfile('work', 'dark') + assignModeToProfile('default', 'system') + + expect(resolveModeForProfile('work')).toBe('dark') + expect(resolveModeForProfile('default')).toBe('system') + }) + + it('lets an unassigned profile inherit the default profile as the global fallback', () => { + assignModeToProfile('default', 'dark') + + expect(resolveModeForProfile('never-themed')).toBe('dark') + }) + + it('normalizes an unknown stored mode back to light', () => { + assignModeToProfile('work', 'midnight-mode' as never) + + expect(resolveModeForProfile('work')).toBe('light') + }) +}) From 3f886def989727a5a4b70780c16542883ddbd284 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 8 Jun 2026 12:36:26 -0500 Subject: [PATCH 3/4] refactor(desktop): collapse per-profile skin/mode into one helper Skin and mode were near-identical resolve/assign pairs with hand-rolled try/catch around localStorage. Fold both into a single profilePref factory (resolve + assign, default profile seeds the legacy global) and lean on storedString/persistString for the error-swallowing. Tests go table-driven over both prefs since they share one contract. No behavior change; -89 LOC. --- apps/desktop/src/themes/context.tsx | 127 +++++------------- apps/desktop/src/themes/profile-theme.test.ts | 84 ++++-------- 2 files changed, 61 insertions(+), 150 deletions(-) diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 36a848cc3b83..babd1c2d0a41 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -13,7 +13,7 @@ import { useStore } from '@nanostores/react' import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query' -import { persistStringRecord, storedStringRecord } from '@/lib/storage' +import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets' @@ -40,91 +40,35 @@ const INJECTED_FONT_URLS = new Set() const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' => mode === 'system' ? (systemDark ? 'dark' : 'light') : mode -const normalizeSkin = (name: string | null | undefined): string => +const normalizeSkin = (name: string | null): string => name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME -// ─── Per-profile skin persistence ────────────────────────────────────────── - -const readGlobalSkin = (): string | null => { - try { - return window.localStorage.getItem(SKIN_KEY) - } catch { - return null - } -} - -/** The skin to render for `profileKey`: its own assignment, else the global - * default (legacy `SKIN_KEY`), else the built-in default. */ -export function resolveSkinForProfile(profileKey: string): string { - const assigned = storedStringRecord(PROFILE_SKINS_KEY)[profileKey] - - return normalizeSkin(assigned ?? readGlobalSkin()) -} - -/** Assign `skin` to `profileKey`. The default profile also updates the legacy - * global skin so other profiles keep inheriting a sensible fallback and the - * boot paint stays consistent for single-profile installs. */ -export function assignSkinToProfile(profileKey: string, skin: string): void { - persistStringRecord(PROFILE_SKINS_KEY, { ...storedStringRecord(PROFILE_SKINS_KEY), [profileKey]: skin }) - - if (profileKey === 'default') { - try { - window.localStorage.setItem(SKIN_KEY, skin) - } catch { - // Storage is best-effort; the per-profile record above is the source of truth. - } - } -} - -const normalizeMode = (value: string | null | undefined): ThemeMode => +const normalizeMode = (value: string | null): ThemeMode => value === 'light' || value === 'dark' || value === 'system' ? value : 'light' -const readGlobalMode = (): string | null => { - try { - return window.localStorage.getItem(MODE_KEY) - } catch { - return null - } -} - -/** The light/dark mode to render for `profileKey`: its own assignment, else the - * global default (legacy `MODE_KEY`), else light. */ -export function resolveModeForProfile(profileKey: string): ThemeMode { - const assigned = storedStringRecord(PROFILE_MODES_KEY)[profileKey] - - return normalizeMode(assigned ?? readGlobalMode()) -} - -/** Assign `mode` to `profileKey`. The default profile also updates the legacy - * global mode so other profiles keep inheriting a sensible fallback and the - * boot paint stays consistent for single-profile installs. */ -export function assignModeToProfile(profileKey: string, mode: ThemeMode): void { - persistStringRecord(PROFILE_MODES_KEY, { ...storedStringRecord(PROFILE_MODES_KEY), [profileKey]: mode }) - - if (profileKey === 'default') { - try { - window.localStorage.setItem(MODE_KEY, mode) - } catch { - // Storage is best-effort; the per-profile record above is the source of truth. +// ─── Per-profile appearance persistence ───────────────────────────────────── +// Skin and mode are each stored per profile, each falling back to a legacy +// global value the default profile keeps seeded — so unassigned profiles +// inherit a sane default and pre-per-profile installs are untouched. +const profilePref = (record: string, legacy: string, normalize: (v: string | null) => T) => ({ + resolve: (profile: string): T => normalize(storedStringRecord(record)[profile] ?? storedString(legacy)), + assign: (profile: string, value: T): void => { + persistStringRecord(record, { ...storedStringRecord(record), [profile]: value }) + + // The default profile keeps the legacy global fresh so others inherit it. + if (profile === 'default') { + persistString(legacy, value) } } -} +}) -const readBootProfileKey = (): string => { - try { - return normalizeProfileKey(window.localStorage.getItem(LAST_PROFILE_KEY)) - } catch { - return 'default' - } -} +export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin) +export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode) -const rememberActiveProfileKey = (profileKey: string): void => { - try { - window.localStorage.setItem(LAST_PROFILE_KEY, profileKey) - } catch { - // Best-effort; a missing value just means the next boot paints the default. - } -} +// Last active profile — lets the boot paint pick its appearance before the +// gateway reports which profile actually launched. +const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_KEY)) +const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile) // ─── Color math (for synthesised light variants of dark-only skins) ──────── @@ -331,10 +275,9 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { // active profile's appearance so a non-default profile relaunch paints its own // skin + light/dark mode. if (typeof window !== 'undefined') { - const bootProfile = readBootProfileKey() - const skin = resolveSkinForProfile(bootProfile) - const resolved = resolveMode(resolveModeForProfile(bootProfile)) - applyTheme(deriveTheme(skin, resolved), resolved) + const profile = readBootProfileKey() + const resolved = resolveMode(modePref.resolve(profile)) + applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved) } // ─── Context ──────────────────────────────────────────────────────────────── @@ -368,19 +311,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const profileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const [themeName, setThemeNameState] = useState(() => - typeof window === 'undefined' ? DEFAULT_SKIN_NAME : resolveSkinForProfile(readBootProfileKey()) + typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey()) ) const [mode, setModeState] = useState(() => - typeof window === 'undefined' ? 'light' : resolveModeForProfile(readBootProfileKey()) + typeof window === 'undefined' ? 'light' : modePref.resolve(readBootProfileKey()) ) // Follow profile switches: paint the profile's assigned skin + mode and // remember it for the next boot's first paint. useEffect(() => { rememberActiveProfileKey(profileKey) - setThemeNameState(resolveSkinForProfile(profileKey)) - setModeState(resolveModeForProfile(profileKey)) + setThemeNameState(skinPref.resolve(profileKey)) + setModeState(modePref.resolve(profileKey)) }, [profileKey]) const systemDark = useMediaQuery('(prefers-color-scheme: dark)') @@ -389,19 +332,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) { useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode]) + // Assign to whichever profile is live right now (read fresh so the callbacks + // stay stable across profile switches). + const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get()) + const setTheme = useCallback((name: string) => { const next = normalizeSkin(name) setThemeNameState(next) - // Assign to whichever profile is live right now (read fresh so the callback - // stays stable across profile switches). - assignSkinToProfile(normalizeProfileKey($activeGatewayProfile.get()), next) + skinPref.assign(liveProfile(), next) }, []) const setMode = useCallback((next: ThemeMode) => { setModeState(next) - // Assign to whichever profile is live right now (read fresh so the callback - // stays stable across profile switches). - assignModeToProfile(normalizeProfileKey($activeGatewayProfile.get()), next) + modePref.assign(liveProfile(), next) }, []) // The light/dark toggle (Shift+X by default) is owned by the keybind runtime diff --git a/apps/desktop/src/themes/profile-theme.test.ts b/apps/desktop/src/themes/profile-theme.test.ts index a0266eb48822..7f2809f71bdc 100644 --- a/apps/desktop/src/themes/profile-theme.test.ts +++ b/apps/desktop/src/themes/profile-theme.test.ts @@ -1,73 +1,41 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { - assignModeToProfile, - assignSkinToProfile, - resolveModeForProfile, - resolveSkinForProfile -} from './context' +import { modePref, skinPref } from './context' import { DEFAULT_SKIN_NAME } from './presets' -describe('per-profile theme resolution', () => { - beforeEach(() => { - window.localStorage.clear() - }) - - it('falls back to the built-in default when nothing is assigned', () => { - expect(resolveSkinForProfile('default')).toBe(DEFAULT_SKIN_NAME) - expect(resolveSkinForProfile('work')).toBe(DEFAULT_SKIN_NAME) - }) - - it('keeps each profile on its own assigned skin', () => { - assignSkinToProfile('work', 'ember') - assignSkinToProfile('default', 'midnight') - - expect(resolveSkinForProfile('work')).toBe('ember') - expect(resolveSkinForProfile('default')).toBe('midnight') - }) - - it('lets an unassigned profile inherit the default profile as the global fallback', () => { - // Setting the default profile also seeds the legacy global skin, so any - // profile without its own assignment inherits it rather than the built-in. - assignSkinToProfile('default', 'mono') - - expect(resolveSkinForProfile('never-themed')).toBe('mono') - }) - - it('normalizes an unknown stored skin back to the default', () => { - assignSkinToProfile('work', 'not-a-real-skin') +// Skin and mode share one per-profile contract, so assert it once over both. +interface Pref { + resolve: (profile: string) => string + assign: (profile: string, value: string) => void +} - expect(resolveSkinForProfile('work')).toBe(DEFAULT_SKIN_NAME) - }) -}) +const cases = [ + { name: 'skin', pref: skinPref as unknown as Pref, fallback: DEFAULT_SKIN_NAME, a: 'ember', b: 'midnight', junk: 'nope' }, + { name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' } +] -describe('per-profile light/dark mode resolution', () => { - beforeEach(() => { - window.localStorage.clear() - }) +describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => { + beforeEach(() => window.localStorage.clear()) - it('falls back to light when nothing is assigned', () => { - expect(resolveModeForProfile('default')).toBe('light') - expect(resolveModeForProfile('work')).toBe('light') + it('falls back to the default when unassigned', () => { + expect(pref.resolve('default')).toBe(fallback) + expect(pref.resolve('work')).toBe(fallback) }) - it('keeps each profile on its own assigned mode', () => { - assignModeToProfile('work', 'dark') - assignModeToProfile('default', 'system') - - expect(resolveModeForProfile('work')).toBe('dark') - expect(resolveModeForProfile('default')).toBe('system') + it('keeps each profile on its own value', () => { + pref.assign('work', a) + pref.assign('default', b) + expect(pref.resolve('work')).toBe(a) + expect(pref.resolve('default')).toBe(b) }) - it('lets an unassigned profile inherit the default profile as the global fallback', () => { - assignModeToProfile('default', 'dark') - - expect(resolveModeForProfile('never-themed')).toBe('dark') + it('lets unassigned profiles inherit the default profile as the global fallback', () => { + pref.assign('default', a) + expect(pref.resolve('never-themed')).toBe(a) }) - it('normalizes an unknown stored mode back to light', () => { - assignModeToProfile('work', 'midnight-mode' as never) - - expect(resolveModeForProfile('work')).toBe('light') + it('normalizes an unknown stored value back to the default', () => { + pref.assign('work', junk) + expect(pref.resolve('work')).toBe(fallback) }) }) From bfc19ddd9019f9f006436b93f61dce397fa0b231 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 8 Jun 2026 12:37:03 -0500 Subject: [PATCH 4/4] refactor(desktop): treat default profile as the global slot directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "default" isn't a real profile — it is the legacy global value. Stop double-writing (record['default'] + global) on assign; route default straight to the global. resolve is unchanged: a profile with no record entry already falls back to the global, so default reads it for free. --- apps/desktop/src/themes/context.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index babd1c2d0a41..0f117213819e 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -47,17 +47,17 @@ const normalizeMode = (value: string | null): ThemeMode => value === 'light' || value === 'dark' || value === 'system' ? value : 'light' // ─── Per-profile appearance persistence ───────────────────────────────────── -// Skin and mode are each stored per profile, each falling back to a legacy -// global value the default profile keeps seeded — so unassigned profiles -// inherit a sane default and pre-per-profile installs are untouched. +// Skin and mode are each stored per profile. "default" isn't a real profile — +// it *is* the legacy global slot, so it reads/writes the global directly. Named +// profiles get their own entry and fall back to that global until assigned, so +// unassigned profiles and pre-per-profile installs stay on the global value. const profilePref = (record: string, legacy: string, normalize: (v: string | null) => T) => ({ resolve: (profile: string): T => normalize(storedStringRecord(record)[profile] ?? storedString(legacy)), assign: (profile: string, value: T): void => { - persistStringRecord(record, { ...storedStringRecord(record), [profile]: value }) - - // The default profile keeps the legacy global fresh so others inherit it. if (profile === 'default') { persistString(legacy, value) + } else { + persistStringRecord(record, { ...storedStringRecord(record), [profile]: value }) } } })