Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 52 additions & 35 deletions apps/desktop/src/app/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -98,43 +108,50 @@ export function AppearanceSettings() {

<ListRow
below={
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name

return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
<>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name

return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
</button>
)
})}
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
}
description={a.themeDesc}
title={a.themeTitle}
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ export const ja = defineLocale({
technical: 'テクニカル',
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export interface Translations {
technicalDesc: string
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ export const zhHant = defineLocale({
technical: '技術',
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
},
fieldLabels: defineFieldCopy({
model: '預設模型',
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ export const zh: Translations = {
technical: '技术',
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
},
fieldLabels: defineFieldCopy({
model: '默认模型',
Expand Down
78 changes: 68 additions & 10 deletions apps/desktop/src/themes/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -27,9 +40,36 @@ const INJECTED_FONT_URLS = new Set<string>()
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 = <T extends string>(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 {
Expand Down Expand Up @@ -231,12 +271,13 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
}
}

// Boot-time paint to avoid a flash before <ThemeProvider> mounts.
// Boot-time paint to avoid a flash before <ThemeProvider> 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 ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -264,29 +305,46 @@ const ThemeContext = createContext<ThemeContextValue>({
})

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<ThemeMode>(() =>
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
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/themes/profile-theme.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading