diff --git a/apps/desktop/src/themes/context.test.tsx b/apps/desktop/src/themes/context.test.tsx new file mode 100644 index 000000000000..860bacfab084 --- /dev/null +++ b/apps/desktop/src/themes/context.test.tsx @@ -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( + +
+ + ) + + 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( + +
+ + ) + + 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( + +
+ + ) + + 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') + }) +}) diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 7266d8f70fbd..e618534c3107 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -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])