Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions design/panel-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { watchDevframeTheme } from 'devframe/client'

// Match the reference dock's primary ramp. Only accent tokens change; semantic
// success/error/warning colors and each SPA's chart palettes stay independent.
const primaryStops = {
DEFAULT: 0,
50: 95,
100: 90,
200: 75,
600: 9,
500: 18,
400: 34,
300: 54,
700: -8,
800: -25,
900: -45,
950: -70,
} as const

/** Opt a built-in devframe SPA into the hub UI provider's primary accent. */
export function syncPanelTheme(): () => void {
const style = document.documentElement.style
const previous = Object.keys(primaryStops).map((stop) => {
const name = `--colors-primary-${stop}`
return { name, value: style.getPropertyValue(name), priority: style.getPropertyPriority(name) }
})
function restore() {
for (const { name, value, priority } of previous) {
if (value)
style.setProperty(name, value, priority)
else
style.removeProperty(name)
}
}
const stop = watchDevframeTheme(({ primaryColor }) => {
if (!primaryColor || !CSS.supports('color', primaryColor)) {
restore()
return
}
for (const [stop, white] of Object.entries(primaryStops)) {
style.setProperty(`--colors-primary-${stop}`, white
? `color-mix(in oklab, ${primaryColor}, ${white > 0 ? 'white' : 'black'} ${Math.abs(white)}%)`
: primaryColor)
}
})
return () => {
stop()
restore()
}
}
11 changes: 10 additions & 1 deletion design/uno.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Preset } from 'unocss'
import { fileURLToPath } from 'node:url'
import { presetAnthonyDesign } from '@antfu/design/unocss'
import { presetAnthonyDesign, resolvePrimary } from '@antfu/design/unocss'
import {
defineConfig,
presetIcons,
Expand Down Expand Up @@ -60,7 +60,16 @@ export function createDesignConfig(options: CreateDesignConfigOptions = {}) {
* shared border color (matching `border-base`) for unqualified borders.
*/
preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }],
theme: {
// Stable palette for status marks and preview content that intentionally
// keeps the default accent when the surrounding panel adopts a theme.
colors: { devframe: resolvePrimary('#3a6a45') },
},
shortcuts: {
/** Fixed semantic colors stay independent of the panel's primary accent. */
'color-status-positive': 'color-devframe-600 dark:color-devframe-300',
'color-preview-accent': 'color-devframe-600 dark:color-devframe-300',
'bg-preview-accent': 'bg-devframe',
/** Fixed navbar height, shared by every surface's top nav. */
'h-nav': 'h-10',
/** Named z-index layers, shared across every surface. */
Expand Down
32 changes: 32 additions & 0 deletions docs/content/1.guide/11.client.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,35 @@ async function reconnect() {
```

In a hub, a hub UI provider reads this status from [`context.connection`](/guide/client-context#the-client-context).

## Panel accent

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:

```ts
import { watchDevframeTheme } from 'devframe/client'

const stopTheme = watchDevframeTheme(({ primaryColor }) => {
const style = document.documentElement.style
if (primaryColor && CSS.supports('color', primaryColor))
style.setProperty('--panel-accent', primaryColor)
else
style.removeProperty('--panel-accent')
})

// Call stopTheme() when the SPA unmounts.
```

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.

`@devframes/hub-ui` offers its `branding.primaryColor` to panels automatically. A custom hub UI provider can use the same protocol:

```ts
import { provideDevframeTheme } from 'devframe/client'

const theme = provideDevframeTheme(iframe, { primaryColor: '#6b84fd' })
theme.update({ primaryColor: '#8250df' })
// Call theme.dispose() when the iframe view unmounts.
```

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.
1 change: 1 addition & 0 deletions docs/content/8.references/3.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,4 @@ Pushed over each panel's [in-page channel](/guide/in-page-channel) port; the pai
|---|---|---|
| `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. |
| `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). |
| `devframe:theme` | the embedding window and its panel iframe | Optional accent handshake: `available`, `request`, and `update` with a `DevframeTheme`. |
14 changes: 14 additions & 0 deletions docs/content/8.references/5.browser-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client#
| `disconnected` | Socket closed (dropped mid-session or never opened). |
| `error` | Fatal: the socket errored or connection meta couldn't load. |

## Panel theme

Optional presentation hints from the embedding window: [Panel accent](/guide/client#panel-accent). All exports come from `devframe/client`.

| API | Contract |
|-----|----------|
| `DevframeTheme.primaryColor` | Optional CSS color. Omitted means use the SPA's default accent. |
| `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. |
| `provideDevframeTheme(iframe, theme)` | Offer hints to one iframe; only an opted-in panel receives theme data. Returns a `DevframeThemeProvider`. |
| `provider.update(theme)` | Replace the hints; `update({})` clears the accent override. |
| `provider.dispose()` | Remove the provider's message and load listeners. |

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.

## In-page channel endpoints

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.
Expand Down
2 changes: 1 addition & 1 deletion docs/content/8.references/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Lookup pages the guides link into:
- [When Clauses](/references/when-clauses): the contexts and operators that gate docks, commands, and custom UI.
- [Events Reference](/references/events): every event, broadcast, shared-state key, and channel name, by direction and reach.
- [Node-Side API](/references/node-api): `DevframeDefinition` fields, CLI options, storage scopes, RPC function types, broadcast options, streaming lifecycle, remote assets, the `ctx.services` host and wire-service fields, diagnostics prefixes, and the auth surface.
- [Browser-Side API](/references/browser-api): `connectDevframe` options, RPC client events, connection statuses, and in-page channel error codes.
- [Browser-Side API](/references/browser-api): `connectDevframe` options, RPC client events, connection statuses, panel theme APIs, and in-page channel error codes.
- [Hub API](/references/hub-api): hub subsystems, launcher fields, duplication strategies, dock categories, the hub UI protocol, the namespace routes, the client runtime, the client context, and dock entry types.
- [Utilities](/references/utilities): the small, stable helpers under `devframe/utils/*`, bundled into `devframe`.
- [Interactive Auth](/references/interactive-auth): the OTP auth recipe: handshake, resolver gate, connect-time trust, banner.
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './rpc-streaming'
export { resolveWsUrl, type WsUrlLocation } from './rpc-ws'
export * from './scope'
export * from './settings'
export * from './theme'
export * from './webmcp'

export const connectDevframe = getDevframeRpcClient
161 changes: 161 additions & 0 deletions packages/devframe/src/client/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DEVFRAME_EVENTS } from '../events'
import { provideDevframeTheme, watchDevframeTheme } from './theme'

const channel = DEVFRAME_EVENTS.postMessage.theme

function fixture() {
const parent = new EventTarget() as Window
const panel = new EventTarget() as Window
const origin = 'https://panel.example'
const parentOrigin = 'https://hub.example'
const queue: (() => void)[] = []
const send = (target: EventTarget, data: unknown, source: EventTarget, origin: string) => {
target.dispatchEvent(Object.assign(new Event('message'), { data, source, origin }))
}
Object.assign(parent, {
postMessage: (data: unknown) => queue.push(() => send(parent, data, panel, origin)),
})
Object.assign(panel, {
parent,
postMessage: (data: unknown, targetOrigin: string) => queue.push(() => {
if (targetOrigin === '*' || targetOrigin === origin)
send(panel, data, parent, parentOrigin)
}),
})
// Model both browsing contexts and queued postMessage delivery without a DOM.
const iframe = Object.assign(new EventTarget(), {
contentWindow: panel,
ownerDocument: { defaultView: parent } as Document,
}) as HTMLIFrameElement
vi.stubGlobal('window', panel)
return {
iframe,
panel,
parent,
send,
flush() {
while (queue.length)
queue.shift()!()
},
}
}

afterEach(() => vi.unstubAllGlobals())

describe('panel theme protocol', () => {
it.each(['panel', 'provider'])('connects cross-origin when %s boots first', (first) => {
const f = fixture()
const changed = vi.fn()
const startPanel = () => watchDevframeTheme(changed)
const startProvider = () => provideDevframeTheme(f.iframe, { primaryColor: '#6b84fd' })
if (first === 'panel') {
startPanel()
f.flush()
startProvider()
}
else {
startProvider()
f.flush()
startPanel()
}
f.flush()
expect(changed).toHaveBeenLastCalledWith({ primaryColor: '#6b84fd' })
})

it('updates and clears the accent without changing other theme state', () => {
const f = fixture()
const provider = provideDevframeTheme(f.iframe, { primaryColor: '#6b84fd' })
const changed = vi.fn()
watchDevframeTheme(changed)
f.flush()
provider.update({ primaryColor: 'rebeccapurple' })
f.flush()
expect(changed).toHaveBeenLastCalledWith({ primaryColor: 'rebeccapurple' })
provider.update({})
f.flush()
expect(changed).toHaveBeenLastCalledWith({ primaryColor: undefined })
})

it('offers no theme data until the iframe opts in', () => {
const f = fixture()
const received: unknown[] = []
f.panel.addEventListener('message', event => received.push((event as MessageEvent).data))
const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
provider.update({ primaryColor: 'red' })
f.flush()
expect(received).toEqual([{ channel, type: 'available' }])
})

it('requires a new document to opt in after navigation', () => {
const f = fixture()
const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
const changed = vi.fn()
const stop = watchDevframeTheme(changed)
f.flush()
stop()
f.iframe.dispatchEvent(new Event('load'))
provider.update({ primaryColor: 'red' })
f.flush()
changed.mockClear()
watchDevframeTheme(changed)
f.flush()
expect(changed).toHaveBeenLastCalledWith({ primaryColor: 'red' })
})

it('rejects theme updates from sibling windows and malformed messages', () => {
const f = fixture()
const changed = vi.fn()
watchDevframeTheme(changed)
f.send(f.panel, { channel, type: 'update', theme: { primaryColor: 'red' } }, new EventTarget(), 'https://hub.example')
for (const data of [null, 'blue', { channel, type: 'update', theme: null }, { channel, type: 'update', theme: { primaryColor: 42 } }])
f.send(f.panel, data, f.parent, 'https://hub.example')
expect(changed).not.toHaveBeenCalled()
})

it('rejects requests from unrelated windows and opaque origins', () => {
const f = fixture()
const received: unknown[] = []
f.panel.addEventListener('message', event => received.push((event as MessageEvent).data))
provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
f.flush()
received.length = 0
f.send(f.parent, { channel, type: 'request' }, new EventTarget(), 'https://panel.example')
f.send(f.parent, { channel, type: 'request' }, f.panel, 'null')
f.flush()
expect(received).toEqual([])
})

it('cleans up both ends and reconnects a preserved iframe to a new provider', () => {
const f = fixture()
const changed = vi.fn()
const stop = watchDevframeTheme(changed)
const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' })
f.flush()
provider.dispose()
changed.mockClear()
provider.update({ primaryColor: 'red' })
f.iframe.dispatchEvent(new Event('load'))
f.flush()
expect(changed).not.toHaveBeenCalled()
const next = provideDevframeTheme(f.iframe, {})
f.flush()
expect(changed).toHaveBeenLastCalledWith({ primaryColor: undefined })
stop()
changed.mockClear()
next.update({ primaryColor: 'red' })
f.flush()
expect(changed).not.toHaveBeenCalled()
})

it('leaves standalone SPAs and SSR untouched', () => {
const changed = vi.fn()
vi.stubGlobal('window', undefined)
watchDevframeTheme(changed)()
const standalone = { parent: undefined as unknown }
standalone.parent = standalone
vi.stubGlobal('window', standalone)
watchDevframeTheme(changed)()
expect(changed).not.toHaveBeenCalled()
})
})
Loading
Loading