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:
@@ -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'
|
||||
|
||||
94
apps/desktop/src/themes/request.test.tsx
Normal file
94
apps/desktop/src/themes/request.test.tsx
Normal 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
apps/desktop/src/themes/request.ts
Normal file
34
apps/desktop/src/themes/request.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user