Skip to content
Open
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
75 changes: 73 additions & 2 deletions apps/desktop/src/themes/context.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { act, cleanup, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { registry } from '@/contrib/registry'
import { $activeGatewayProfile } from '@/store/profile'

import { __resetBackendSkinSync, ingestBackendSkin } from './backend-sync'
import { skinPref, ThemeProvider, useTheme } from './context'
import { everforestTheme } from './presets'
import { DEFAULT_SKIN_NAME, everforestTheme, nousTheme } from './presets'

// The live-authoring loop: Hermes writes/edits one skin file and every surface
// repaints. An in-place edit keeps the NAME — only the palette moves.
Expand All @@ -12,6 +15,14 @@ const bloomberg = (foreground: string) => ({
colors: { background: '#000000', ui_text: foreground, ui_accent: '#ff8000' }
})

const pluginTheme = {
...nousTheme,
name: 'plugin-neon',
label: 'Plugin Neon',
description: 'Runtime SDK test theme',
colors: { ...nousTheme.colors, foreground: '#12ff99' }
}

const cssVar = (name: string) => window.document.documentElement.style.getPropertyValue(name)

describe('ThemeProvider ← backend skin sync', () => {
Expand Down Expand Up @@ -138,3 +149,63 @@ describe('ThemeProvider highlight preview', () => {
expect(cssVar('--theme-foreground')).toBe(painted)
})
})

describe('ThemeProvider late-bound persistence', () => {
let disposePluginTheme: (() => void) | null = null

beforeEach(() => {
window.localStorage.clear()
__resetBackendSkinSync()
$activeGatewayProfile.set('default')
})

afterEach(() => {
cleanup()
disposePluginTheme?.()
disposePluginTheme = null
$activeGatewayProfile.set('default')
})

function UnknownThemeButton() {
const { setTheme } = useTheme()

return <button onClick={() => setTheme('definitely-not-a-theme')}>Pick unknown</button>
}

it('repaints a persisted SDK skin when the runtime plugin registers after boot', () => {
window.localStorage.setItem('hermes-desktop-theme-v2', pluginTheme.name)

render(
<ThemeProvider>
<div />
</ThemeProvider>
)

expect(skinPref.resolve('default')).toBe(pluginTheme.name)
expect(cssVar('--theme-foreground')).toBe(nousTheme.colors.foreground)

act(() => {
disposePluginTheme = registry.register({
area: 'themes',
id: 'test:plugin-neon',
source: 'plugin:test',
data: pluginTheme
})
})

expect(cssVar('--theme-foreground')).toBe('#12ff99')
})

it('keeps live theme selection strict even though persisted names are lenient', () => {
const view = render(
<ThemeProvider>
<UnknownThemeButton />
</ThemeProvider>
)

fireEvent.click(view.getByRole('button', { name: 'Pick unknown' }))

expect(skinPref.resolve('default')).toBe(DEFAULT_SKIN_NAME)
expect(window.localStorage.getItem('hermes-desktop-theme-v2')).toBe(DEFAULT_SKIN_NAME)
})
})
11 changes: 9 additions & 2 deletions apps/desktop/src/themes/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ 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

// Persisted names are intentionally late-bound: backend/YAML and runtime SDK
// themes register after the first paint. Keep the stored name so it can resolve
// reactively once its registry arrives, while retired/empty values still migrate
// to the default. Live selections remain strict through normalizeSkin().
const normalizeStoredSkin = (name: string | null): string =>
name && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME

const normalizeSkin = (name: string | null): string =>
name && resolveTheme(name) && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME

Expand Down Expand Up @@ -79,7 +86,7 @@ const profilePref = <T extends string>(record: string, legacy: string, normalize
}
})

export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin)
export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeStoredSkin)
export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode)

/** Everything a peer window could change that this one has to repaint for. */
Expand Down Expand Up @@ -515,4 +522,4 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
44 changes: 36 additions & 8 deletions apps/desktop/src/themes/profile-theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ 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.
// Skin and mode share the same per-profile storage contract for assignment and
// inheritance. Validation differs intentionally: stored skin names are
// late-bound, while mode values remain a closed enum.
interface Pref {
resolve: (profile: string) => string
assign: (profile: string, value: string) => void
Expand All @@ -15,13 +17,12 @@ const cases = [
pref: skinPref as unknown as Pref,
fallback: DEFAULT_SKIN_NAME,
a: 'ember',
b: 'catppuccin',
junk: 'nope'
b: 'catppuccin'
},
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'system', a: 'dark', b: 'light', junk: 'dusk' }
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'system', a: 'dark', b: 'light' }
]

describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => {
describe.each(cases)('per-profile $name', ({ pref, fallback, a, b }) => {
beforeEach(() => window.localStorage.clear())

it('falls back to the default when unassigned', () => {
Expand All @@ -40,10 +41,37 @@ describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => {
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)
describe('skin restart persistence', () => {
beforeEach(() => window.localStorage.clear())

it('preserves an unresolved global skin name for late backend registration', () => {
window.localStorage.setItem('hermes-desktop-theme-v2', 'trt')

expect(skinPref.resolve('default')).toBe('trt')
})

it('preserves an unresolved named-profile skin name for late SDK registration', () => {
window.localStorage.setItem('hermes-desktop-profile-themes-v1', JSON.stringify({ work: 'plugin-neon' }))

expect(skinPref.resolve('work')).toBe('plugin-neon')
})

it.each(['nous-light', 'default', 'gold'])('still migrates retired skin %s to the default', retired => {
window.localStorage.setItem('hermes-desktop-theme-v2', retired)

expect(skinPref.resolve('default')).toBe(DEFAULT_SKIN_NAME)
})
})

describe('mode persistence validation', () => {
beforeEach(() => window.localStorage.clear())

it('still normalizes an unknown stored mode back to the default', () => {
window.localStorage.setItem('hermes-desktop-profile-modes-v1', JSON.stringify({ work: 'dusk' }))

expect(modePref.resolve('work')).toBe('system')
})
})

Expand Down
Loading