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

import { __resetBackendSkinSync, ingestBackendSkin } from './backend-sync'
import { ThemeProvider } from './context'

// 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.
const bloomberg = (foreground: string) => ({
name: 'bloomberg',
colors: { background: '#000000', ui_text: foreground, ui_accent: '#ff8000' }
})

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

describe('ThemeProvider ← backend skin sync', () => {
beforeEach(() => {
window.localStorage.clear()
__resetBackendSkinSync()
})

afterEach(cleanup)

it('applies an activated backend skin', () => {
render(
<ThemeProvider>
<div />
</ThemeProvider>
)

act(() => ingestBackendSkin(bloomberg('#ff9f0a'), { apply: true }))

expect(cssVar('--theme-foreground')).toBe('#ff9f0a')
expect(cssVar('--theme-background-seed')).toBe('#000000')
})

it('repaints an in-place edit of the ACTIVE skin (same name, new palette)', () => {
render(
<ThemeProvider>
<div />
</ThemeProvider>
)

act(() => ingestBackendSkin(bloomberg('#ff9f0a'), { apply: true }))
expect(cssVar('--theme-foreground')).toBe('#ff9f0a')

// Recolor the same skin file. The same-name apply guard correctly no-ops
// (protects manual desktop picks), so the repaint must come from the
// registry update reaching the active theme derivation.
act(() => ingestBackendSkin(bloomberg('#ff2d95'), { apply: true }))
expect(cssVar('--theme-foreground')).toBe('#ff2d95')
})

it('does not repaint an edit to an INACTIVE skin', () => {
render(
<ThemeProvider>
<div />
</ThemeProvider>
)

act(() => ingestBackendSkin(bloomberg('#ff9f0a'), { apply: true }))

// A different skin registered without apply (e.g. seeded on reconnect)
// must not touch the painted theme.
act(() =>
ingestBackendSkin({ name: 'forest', colors: { background: '#001100', ui_text: '#66ff66' } }, { apply: false })
)
expect(cssVar('--theme-foreground')).toBe('#ff9f0a')
})
})
10 changes: 9 additions & 1 deletion apps/desktop/src/themes/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {

const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])

const activeTheme = useMemo(
() => deriveTheme(themeName, resolvedMode),
// deriveTheme resolves its seed through the merged registry, so the theme
// stores are its reactivity too — an in-place palette edit of the ACTIVE
// skin (live theme authoring) must repaint, not just a name switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
[themeName, resolvedMode, userThemes, backendThemes, registryVersion]
)

// What actually gets painted (matches the `.dark` class applyTheme toggles).
const renderedMode = useMemo(() => renderedModeFor(activeTheme.colors, resolvedMode), [activeTheme, resolvedMode])
Expand Down
Loading