Skip to content

Commit 59eef9b

Browse files
authored
feat(hub-ui): add a dev-served playground with HMR (#225)
1 parent 492dd4e commit 59eef9b

12 files changed

Lines changed: 386 additions & 1 deletion

File tree

knip.jsonc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@
107107
"packages/hub": {
108108
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts", "src/node/{index,initiate}.ts"]
109109
},
110+
"packages/hub-ui": {
111+
// `playground/client-scripts/*.ts` are dock `action` entries the
112+
// playground's `seed.ts` points at by their runtime URL string
113+
// (`action.importFrom`), not a static import knip's graph can see —
114+
// the same shape as a devframe's `clientScript`/`clientScripts` entry.
115+
"entry": ["playground/client-scripts/*.ts"]
116+
},
110117
"packages/json-render": {
111118
// `src/node/index.ts` is already picked up via `tsdown.config.ts`
112119
// (its literal `entry` object parses cleanly); only `core.ts`/`hub.ts`

packages/hub-ui/.storybook/preview.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import type { Decorator, Preview } from '@storybook/vue3-vite'
2+
// The same Tailwind preflight `scripts/build-css.ts` prepends to the shipped
3+
// shadow-root stylesheet — first, so `virtual:uno.css`'s utilities (and the
4+
// hand-written `style.css`) win over its resets, matching the production
5+
// build's `[reset, userStyle, unoCss, ...]` order. Without it, stories miss
6+
// the reset (unstyled default `<button>`/`<ul>`/heading margins, …) real
7+
// dock content never shows once mounted in its actual shadow root.
8+
import '@unocss/reset/tailwind.css'
29
import 'virtual:uno.css'
310
import '@antfu/design/styles.css'
411
import '../src/client/style.css'

packages/hub-ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"build:node": "tsdown",
3535
"build:embedded": "vite build --config vite.embedded.config.ts",
3636
"build:standalone": "vite build --config src/client/standalone/vite.config.ts",
37+
"dev": "pnpm -C ../.. exec turbo run build --filter=@devframes/hub... --filter=@devframes/plugin-git... && vite --config playground/vite.config.ts",
3738
"typecheck": "tsc --noEmit",
3839
"prepack": "pnpm build",
3940
"storybook": "storybook dev -p 6014",
@@ -48,6 +49,7 @@
4849
"@antfu/design": "catalog:frontend",
4950
"@devframes/hub": "workspace:*",
5051
"@devframes/json-render": "workspace:*",
52+
"@devframes/plugin-git": "workspace:*",
5153
"@iconify-json/ph": "catalog:frontend",
5254
"@storybook/addon-docs": "catalog:storybook",
5355
"@storybook/vue3-vite": "catalog:storybook",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { DockClientScriptContext } from '@devframes/hub/client'
2+
3+
/**
4+
* The "Ping" action dock's client script — `seed.ts` points its `action`
5+
* entry at this file's dev URL (served straight from the playground's own
6+
* Vite root, no build). Actions re-run their script on every click (see
7+
* `executeSetupScript`), so each click posts a fresh message.
8+
*
9+
* `messages.add` writes over the hub's built-in `hub:messages:add` RPC (it
10+
* always exists), so this succeeds — but nothing renders it: the message
11+
* feed / toasts read back through `devframes:plugin:messages:list`, which
12+
* only `@devframes/plugin-messages` registers, and this playground doesn't
13+
* mount it (its SPA needs a build, which would need this very package's own
14+
* dist — see `hub-plugin.ts`'s doc comment). Confirm a click ran by watching
15+
* the network tab for `client-scripts/ping-action.ts`, or breakpoint here.
16+
*/
17+
export default async function ping(context: DockClientScriptContext): Promise<void> {
18+
await context.messages.add({
19+
level: 'success',
20+
message: 'Pong!',
21+
description: 'The "Ping" action dock ran its client script just now.',
22+
})
23+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Shared between `devframes.ts` (the group's iframe members) and `seed.ts`
3+
* (the group entry itself and its action member) — a group entry and its
4+
* members are separate `docks.register()` calls that only line up through
5+
* this matching `groupId`.
6+
*/
7+
export const PLAYGROUND_GROUP_ID = 'playground-tools'
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { HubInstance } from '@devframes/hub/initiate'
2+
import type { Plugin, ViteDevServer } from 'vite'
3+
import { Server as NodeHttpServer } from 'node:http'
4+
import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'
5+
import gitDevframe from '@devframes/plugin-git'
6+
import { PLAYGROUND_GROUP_ID } from './constants'
7+
import { seedPlayground } from './seed'
8+
9+
/**
10+
* Mounts a bare, headless hub instance as Vite dev-server middleware — just
11+
* enough backend for `main.ts`'s `DockStandalone`/`DockEmbedded` to connect
12+
* to (RPC, WebSocket, `__connection.json`), plus a real mounted devframe (the
13+
* Git dashboard, below) so the dock bar has real content to switch between,
14+
* not just the client-only entries `seed.ts` registers. No `ui` slot, no
15+
* renderer manifest: this playground is developing hub-ui itself, not
16+
* exercising the wider hub protocol (`examples/hub-vite` already does that).
17+
*
18+
* A hand-rolled slice of `@devframes/vite/hub` rather than that package
19+
* itself — pulling it in here would make `@devframes/hub-ui` and
20+
* `@devframes/vite` depend on each other (`@devframes/vite` already carries
21+
* an optional peer dependency on `@devframes/hub-ui` for its own default UI
22+
* slot), a cyclic workspace dependency for no real benefit.
23+
*
24+
* `@devframes/plugin-git` is the one built-in plugin that doesn't itself
25+
* depend on `@devframes/vite` (every other plugin does, for its own
26+
* dev-spa/build tooling) — mounting any of those here would reintroduce the
27+
* same cyclic dependency `@devframes/vite/hub` avoids, just one hop further
28+
* out (hub-ui → that plugin → `@devframes/vite` → hub-ui again, via its peer
29+
* dependency).
30+
*/
31+
export function hubUiPlaygroundHub(): Plugin {
32+
let instance: HubInstance | undefined
33+
34+
const teardown = async (): Promise<void> => {
35+
const previous = instance
36+
instance = undefined
37+
await previous?.close().catch(() => {})
38+
}
39+
40+
return {
41+
name: 'hub-ui-playground:hub',
42+
apply: 'serve',
43+
44+
async configureServer(server: ViteDevServer) {
45+
// Vite re-invokes `configureServer` on each restart.
46+
await teardown()
47+
48+
const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined
49+
const hub = initHub({
50+
base: DEVFRAMES_HUB_BASE,
51+
// Storage (if anything writes to it) lands under this package's own
52+
// `node_modules`, not the playground folder.
53+
cwd: new URL('..', import.meta.url).pathname,
54+
origin: () => {
55+
const resolved = server.resolvedUrls?.local?.[0]
56+
return resolved ? new URL(resolved).origin : ''
57+
},
58+
// Frictionless local loop — no interactive OTP gate.
59+
auth: false,
60+
// Share Vite's own HTTP server for the WS upgrade, like
61+
// `@devframes/vite/hub` does.
62+
server: httpServer,
63+
...(httpServer ? {} : { ws: { sidecar: true } }),
64+
// Collapsed under the "Playground Tools" group `seed.ts`'s
65+
// `configure` registers below, alongside the "Ping" action. Read-only
66+
// (`write` stays unset) — this is a throwaway dev loop, not somewhere
67+
// to stage/commit from. Inspects this very checkout: `cwd` above is
68+
// this package's own directory.
69+
devframes: [
70+
{ devframe: gitDevframe, dock: { groupId: PLAYGROUND_GROUP_ID } },
71+
],
72+
configure: seedPlayground,
73+
})
74+
instance = hub
75+
76+
server.middlewares.use(hub.nodeMiddleware)
77+
78+
server.httpServer?.once('close', () => {
79+
if (instance !== hub)
80+
return
81+
void teardown()
82+
})
83+
},
84+
85+
async closeBundle() {
86+
await teardown()
87+
},
88+
}
89+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>hub-ui playground</title>
7+
<style>
8+
html,
9+
body {
10+
margin: 0;
11+
height: 100%;
12+
background: #fff;
13+
font-family: system-ui, sans-serif;
14+
}
15+
html.dark,
16+
html.dark body {
17+
background: #111;
18+
color: #eee;
19+
}
20+
/* `main.ts` marks the mode on `<body>` before mounting — `#app` only
21+
needs to fill the viewport in standalone mode (where `DockStandalone`
22+
mounts into it); in embedded mode it stays empty and out of the way,
23+
leaving `#host-content` visible under the floating dock. */
24+
body.standalone #app {
25+
height: 100%;
26+
}
27+
#host-content {
28+
max-width: 40rem;
29+
margin: 4rem auto;
30+
padding: 0 1.5rem;
31+
line-height: 1.6;
32+
}
33+
</style>
34+
</head>
35+
<body>
36+
<!--
37+
Standalone mode (default) mounts `DockStandalone` into `#app`, filling
38+
the page — this content sits underneath it, unused.
39+
40+
`?embedded` instead leaves this content visible and floats
41+
`DockEmbedded` over it, standing in for a host app the dock is
42+
inspecting.
43+
-->
44+
<div id="app"></div>
45+
<div id="host-content">
46+
<h1>hub-ui playground</h1>
47+
<p>
48+
A sample host page. Load with <code>?embedded</code> to see the
49+
floating dock (<code>DockEmbedded</code>) over this content instead
50+
of the full-page standalone viewer (<code>DockStandalone</code>).
51+
</p>
52+
</div>
53+
<script type="module" src="./main.ts"></script>
54+
</body>
55+
</html>

packages/hub-ui/playground/main.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { DockPanelStorage } from '@devframes/hub/client'
2+
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
3+
import { useLocalStorage } from '@vueuse/core'
4+
import { watchEffect } from 'vue'
5+
import { isDark } from '../src/client/state/color-mode'
6+
import { DEFAULT_DOCK_PANEL_STORE } from '../src/client/state/docks'
7+
8+
/**
9+
* The base `hub-plugin.ts` mounts the playground's hub instance at. Kept as
10+
* an explicit constant (rather than inferred from the page's own URL, like
11+
* the production `standalone`/`embedded` entries do) because this playground
12+
* page is served from Vite's own root (`/`), not colocated with the hub the
13+
* way a built `createUi()` viewer is.
14+
*/
15+
const HUB_BASE = '/__devframes/'
16+
17+
/**
18+
* `?embedded` mounts the floating `DockEmbedded` bootstrap over the sample
19+
* host content in `index.html` — the same surface a host page gets from
20+
* `<script src="<base>embedded.js">`. The default mounts `DockStandalone`
21+
* full-page — the primary surface most hub-ui changes touch.
22+
*/
23+
const mode = new URLSearchParams(location.search).has('embedded') ? 'embedded' : 'standalone'
24+
document.body.classList.add(mode)
25+
26+
// This page runs in the light DOM, so mirror the color mode onto the
27+
// document element like the standalone viewer does.
28+
watchEffect(() => {
29+
const el = document.documentElement
30+
el.classList.toggle('dark', isDark.value)
31+
el.classList.toggle('light', !isDark.value)
32+
el.style.colorScheme = isDark.value ? 'dark' : 'light'
33+
})
34+
35+
async function main(): Promise<void> {
36+
const rpc = await getDevframeRpcClient({ baseURL: HUB_BASE, simpleAuth: false })
37+
const { createDocksContext } = await import('../src/client/state/context')
38+
39+
if (mode === 'embedded') {
40+
const state = useLocalStorage<DockPanelStorage>(
41+
'devframes-hub-ui-playground-dock-state',
42+
DEFAULT_DOCK_PANEL_STORE(),
43+
{ mergeDefaults: true },
44+
)
45+
const context = await createDocksContext('embedded', rpc, state)
46+
setDevframeClientContext(context)
47+
const { DockEmbedded } = await import('../src/client/components/DockEmbedded')
48+
document.body.appendChild(new DockEmbedded({ context }) as unknown as HTMLElement)
49+
return
50+
}
51+
52+
const context = await createDocksContext('standalone', rpc)
53+
setDevframeClientContext(context)
54+
const { DockStandalone } = await import('../src/client/components/DockStandalone')
55+
document.getElementById('app')!.appendChild(new DockStandalone({ context }) as unknown as HTMLElement)
56+
}
57+
58+
void main()

packages/hub-ui/playground/seed.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { DevframeViewAction, DevframeViewGroup } from '@devframes/hub'
2+
import type { DevframeHubContext } from '@devframes/hub/node'
3+
import type { DevframeDockEntryBase } from '@devframes/hub/types'
4+
import { PLAYGROUND_GROUP_ID } from './constants'
5+
6+
/**
7+
* A dock type no renderer covers — registering it exercises the viewer's
8+
* fallback view (`No renderer for "playground-unrendered" in the current
9+
* environment`) instead of leaving the dock bar empty. Mirrors
10+
* `examples/hub-vite/src/unrendered-dock.ts`.
11+
*/
12+
interface PlaygroundUnrenderedDockEntry extends DevframeDockEntryBase {
13+
type: 'playground-unrendered'
14+
}
15+
16+
declare module '@devframes/hub/types' {
17+
interface DevframeDockEntryRegistry {
18+
'playground-unrendered': PlaygroundUnrenderedDockEntry
19+
}
20+
}
21+
22+
const unrenderedDockEntry: PlaygroundUnrenderedDockEntry = {
23+
type: 'playground-unrendered',
24+
id: 'playground:unrendered',
25+
title: 'No Renderer',
26+
icon: 'ph:puzzle-piece-duotone',
27+
category: 'app',
28+
}
29+
30+
/**
31+
* The dock-bar button collapsing the Git devframe (grouped by
32+
* `hub-plugin.ts`'s `devframes` entry) and the "Ping" action below —
33+
* exercises the grouped-dock UI (`DockGroupButton`/`DockGroupPopover`) the
34+
* playground otherwise never touches.
35+
*/
36+
const playgroundGroup: DevframeViewGroup = {
37+
type: 'group',
38+
id: PLAYGROUND_GROUP_ID,
39+
title: 'Playground Tools',
40+
icon: 'ph:flask-duotone',
41+
category: 'app',
42+
// No `defaultChildId` — clicking reveals the member popover instead of
43+
// jumping straight to one, exercising that UI too (`DockGroupPopover`).
44+
}
45+
46+
/**
47+
* A one-shot action dock — no panel of its own, just a client script
48+
* (`client-scripts/ping-action.ts`) the viewer imports and runs on click.
49+
* Grouped alongside the Git devframe above.
50+
*/
51+
const pingAction: DevframeViewAction = {
52+
type: 'action',
53+
id: 'playground:ping',
54+
title: 'Ping',
55+
icon: 'ph:hand-waving-duotone',
56+
category: 'app',
57+
groupId: PLAYGROUND_GROUP_ID,
58+
action: { importFrom: '/client-scripts/ping-action.ts' },
59+
}
60+
61+
/**
62+
* Seeds the playground's hub context with just enough content to exercise
63+
* hub-ui's own surfaces — the dock bar, message center, and command palette —
64+
* without needing a real mounted devframe SPA. Called from `hub-plugin.ts`'s
65+
* `configure` hook once the context exists.
66+
*/
67+
export async function seedPlayground(ctx: DevframeHubContext): Promise<void> {
68+
ctx.docks.register(unrenderedDockEntry)
69+
ctx.docks.register(playgroundGroup)
70+
ctx.docks.register(pingAction)
71+
72+
ctx.commands.register({
73+
id: 'playground:say-hello',
74+
title: 'Playground · Say Hello',
75+
icon: 'ph:hand-waving-duotone',
76+
category: 'playground',
77+
handler: () => 'Hello from the hub-ui playground!',
78+
})
79+
ctx.commands.register({
80+
id: 'playground:throw',
81+
title: 'Playground · Throw an Error',
82+
icon: 'ph:bomb-duotone',
83+
category: 'playground',
84+
handler: () => {
85+
throw new Error('Deliberate playground error — exercises the command palette\'s failure toast.')
86+
},
87+
})
88+
89+
await ctx.messages.add({
90+
level: 'info',
91+
message: 'Hub UI playground started',
92+
description: 'Editing anything under packages/hub-ui/src/client hot-reloads this page.',
93+
})
94+
await ctx.messages.add({
95+
level: 'success',
96+
message: 'Sample success message',
97+
})
98+
await ctx.messages.add({
99+
level: 'warn',
100+
message: 'Sample warning message',
101+
description: 'Messages support an optional description line like this one.',
102+
})
103+
await ctx.messages.add({
104+
level: 'error',
105+
message: 'Sample error message',
106+
})
107+
}

0 commit comments

Comments
 (0)