Skip to content
Merged
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
7 changes: 7 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@
"packages/hub": {
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts", "src/node/{index,initiate}.ts"]
},
"packages/hub-ui": {
// `playground/client-scripts/*.ts` are dock `action` entries the
// playground's `seed.ts` points at by their runtime URL string
// (`action.importFrom`), not a static import knip's graph can see —
// the same shape as a devframe's `clientScript`/`clientScripts` entry.
"entry": ["playground/client-scripts/*.ts"]
},
"packages/json-render": {
// `src/node/index.ts` is already picked up via `tsdown.config.ts`
// (its literal `entry` object parses cleanly); only `core.ts`/`hub.ts`
Expand Down
7 changes: 7 additions & 0 deletions packages/hub-ui/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { Decorator, Preview } from '@storybook/vue3-vite'
// The same Tailwind preflight `scripts/build-css.ts` prepends to the shipped
// shadow-root stylesheet — first, so `virtual:uno.css`'s utilities (and the
// hand-written `style.css`) win over its resets, matching the production
// build's `[reset, userStyle, unoCss, ...]` order. Without it, stories miss
// the reset (unstyled default `<button>`/`<ul>`/heading margins, …) real
// dock content never shows once mounted in its actual shadow root.
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
import '@antfu/design/styles.css'
import '../src/client/style.css'
Expand Down
2 changes: 2 additions & 0 deletions packages/hub-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"build:node": "tsdown",
"build:embedded": "vite build --config vite.embedded.config.ts",
"build:standalone": "vite build --config src/client/standalone/vite.config.ts",
"dev": "pnpm -C ../.. exec turbo run build --filter=@devframes/hub... --filter=@devframes/plugin-git... && vite --config playground/vite.config.ts",
"typecheck": "tsc --noEmit",
"prepack": "pnpm build",
"storybook": "storybook dev -p 6014",
Expand All @@ -48,6 +49,7 @@
"@antfu/design": "catalog:frontend",
"@devframes/hub": "workspace:*",
"@devframes/json-render": "workspace:*",
"@devframes/plugin-git": "workspace:*",
"@iconify-json/ph": "catalog:frontend",
"@storybook/addon-docs": "catalog:storybook",
"@storybook/vue3-vite": "catalog:storybook",
Expand Down
23 changes: 23 additions & 0 deletions packages/hub-ui/playground/client-scripts/ping-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { DockClientScriptContext } from '@devframes/hub/client'

/**
* The "Ping" action dock's client script — `seed.ts` points its `action`
* entry at this file's dev URL (served straight from the playground's own
* Vite root, no build). Actions re-run their script on every click (see
* `executeSetupScript`), so each click posts a fresh message.
*
* `messages.add` writes over the hub's built-in `hub:messages:add` RPC (it
* always exists), so this succeeds — but nothing renders it: the message
* feed / toasts read back through `devframes:plugin:messages:list`, which
* only `@devframes/plugin-messages` registers, and this playground doesn't
* mount it (its SPA needs a build, which would need this very package's own
* dist — see `hub-plugin.ts`'s doc comment). Confirm a click ran by watching
* the network tab for `client-scripts/ping-action.ts`, or breakpoint here.
*/
export default async function ping(context: DockClientScriptContext): Promise<void> {
await context.messages.add({
level: 'success',
message: 'Pong!',
description: 'The "Ping" action dock ran its client script just now.',
})
}
7 changes: 7 additions & 0 deletions packages/hub-ui/playground/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Shared between `devframes.ts` (the group's iframe members) and `seed.ts`
* (the group entry itself and its action member) — a group entry and its
* members are separate `docks.register()` calls that only line up through
* this matching `groupId`.
*/
export const PLAYGROUND_GROUP_ID = 'playground-tools'
89 changes: 89 additions & 0 deletions packages/hub-ui/playground/hub-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { HubInstance } from '@devframes/hub/initiate'
import type { Plugin, ViteDevServer } from 'vite'
import { Server as NodeHttpServer } from 'node:http'
import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'
import gitDevframe from '@devframes/plugin-git'
import { PLAYGROUND_GROUP_ID } from './constants'
import { seedPlayground } from './seed'

/**
* Mounts a bare, headless hub instance as Vite dev-server middleware — just
* enough backend for `main.ts`'s `DockStandalone`/`DockEmbedded` to connect
* to (RPC, WebSocket, `__connection.json`), plus a real mounted devframe (the
* Git dashboard, below) so the dock bar has real content to switch between,
* not just the client-only entries `seed.ts` registers. No `ui` slot, no
* renderer manifest: this playground is developing hub-ui itself, not
* exercising the wider hub protocol (`examples/hub-vite` already does that).
*
* A hand-rolled slice of `@devframes/vite/hub` rather than that package
* itself — pulling it in here would make `@devframes/hub-ui` and
* `@devframes/vite` depend on each other (`@devframes/vite` already carries
* an optional peer dependency on `@devframes/hub-ui` for its own default UI
* slot), a cyclic workspace dependency for no real benefit.
*
* `@devframes/plugin-git` is the one built-in plugin that doesn't itself
* depend on `@devframes/vite` (every other plugin does, for its own
* dev-spa/build tooling) — mounting any of those here would reintroduce the
* same cyclic dependency `@devframes/vite/hub` avoids, just one hop further
* out (hub-ui → that plugin → `@devframes/vite` → hub-ui again, via its peer
* dependency).
*/
export function hubUiPlaygroundHub(): Plugin {
let instance: HubInstance | undefined

const teardown = async (): Promise<void> => {
const previous = instance
instance = undefined
await previous?.close().catch(() => {})
}

return {
name: 'hub-ui-playground:hub',
apply: 'serve',

async configureServer(server: ViteDevServer) {
// Vite re-invokes `configureServer` on each restart.
await teardown()

const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined
const hub = initHub({
base: DEVFRAMES_HUB_BASE,
// Storage (if anything writes to it) lands under this package's own
// `node_modules`, not the playground folder.
cwd: new URL('..', import.meta.url).pathname,
origin: () => {
const resolved = server.resolvedUrls?.local?.[0]
return resolved ? new URL(resolved).origin : ''
},
// Frictionless local loop — no interactive OTP gate.
auth: false,
// Share Vite's own HTTP server for the WS upgrade, like
// `@devframes/vite/hub` does.
server: httpServer,
...(httpServer ? {} : { ws: { sidecar: true } }),
// Collapsed under the "Playground Tools" group `seed.ts`'s
// `configure` registers below, alongside the "Ping" action. Read-only
// (`write` stays unset) — this is a throwaway dev loop, not somewhere
// to stage/commit from. Inspects this very checkout: `cwd` above is
// this package's own directory.
devframes: [
{ devframe: gitDevframe, dock: { groupId: PLAYGROUND_GROUP_ID } },
],
configure: seedPlayground,
})
instance = hub

server.middlewares.use(hub.nodeMiddleware)

server.httpServer?.once('close', () => {
if (instance !== hub)
return
void teardown()
})
},

async closeBundle() {
await teardown()
},
}
}
55 changes: 55 additions & 0 deletions packages/hub-ui/playground/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>hub-ui playground</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #fff;
font-family: system-ui, sans-serif;
}
html.dark,
html.dark body {
background: #111;
color: #eee;
}
/* `main.ts` marks the mode on `<body>` before mounting — `#app` only
needs to fill the viewport in standalone mode (where `DockStandalone`
mounts into it); in embedded mode it stays empty and out of the way,
leaving `#host-content` visible under the floating dock. */
body.standalone #app {
height: 100%;
}
#host-content {
max-width: 40rem;
margin: 4rem auto;
padding: 0 1.5rem;
line-height: 1.6;
}
</style>
</head>
<body>
<!--
Standalone mode (default) mounts `DockStandalone` into `#app`, filling
the page — this content sits underneath it, unused.

`?embedded` instead leaves this content visible and floats
`DockEmbedded` over it, standing in for a host app the dock is
inspecting.
-->
<div id="app"></div>
<div id="host-content">
<h1>hub-ui playground</h1>
<p>
A sample host page. Load with <code>?embedded</code> to see the
floating dock (<code>DockEmbedded</code>) over this content instead
of the full-page standalone viewer (<code>DockStandalone</code>).
</p>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>
58 changes: 58 additions & 0 deletions packages/hub-ui/playground/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { DockPanelStorage } from '@devframes/hub/client'
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
import { useLocalStorage } from '@vueuse/core'
import { watchEffect } from 'vue'
import { isDark } from '../src/client/state/color-mode'
import { DEFAULT_DOCK_PANEL_STORE } from '../src/client/state/docks'

/**
* The base `hub-plugin.ts` mounts the playground's hub instance at. Kept as
* an explicit constant (rather than inferred from the page's own URL, like
* the production `standalone`/`embedded` entries do) because this playground
* page is served from Vite's own root (`/`), not colocated with the hub the
* way a built `createUi()` viewer is.
*/
const HUB_BASE = '/__devframes/'

/**
* `?embedded` mounts the floating `DockEmbedded` bootstrap over the sample
* host content in `index.html` — the same surface a host page gets from
* `<script src="<base>embedded.js">`. The default mounts `DockStandalone`
* full-page — the primary surface most hub-ui changes touch.
*/
const mode = new URLSearchParams(location.search).has('embedded') ? 'embedded' : 'standalone'
document.body.classList.add(mode)

// This page runs in the light DOM, so mirror the color mode onto the
// document element like the standalone viewer does.
watchEffect(() => {
const el = document.documentElement
el.classList.toggle('dark', isDark.value)
el.classList.toggle('light', !isDark.value)
el.style.colorScheme = isDark.value ? 'dark' : 'light'
})

async function main(): Promise<void> {
const rpc = await getDevframeRpcClient({ baseURL: HUB_BASE, simpleAuth: false })
const { createDocksContext } = await import('../src/client/state/context')

if (mode === 'embedded') {
const state = useLocalStorage<DockPanelStorage>(
'devframes-hub-ui-playground-dock-state',
DEFAULT_DOCK_PANEL_STORE(),
{ mergeDefaults: true },
)
const context = await createDocksContext('embedded', rpc, state)
setDevframeClientContext(context)
const { DockEmbedded } = await import('../src/client/components/DockEmbedded')
document.body.appendChild(new DockEmbedded({ context }) as unknown as HTMLElement)
return
}

const context = await createDocksContext('standalone', rpc)
setDevframeClientContext(context)
const { DockStandalone } = await import('../src/client/components/DockStandalone')
document.getElementById('app')!.appendChild(new DockStandalone({ context }) as unknown as HTMLElement)
}

void main()
107 changes: 107 additions & 0 deletions packages/hub-ui/playground/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { DevframeViewAction, DevframeViewGroup } from '@devframes/hub'
import type { DevframeHubContext } from '@devframes/hub/node'
import type { DevframeDockEntryBase } from '@devframes/hub/types'
import { PLAYGROUND_GROUP_ID } from './constants'

/**
* A dock type no renderer covers — registering it exercises the viewer's
* fallback view (`No renderer for "playground-unrendered" in the current
* environment`) instead of leaving the dock bar empty. Mirrors
* `examples/hub-vite/src/unrendered-dock.ts`.
*/
interface PlaygroundUnrenderedDockEntry extends DevframeDockEntryBase {
type: 'playground-unrendered'
}

declare module '@devframes/hub/types' {
interface DevframeDockEntryRegistry {
'playground-unrendered': PlaygroundUnrenderedDockEntry
}
}

const unrenderedDockEntry: PlaygroundUnrenderedDockEntry = {
type: 'playground-unrendered',
id: 'playground:unrendered',
title: 'No Renderer',
icon: 'ph:puzzle-piece-duotone',
category: 'app',
}

/**
* The dock-bar button collapsing the Git devframe (grouped by
* `hub-plugin.ts`'s `devframes` entry) and the "Ping" action below —
* exercises the grouped-dock UI (`DockGroupButton`/`DockGroupPopover`) the
* playground otherwise never touches.
*/
const playgroundGroup: DevframeViewGroup = {
type: 'group',
id: PLAYGROUND_GROUP_ID,
title: 'Playground Tools',
icon: 'ph:flask-duotone',
category: 'app',
// No `defaultChildId` — clicking reveals the member popover instead of
// jumping straight to one, exercising that UI too (`DockGroupPopover`).
}

/**
* A one-shot action dock — no panel of its own, just a client script
* (`client-scripts/ping-action.ts`) the viewer imports and runs on click.
* Grouped alongside the Git devframe above.
*/
const pingAction: DevframeViewAction = {
type: 'action',
id: 'playground:ping',
title: 'Ping',
icon: 'ph:hand-waving-duotone',
category: 'app',
groupId: PLAYGROUND_GROUP_ID,
action: { importFrom: '/client-scripts/ping-action.ts' },
}

/**
* Seeds the playground's hub context with just enough content to exercise
* hub-ui's own surfaces — the dock bar, message center, and command palette —
* without needing a real mounted devframe SPA. Called from `hub-plugin.ts`'s
* `configure` hook once the context exists.
*/
export async function seedPlayground(ctx: DevframeHubContext): Promise<void> {
ctx.docks.register(unrenderedDockEntry)
ctx.docks.register(playgroundGroup)
ctx.docks.register(pingAction)

ctx.commands.register({
id: 'playground:say-hello',
title: 'Playground · Say Hello',
icon: 'ph:hand-waving-duotone',
category: 'playground',
handler: () => 'Hello from the hub-ui playground!',
})
ctx.commands.register({
id: 'playground:throw',
title: 'Playground · Throw an Error',
icon: 'ph:bomb-duotone',
category: 'playground',
handler: () => {
throw new Error('Deliberate playground error — exercises the command palette\'s failure toast.')
},
})

await ctx.messages.add({
level: 'info',
message: 'Hub UI playground started',
description: 'Editing anything under packages/hub-ui/src/client hot-reloads this page.',
})
await ctx.messages.add({
level: 'success',
message: 'Sample success message',
})
await ctx.messages.add({
level: 'warn',
message: 'Sample warning message',
description: 'Messages support an optional description line like this one.',
})
await ctx.messages.add({
level: 'error',
message: 'Sample error message',
})
}
Loading
Loading