From 2e1e3cc91d69c8c96d88aed248c8cbb0cb57e2f1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 13:30:31 -0500 Subject: [PATCH] feat(desktop): let plugins switch the theme from outside React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/sdk/index.ts | 8 +- apps/desktop/src/themes/index.ts | 1 + apps/desktop/src/themes/request.test.tsx | 94 ++++++++++++++++++++++++ apps/desktop/src/themes/request.ts | 34 +++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/themes/request.test.tsx create mode 100644 apps/desktop/src/themes/request.ts 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 +}