diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 6b83836be6..34d78a645b 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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' diff --git a/apps/desktop/src/themes/index.ts b/apps/desktop/src/themes/index.ts index 62bbfe289d..9cf41d1638 100644 --- a/apps/desktop/src/themes/index.ts +++ b/apps/desktop/src/themes/index.ts @@ -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' diff --git a/apps/desktop/src/themes/request.test.tsx b/apps/desktop/src/themes/request.test.tsx new file mode 100644 index 0000000000..538599297e --- /dev/null +++ b/apps/desktop/src/themes/request.test.tsx @@ -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 + + function Probe() { + ctx = useTheme() + + return null + } + + const renderProbe = () => + render( + + + + ) + + 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() + }) +}) diff --git a/apps/desktop/src/themes/request.ts b/apps/desktop/src/themes/request.ts new file mode 100644 index 0000000000..e806c2d464 --- /dev/null +++ b/apps/desktop/src/themes/request.ts @@ -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 +} diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md index dcf1729879..ad50de468a 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md @@ -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 diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 5a4c0dc4ed..82f34f115b 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -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 => ( + + )) +} +``` + +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` |