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])