diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index eb2489209cf..ae145c8c612 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 + <> +
+ {availableThemes.map(theme => { + const active = themeName === theme.name - return ( - - ) - })} -
+ key={theme.name} + onClick={() => { + triggerHaptic('crisp') + setTheme(theme.name) + }} + type="button" + > + +
+
+
+ {theme.label} +
+
+ {theme.description} +
+
+ {active && ( + + + + )} +
+ + ) + })} + + {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 62ddb4fd581..7eedaee2524 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 f2f4f5effa4..5e5865fb900 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 55f0691b2e1..5a4b9743a20 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 0556540d5c6..38c2ad00f9d 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 e3610272696..82d3c478d3a 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 62d71869ba1..0f117213819 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -9,15 +9,28 @@ * 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 { 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' 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 + 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' const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold']) export type ThemeMode = 'light' | 'dark' | 'system' @@ -27,9 +40,36 @@ 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 +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. "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 => { + if (profile === 'default') { + persistString(legacy, value) + } else { + persistStringRecord(record, { ...storedStringRecord(record), [profile]: value }) + } + } +}) + +export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin) +export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode) + +// 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) ──────── function hexToRgb(hex: string): [number, number, number] | null { @@ -231,12 +271,13 @@ 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 appearance so a non-default profile relaunch paints its own +// skin + light/dark mode. if (typeof window !== 'undefined') { - const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY)) - const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light' - const resolved = resolveMode(mode) - applyTheme(deriveTheme(skin, resolved), resolved) + const profile = readBootProfileKey() + const resolved = resolveMode(modePref.resolve(profile)) + applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved) } // ─── Context ──────────────────────────────────────────────────────────────── @@ -264,29 +305,46 @@ const ThemeContext = createContext({ }) export function ThemeProvider({ children }: { children: ReactNode }) { + // 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(() => - typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY)) + typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey()) ) const [mode, setModeState] = useState(() => - typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light') + 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(skinPref.resolve(profileKey)) + setModeState(modePref.resolve(profileKey)) + }, [profileKey]) + const systemDark = useMediaQuery('(prefers-color-scheme: dark)') const resolvedMode = resolveMode(mode, systemDark) const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode]) 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) - window.localStorage.setItem(SKIN_KEY, next) + skinPref.assign(liveProfile(), next) }, []) const setMode = useCallback((next: ThemeMode) => { setModeState(next) - window.localStorage.setItem(MODE_KEY, 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 new file mode 100644 index 00000000000..7f2809f71bd --- /dev/null +++ b/apps/desktop/src/themes/profile-theme.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { modePref, skinPref } from './context' +import { DEFAULT_SKIN_NAME } from './presets' + +// 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 +} + +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.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => { + beforeEach(() => window.localStorage.clear()) + + 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 value', () => { + pref.assign('work', a) + pref.assign('default', b) + expect(pref.resolve('work')).toBe(a) + expect(pref.resolve('default')).toBe(b) + }) + + 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 value back to the default', () => { + pref.assign('work', junk) + expect(pref.resolve('work')).toBe(fallback) + }) +})