Skip to content

Commit dad84fd

Browse files
committed
feat(client): add opt-in panel accent inheritance
Expose a framework-neutral theme handshake for iframe panels and connect hub-ui branding to built-in SPAs through their primary design tokens. Preserve standalone defaults, status colors, and independent chart and preview palettes. Include protocol tests, documentation, and API snapshots.
1 parent 1bd966f commit dad84fd

25 files changed

Lines changed: 444 additions & 7 deletions

File tree

design/panel-theme.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { watchDevframeTheme } from 'devframe/client'
2+
3+
// Match the reference dock's primary ramp. Only accent tokens change; semantic
4+
// success/error/warning colors and each SPA's chart palettes stay independent.
5+
const primaryStops = {
6+
DEFAULT: 0,
7+
50: 95,
8+
100: 90,
9+
200: 75,
10+
600: 9,
11+
500: 18,
12+
400: 34,
13+
300: 54,
14+
700: -8,
15+
800: -25,
16+
900: -45,
17+
950: -70,
18+
} as const
19+
20+
/** Opt a built-in devframe SPA into the hub UI provider's primary accent. */
21+
export function syncPanelTheme(): () => void {
22+
const style = document.documentElement.style
23+
const previous = Object.keys(primaryStops).map((stop) => {
24+
const name = `--colors-primary-${stop}`
25+
return { name, value: style.getPropertyValue(name), priority: style.getPropertyPriority(name) }
26+
})
27+
function restore() {
28+
for (const { name, value, priority } of previous) {
29+
if (value)
30+
style.setProperty(name, value, priority)
31+
else
32+
style.removeProperty(name)
33+
}
34+
}
35+
const stop = watchDevframeTheme(({ primaryColor }) => {
36+
if (!primaryColor || !CSS.supports('color', primaryColor)) {
37+
restore()
38+
return
39+
}
40+
for (const [stop, white] of Object.entries(primaryStops)) {
41+
style.setProperty(`--colors-primary-${stop}`, white
42+
? `color-mix(in oklab, ${primaryColor}, ${white > 0 ? 'white' : 'black'} ${Math.abs(white)}%)`
43+
: primaryColor)
44+
}
45+
})
46+
return () => {
47+
stop()
48+
restore()
49+
}
50+
}

design/uno.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Preset } from 'unocss'
22
import { fileURLToPath } from 'node:url'
3-
import { presetAnthonyDesign } from '@antfu/design/unocss'
3+
import { presetAnthonyDesign, resolvePrimary } from '@antfu/design/unocss'
44
import {
55
defineConfig,
66
presetIcons,
@@ -60,6 +60,11 @@ export function createDesignConfig(options: CreateDesignConfigOptions = {}) {
6060
* shared border color (matching `border-base`) for unqualified borders.
6161
*/
6262
preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }],
63+
theme: {
64+
// Stable palette for status marks and preview content that intentionally
65+
// keeps the default accent when the surrounding panel adopts a theme.
66+
colors: { devframe: resolvePrimary('#3a6a45') },
67+
},
6368
shortcuts: {
6469
/** Fixed navbar height, shared by every surface's top nav. */
6570
'h-nav': 'h-10',

docs/content/1.guide/11.client.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,35 @@ async function reconnect() {
333333
```
334334

335335
In a hub, a hub UI provider reads this status from [`context.connection`](/guide/client-context#the-client-context).
336+
337+
## Panel accent
338+
339+
A SPA can adopt its hub UI provider's primary accent with `watchDevframeTheme()`. Map the hint to your own UI token and retain separate tokens for brand graphics, status colors, and charts:
340+
341+
```ts
342+
import { watchDevframeTheme } from 'devframe/client'
343+
344+
const stopTheme = watchDevframeTheme(({ primaryColor }) => {
345+
const style = document.documentElement.style
346+
if (primaryColor && CSS.supports('color', primaryColor))
347+
style.setProperty('--panel-accent', primaryColor)
348+
else
349+
style.removeProperty('--panel-accent')
350+
})
351+
352+
// Call stopTheme() when the SPA unmounts.
353+
```
354+
355+
Use `var(--panel-accent, #3a6a45)` in the SPA's accent styles. A standalone SPA keeps its fallback. Subscription is independent of the RPC connection and works with static builds. The callback receives presentation hints from the direct embedding window; each SPA chooses the UI tokens it changes.
356+
357+
`@devframes/hub-ui` offers its `branding.primaryColor` to panels automatically. A custom hub UI provider can use the same protocol:
358+
359+
```ts
360+
import { provideDevframeTheme } from 'devframe/client'
361+
362+
const theme = provideDevframeTheme(iframe, { primaryColor: '#6b84fd' })
363+
theme.update({ primaryColor: '#8250df' })
364+
// Call theme.dispose() when the iframe view unmounts.
365+
```
366+
367+
The provider supports both startup orders, reloads, and remounting a preserved iframe. The [Panel theme reference](/references/browser-api#panel-theme) lists the API and origin contract.

docs/content/8.references/3.events.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,4 @@ Pushed over each panel's [in-page channel](/guide/in-page-channel) port; the pai
127127
|---|---|---|
128128
| `devframe:remote-assets-error` | the remote-assets fallback page, to `window.parent` | The failed package/version/reason, so an embedding hub UI provider can replace the 502 page. |
129129
| `devframe:in-page-channel` | both [in-page channel](/guide/in-page-channel) endpoints, across window boundaries | The versioned handshake envelope (panel hello, page-script port grant). |
130+
| `devframe:theme` | the embedding window and its panel iframe | Optional accent handshake: `available`, `request`, and `update` with a `DevframeTheme`. |

docs/content/8.references/5.browser-api.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client#
4646
| `disconnected` | Socket closed (dropped mid-session or never opened). |
4747
| `error` | Fatal: the socket errored or connection meta couldn't load. |
4848

49+
## Panel theme
50+
51+
Optional presentation hints from the embedding window: [Panel accent](/guide/client#panel-accent). All exports come from `devframe/client`.
52+
53+
| API | Contract |
54+
|-----|----------|
55+
| `DevframeTheme.primaryColor` | Optional CSS color. Omitted means use the SPA's default accent. |
56+
| `watchDevframeTheme(onChange)` | Opt the panel into updates from its direct parent window. Returns a listener cleanup function. Standalone SPAs and SSR return a no-op cleanup. |
57+
| `provideDevframeTheme(iframe, theme)` | Offer hints to one iframe; only an opted-in panel receives theme data. Returns a `DevframeThemeProvider`. |
58+
| `provider.update(theme)` | Replace the hints; `update({})` clears the accent override. |
59+
| `provider.dispose()` | Remove the provider's message and load listeners. |
60+
61+
The protocol supports same-origin and cross-origin iframe documents with a non-opaque origin. Each side checks the sending window; updates target the requesting document's exact origin. A newly loaded iframe document opts in again. The subscription trusts its direct embedding window for presentation hints; the SPA controls how it applies them.
62+
4963
## In-page channel endpoints
5064

5165
The browser-only endpoint methods of the [in-page channel](/guide/in-page-channel). `emit()` sends to the opposite endpoint; `on()` handles events arriving from that endpoint.

packages/devframe/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './rpc-streaming'
99
export { resolveWsUrl, type WsUrlLocation } from './rpc-ws'
1010
export * from './scope'
1111
export * from './settings'
12+
export * from './theme'
1213
export * from './webmcp'
1314

1415
export const connectDevframe = getDevframeRpcClient
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { DEVFRAME_EVENTS } from '../events'
3+
import { provideDevframeTheme, watchDevframeTheme } from './theme'
4+
5+
const channel = DEVFRAME_EVENTS.postMessage.theme
6+
7+
function fixture() {
8+
const parent = new EventTarget() as Window
9+
const panel = new EventTarget() as Window
10+
const origin = 'https://panel.example'
11+
const parentOrigin = 'https://hub.example'
12+
const queue: (() => void)[] = []
13+
const send = (target: EventTarget, data: unknown, source: EventTarget, origin: string) => {
14+
target.dispatchEvent(Object.assign(new Event('message'), { data, source, origin }))
15+
}
16+
Object.assign(parent, {
17+
postMessage: (data: unknown) => queue.push(() => send(parent, data, panel, origin)),
18+
})
19+
Object.assign(panel, {
20+
parent,
21+
postMessage: (data: unknown, targetOrigin: string) => queue.push(() => {
22+
if (targetOrigin === '*' || targetOrigin === origin)
23+
send(panel, data, parent, parentOrigin)
24+
}),
25+
})
26+
// Model both browsing contexts and queued postMessage delivery without a DOM.
27+
const iframe = Object.assign(new EventTarget(), {
28+
contentWindow: panel,
29+
ownerDocument: { defaultView: parent } as Document,
30+
}) as HTMLIFrameElement
31+
vi.stubGlobal('window', panel)
32+
return {
33+
iframe,
34+
panel,
35+
parent,
36+
send,
37+
flush() {
38+
while (queue.length)
39+
queue.shift()!()
40+
},
41+
}
42+
}
43+
44+
afterEach(() => vi.unstubAllGlobals())
45+
46+
describe('panel theme protocol', () => {
47+
it.each(['panel', 'provider'])('connects cross-origin when %s boots first', (first) => {
48+
const f = fixture()
49+
const changed = vi.fn()
50+
const startPanel = () => watchDevframeTheme(changed)
51+
const startProvider = () => provideDevframeTheme(f.iframe, { primaryColor: '#6b84fd' })
52+
if (first === 'panel') {
53+
startPanel()
54+
f.flush()
55+
startProvider()
56+
}
57+
else {
58+
startProvider()
59+
f.flush()
60+
startPanel()
61+
}
62+
f.flush()
63+
expect(changed).toHaveBeenLastCalledWith({ primaryColor: '#6b84fd' })
64+
})
65+
66+
it('updates and clears the accent without changing other theme state', () => {
67+
const f = fixture()
68+
const provider = provideDevframeTheme(f.iframe, { primaryColor: '#6b84fd' })
69+
const changed = vi.fn()
70+
watchDevframeTheme(changed)
71+
f.flush()
72+
provider.update({ primaryColor: 'rebeccapurple' })
73+
f.flush()
74+
expect(changed).toHaveBeenLastCalledWith({ primaryColor: 'rebeccapurple' })
75+
provider.update({})
76+
f.flush()
77+
expect(changed).toHaveBeenLastCalledWith({ primaryColor: undefined })
78+
})
79+
80+
it('offers no theme data until the iframe opts in', () => {
81+
const f = fixture()
82+
const received: unknown[] = []
83+
f.panel.addEventListener('message', event => received.push((event as MessageEvent).data))
84+
const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
85+
provider.update({ primaryColor: 'red' })
86+
f.flush()
87+
expect(received).toEqual([{ channel, type: 'available' }])
88+
})
89+
90+
it('requires a new document to opt in after navigation', () => {
91+
const f = fixture()
92+
const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
93+
const changed = vi.fn()
94+
const stop = watchDevframeTheme(changed)
95+
f.flush()
96+
stop()
97+
f.iframe.dispatchEvent(new Event('load'))
98+
provider.update({ primaryColor: 'red' })
99+
f.flush()
100+
changed.mockClear()
101+
watchDevframeTheme(changed)
102+
f.flush()
103+
expect(changed).toHaveBeenLastCalledWith({ primaryColor: 'red' })
104+
})
105+
106+
it('rejects theme updates from sibling windows and malformed messages', () => {
107+
const f = fixture()
108+
const changed = vi.fn()
109+
watchDevframeTheme(changed)
110+
f.send(f.panel, { channel, type: 'update', theme: { primaryColor: 'red' } }, new EventTarget(), 'https://hub.example')
111+
for (const data of [null, 'blue', { channel, type: 'update', theme: null }, { channel, type: 'update', theme: { primaryColor: 42 } }])
112+
f.send(f.panel, data, f.parent, 'https://hub.example')
113+
expect(changed).not.toHaveBeenCalled()
114+
})
115+
116+
it('rejects requests from unrelated windows and opaque origins', () => {
117+
const f = fixture()
118+
const received: unknown[] = []
119+
f.panel.addEventListener('message', event => received.push((event as MessageEvent).data))
120+
provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
121+
f.flush()
122+
received.length = 0
123+
f.send(f.parent, { channel, type: 'request' }, new EventTarget(), 'https://panel.example')
124+
f.send(f.parent, { channel, type: 'request' }, f.panel, 'null')
125+
f.flush()
126+
expect(received).toEqual([])
127+
})
128+
129+
it('cleans up both ends and reconnects a preserved iframe to a new provider', () => {
130+
const f = fixture()
131+
const changed = vi.fn()
132+
const stop = watchDevframeTheme(changed)
133+
const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
134+
f.flush()
135+
provider.dispose()
136+
changed.mockClear()
137+
provider.update({ primaryColor: 'red' })
138+
f.iframe.dispatchEvent(new Event('load'))
139+
f.flush()
140+
expect(changed).not.toHaveBeenCalled()
141+
const next = provideDevframeTheme(f.iframe, {})
142+
f.flush()
143+
expect(changed).toHaveBeenLastCalledWith({ primaryColor: undefined })
144+
stop()
145+
changed.mockClear()
146+
next.update({ primaryColor: 'red' })
147+
f.flush()
148+
expect(changed).not.toHaveBeenCalled()
149+
})
150+
151+
it('leaves standalone SPAs and SSR untouched', () => {
152+
const changed = vi.fn()
153+
vi.stubGlobal('window', undefined)
154+
watchDevframeTheme(changed)()
155+
const standalone = { parent: undefined as unknown }
156+
standalone.parent = standalone
157+
vi.stubGlobal('window', standalone)
158+
watchDevframeTheme(changed)()
159+
expect(changed).not.toHaveBeenCalled()
160+
})
161+
})

0 commit comments

Comments
 (0)