feat(desktop): let plugins switch the theme from outside React

The SDK could contribute a theme through `THEMES_AREA` but never select one,
and `useTheme().setTheme` needs a component to hang the hook on. A plugin that
repaints on an event — a gateway coming up, a socket message — had nowhere to
call, so shipping one meant patching the app and re-patching it after every
upgrade.

`requestTheme(name)` writes to the same one-shot channel the backend `/skin`
sync already uses, so the ThemeProvider drains it through `setTheme` and an
imperative switch normalizes and persists per profile exactly like a manual
pick — one policy, one owner.

An unresolvable name is refused rather than coerced. `setTheme` falls back to
the default skin, which is right for a person picking off a list and wrong for
code reacting to an event: a gateway naming a theme the user never installed
would silently reset an appearance the caller never meant to touch. The
returned boolean doubles as the availability check.
This commit is contained in:
Brooklyn Nicholson
2026-08-20 13:30:31 -05:00
parent 63c6d9a45c
commit 2e1e3cc91d
4 changed files with 136 additions and 1 deletions

View File

@@ -1089,8 +1089,14 @@ export {
oklchToSrgb255,
readableOn
} from '@/themes/color'
/** The painted theme, its name, and the appearance it resolved to. */
/** The painted theme, its name, and the appearance it resolved to — plus
* `setTheme` / `setMode` to change it from a component. */
export { useTheme } from '@/themes/context'
/** Switch the theme from outside React (a gateway event, a connection coming
* up, any callback with no component around it). Returns false and leaves the
* appearance alone when the name doesn't resolve, so it doubles as the "is
* this theme installed?" check. */
export { requestTheme } from '@/themes/request'
export { retintTheme, themeHue } from '@/themes/retint'
export type { DesktopTheme, DesktopThemeColors } from '@/themes/types'
export { THEMES_AREA } from '@/themes/user-themes'

View File

@@ -1,6 +1,7 @@
export { ingestBackendSkin } from './backend-sync'
export { ThemeProvider, useTheme } from './context'
export { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
export { requestTheme } from './request'
export { skinToDesktopTheme } from './skin'
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
export type { HermesSkin } from '@hermes/shared/skin'

View File

@@ -0,0 +1,94 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { __resetBackendSkinSync } from './backend-sync'
import { skinPref, ThemeProvider, useTheme } from './context'
import { midnightTheme } from './presets'
import { requestTheme } from './request'
import type { DesktopTheme } from './types'
import { THEMES_AREA } from './user-themes'
const cssVar = (name: string) => window.document.documentElement.style.getPropertyValue(name)
describe('requestTheme', () => {
let ctx: ReturnType<typeof useTheme>
function Probe() {
ctx = useTheme()
return null
}
const renderProbe = () =>
render(
<ThemeProvider>
<Probe />
</ThemeProvider>
)
beforeEach(() => {
window.localStorage.clear()
__resetBackendSkinSync()
})
afterEach(cleanup)
it('switches the painted theme from outside React', () => {
renderProbe()
let accepted = false
act(() => {
accepted = requestTheme('mono')
})
expect(accepted).toBe(true)
expect(ctx.themeName).toBe('mono')
})
// The imperative door must land in the same place the React one does, or a
// plugin-driven switch would evaporate on the next profile read.
it('persists per profile like a manual pick', () => {
renderProbe()
act(() => void requestTheme('midnight'))
expect(skinPref.resolve('default')).toBe('midnight')
})
it('refuses a name that does not resolve, leaving the appearance untouched', () => {
renderProbe()
act(() => void requestTheme('mono'))
const painted = cssVar('--theme-foreground')
let accepted = true
act(() => {
accepted = requestTheme('a-theme-nobody-installed')
})
expect(accepted).toBe(false)
expect(ctx.themeName).toBe('mono')
expect(cssVar('--theme-foreground')).toBe(painted)
})
// The whole plugin loop: contribute a palette through THEMES_AREA, then
// activate it on an event with no component in scope.
it('activates a theme contributed through the registry', () => {
const zeus: DesktopTheme = { ...midnightTheme, description: 'Zeus', label: 'Zeus', name: 'zeus' }
const dispose = registry.register({ area: THEMES_AREA, data: zeus, id: 'zeus' })
renderProbe()
let accepted = false
act(() => {
accepted = requestTheme('zeus')
})
expect(accepted).toBe(true)
expect(ctx.themeName).toBe('zeus')
dispose()
})
})

View File

@@ -0,0 +1,34 @@
/**
* Imperative theme switching, for callers with no component to hang a hook on.
*
* `useTheme().setTheme` is the React door and covers every in-app surface, but
* not every switch happens during a render. The backend announces a `/skin`
* change over the gateway, and a plugin may want to repaint when a connection
* comes up or a background event fires. Those callers write a name to
* `$pendingSkinApply`; the ThemeProvider drains it through `setTheme`, so an
* imperative switch normalizes, persists per profile, and drops any preview
* exactly like a manual pick — one policy, one owner.
*/
import { $pendingSkinApply } from './backend-sync'
import { resolveTheme } from './user-themes'
/**
* Ask for a theme switch from outside React. Returns whether the name resolved.
*
* An unknown name is refused rather than coerced. `setTheme` falls back to the
* default skin for anything it can't resolve, which is right for a person
* picking from a list but wrong for code reacting to an event: a gateway naming
* a theme the user never installed would silently reset their appearance
* instead of leaving it alone. The boolean doubles as the availability check,
* so a caller needs no separate lookup to know it got what it asked for.
*/
export function requestTheme(name: string): boolean {
if (!resolveTheme(name)) {
return false
}
$pendingSkinApply.set(name)
return true
}