feat(desktop): let plugins switch the theme, and document the theme surface (#91018)

* 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.

* docs: document the desktop plugin SDK theme surface

`useTheme`, the accent override, the retint helper and the OKLCH math reached
the SDK without reaching this page, which still documented `THEMES_AREA` alone.
Registering a theme only lists it in the picker, so the natural reading was that
plugins cannot switch themes at all — and the one person who tried concluded
exactly that and patched the app instead.

Documents the selection half: the hook for components, `requestTheme` for
callbacks with no component around them, and a Theming row in the export table.
The agent-facing reference gets the same note, since it never covered themes.
This commit is contained in:
brooklyn!
2026-08-20 13:36:08 -05:00
committed by GitHub
6 changed files with 183 additions and 2 deletions
+7 -1
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'
+1
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'
+94
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()
})
})
+34
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
}
@@ -81,7 +81,13 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
`'panes'` (layout zones — set `title` and
`data: { placement, dock?, width?, height? }`; the pane auto-joins a
matching zone), `PALETTE_AREA` (⌘K commands), `KEYBINDS_AREA` (rebindable
actions).
actions), `THEMES_AREA` (`data` is a full `DesktopTheme`).
- THEMES: registering one only lists it in the picker. Select it with
`useTheme().setTheme(name)` from a component, or `requestTheme(name)` from a
callback with no component around it (a gateway event, a socket handler).
`requestTheme` returns `false` for a name that doesn't resolve and leaves the
appearance alone, so use it as the availability check instead of coercing the
user back to the default skin.
- Pane placement: `placement: 'left'|'right'|'bottom'|'main'` is the
semantic role — the pane stacks (tabs) with existing panes of that role.
To land on a specific EDGE instead, add `dock: { pane, pos }` — the same
@@ -342,6 +342,45 @@ import { THEMES_AREA } from '@hermes/plugin-sdk'
ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme })
```
Registering a theme lists it; it does not select it. `useTheme()` reads the
painted appearance (`theme`, `themeName`, `availableThemes`, `resolvedMode`) and
changes it (`setTheme`, `setMode`, `previewTheme`) from a component:
```javascript
import { Button, useTheme } from '@hermes/plugin-sdk'
function ThemePicker() {
const { availableThemes, setTheme, themeName } = useTheme()
return availableThemes.map(t => (
<Button key={t.name} disabled={t.name === themeName} onClick={() => setTheme(t.name)}>
{t.label}
</Button>
))
}
```
A switch driven by something other than a render — a gateway connecting, a
socket event, any `host.onEvent` callback — has no component to hang the hook
on. Use `requestTheme(name)` there. An unresolvable name is refused rather than
coerced to the default skin, so the return value doubles as the availability
check and a wrong name can never silently reset someone's appearance:
```javascript
import { host, requestTheme } from '@hermes/plugin-sdk'
host.onEvent('gateway.ready', () => {
if (!requestTheme('noir')) {
host.notifyError('Connected, but the noir theme is not installed.')
}
})
```
Both doors persist per profile, so a plugin-driven switch sticks exactly like a
manual pick. To tint the *active* theme rather than replace it, use
`setAccentOverride(hex)` and clear it in `ctx.onDispose` — the bundled `accent`
plugin is the worked example.
### Composer extensions
`COMPOSER_AREAS` (`top`, `bottom`, `leading`, `actions`, `attachments`,
@@ -859,6 +898,7 @@ not treat this pipeline as a trust boundary.
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` |
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` |
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` |
| Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `contrastRatio`, `readableOn`, `normalizeHex`) |
| UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText` |
| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness` |