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:
@@ -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,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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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` |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user