Skip to content

Commit e018974

Browse files
committed
feat(examples): render a json-render dock in the minimal-vite hub
Verify the JSON-render hub integration end to end in the Vite hub shell: - Extract a reusable `createDashboardView(ctx)` + `dashboardSpec` from the minimal-json-render example (exported as `minimal-json-render/dashboard`), used by both the standalone devframe and the hub. - The Vite hub authors the view on its hub context and projects it onto a `json-render` dock (via `@devframes/json-render/hub`), and the client host registers `createJsonRenderDockRenderer()` and mounts the dock into a panel. - Wire the hub's Vite build for the Vue renderer: @vitejs/plugin-vue, optimizeDeps exclude, UnoCSS `.vue` scanning + badge-color safelist, and a `*.vue` type shim. Created with the help of an agent.
1 parent ae2304e commit e018974

13 files changed

Lines changed: 340 additions & 199 deletions

File tree

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const alias = {
5252
'@devframes/json-render': r('json-render/src/index.ts'),
5353
'@devframes/json-render-ui/components': r('json-render-ui/src/components/index.ts'),
5454
'@devframes/json-render-ui': r('json-render-ui/src/index.ts'),
55+
'minimal-json-render/dashboard': fileURLToPath(new URL('./examples/minimal-json-render/src/dashboard.ts', import.meta.url)),
5556
'@devframes/plugin-code-server/client': p('code-server/src/client/index.ts'),
5657
'@devframes/plugin-code-server/node': p('code-server/src/node/index.ts'),
5758
'@devframes/plugin-code-server/constants': p('code-server/src/constants.ts'),

examples/minimal-json-render/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
"private": true,
66
"description": "Standalone devframe that serves a JSON-render view — a server-authored spec rendered by @devframes/json-render-ui, with live state and an action bridge.",
77
"homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-json-render",
8+
"exports": {
9+
".": "./src/devframe.ts",
10+
"./dashboard": "./src/dashboard.ts"
11+
},
812
"main": "src/devframe.ts",
913
"bin": {
1014
"minimal-json-render": "./bin.mjs"
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import type { DevframeJsonRenderSpec, JsonRenderView } from '@devframes/json-render'
2+
import type { DevframeNodeContext } from 'devframe/types'
3+
import { createJsonRenderView } from '@devframes/json-render/node'
4+
import { defineRpcFunction } from 'devframe'
5+
import { DEPLOY_ACTION, REFRESH_ACTION, SAVE_ACTION, VIEW_ID } from './shared.ts'
6+
7+
const VITE_CONFIG = `import { defineConfig } from 'vite'
8+
9+
export default defineConfig({
10+
base: './',
11+
build: { outDir: 'dist/client' },
12+
})
13+
`
14+
15+
// Initial state model. Every value the spec reads via \`{ $state: '/…' }\`
16+
// resolves from here and updates live as the server patches it.
17+
export const dashboardState = {
18+
project: { name: 'acme-app', version: '1.4.2', license: 'MIT', repository: 'github.com/acme/app' },
19+
metrics: { coverage: 82, bundle: 64 },
20+
modules: [
21+
{ name: '@acme/core', size: '42 kB', status: 'ok' },
22+
{ name: '@acme/ui', size: '88 kB', status: 'ok' },
23+
{ name: '@acme/cli', size: '17 kB', status: 'stale' },
24+
],
25+
deps: {
26+
runtime: { vue: '^3.5', birpc: '^4.0' },
27+
dev: { vite: '^8.1', tsdown: '^0.22', vitest: '^4.1' },
28+
},
29+
form: { name: '', darkMode: true, notifications: false },
30+
building: false,
31+
uptime: 0,
32+
}
33+
34+
/**
35+
* A server-authored spec exercising every base-catalog component. Values marked
36+
* `{ $state: '/…' }` resolve from live state; `{ $bindState: '/…' }` are
37+
* two-way inputs that write back into it.
38+
*/
39+
export const dashboardSpec: DevframeJsonRenderSpec = {
40+
root: 'root',
41+
elements: {
42+
root: { type: 'Stack', props: { gap: 16 }, children: ['header', 'overview', 'settings', 'modules', 'config', 'tree', 'footerDivider', 'footer'] },
43+
44+
// ── Header ────────────────────────────────────────────────────────────
45+
header: { type: 'Stack', props: { direction: 'row', gap: 10, align: 'center', justify: 'between' }, children: ['headLeft', 'headRight'] },
46+
headLeft: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['logo', 'headTitle'] },
47+
logo: { type: 'Icon', props: { name: 'ph:cube-duotone', size: 26 }, children: [] },
48+
headTitle: { type: 'Text', props: { text: 'Acme Dashboard', variant: 'heading' }, children: [] },
49+
headRight: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['status', 'refreshBtn', 'deployBtn'] },
50+
status: { type: 'Badge', props: { text: 'healthy', variant: 'success' }, children: [] },
51+
refreshBtn: {
52+
type: 'Button',
53+
props: { label: 'Refresh', variant: 'ghost', icon: 'ph:arrow-clockwise' },
54+
on: { press: { action: REFRESH_ACTION } },
55+
children: [],
56+
},
57+
deployBtn: {
58+
type: 'Button',
59+
props: { label: 'Deploy', variant: 'primary', icon: 'ph:rocket-launch' },
60+
on: { press: { action: DEPLOY_ACTION } },
61+
children: [],
62+
},
63+
64+
// ── Overview: KeyValueTable + Progress ────────────────────────────────
65+
overview: { type: 'Card', props: { title: 'Overview' }, children: ['overviewBody'] },
66+
overviewBody: { type: 'Stack', props: { gap: 14 }, children: ['meta', 'coverage', 'bundle'] },
67+
meta: { type: 'KeyValueTable', props: { data: { $state: '/project' } }, children: [] },
68+
coverage: { type: 'Progress', props: { label: 'Test coverage', value: { $state: '/metrics/coverage' }, max: 100 }, children: [] },
69+
bundle: { type: 'Progress', props: { label: 'Bundle budget', value: { $state: '/metrics/bundle' }, max: 100 }, children: [] },
70+
71+
// ── Settings: TextInput + Switch + Divider + Button ───────────────────
72+
settings: { type: 'Card', props: { title: 'Settings', collapsible: true }, children: ['settingsBody'] },
73+
settingsBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greeting', 'prefsDivider', 'darkSwitch', 'notifSwitch', 'saveBtn'] },
74+
nameInput: { type: 'TextInput', props: { label: 'Display name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] },
75+
greeting: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'caption', color: 'primary' }, children: [] },
76+
prefsDivider: { type: 'Divider', props: { label: 'preferences' }, children: [] },
77+
darkSwitch: { type: 'Switch', props: { label: 'Dark mode', value: { $bindState: '/form/darkMode' } }, children: [] },
78+
notifSwitch: { type: 'Switch', props: { label: 'Email notifications', value: { $bindState: '/form/notifications' } }, children: [] },
79+
saveBtn: {
80+
type: 'Button',
81+
props: { label: 'Save settings', variant: 'secondary', icon: 'ph:floppy-disk' },
82+
// Bound values are resolved into the action params before dispatch, so the
83+
// server receives whatever the user typed/toggled.
84+
on: { press: { action: SAVE_ACTION, params: { name: { $state: '/form/name' } } } },
85+
children: [],
86+
},
87+
88+
// ── Modules: DataTable (loading bound to /building) ───────────────────
89+
modules: { type: 'Card', props: { title: 'Modules' }, children: ['modulesTable'] },
90+
modulesTable: {
91+
type: 'DataTable',
92+
props: {
93+
columns: [
94+
{ key: 'name', label: 'Module' },
95+
{ key: 'size', label: 'Size' },
96+
{ key: 'status', label: 'Status' },
97+
],
98+
rows: { $state: '/modules' },
99+
height: 180,
100+
loading: { $state: '/building' },
101+
},
102+
children: [],
103+
},
104+
105+
// ── Config: CodeBlock ─────────────────────────────────────────────────
106+
config: { type: 'Card', props: { title: 'Config' }, children: ['code'] },
107+
code: { type: 'CodeBlock', props: { filename: 'vite.config.ts', language: 'ts', code: VITE_CONFIG }, children: [] },
108+
109+
// ── Dependency tree: Tree ─────────────────────────────────────────────
110+
tree: { type: 'Card', props: { title: 'Dependency tree', collapsible: true, defaultCollapsed: true }, children: ['depTree'] },
111+
depTree: { type: 'Tree', props: { data: { $state: '/deps' }, defaultExpanded: true }, children: [] },
112+
113+
// ── Footer ────────────────────────────────────────────────────────────
114+
footerDivider: { type: 'Divider', props: {}, children: [] },
115+
footer: { type: 'Stack', props: { direction: 'row', gap: 6 }, children: ['footerLabel', 'footerValue', 'footerSuffix'] },
116+
footerLabel: { type: 'Text', props: { text: 'Rendered from a JSON-render spec · uptime', variant: 'caption', color: 'faint' }, children: [] },
117+
footerValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'caption', color: 'faint' }, children: [] },
118+
footerSuffix: { type: 'Text', props: { text: 's', variant: 'caption', color: 'faint' }, children: [] },
119+
},
120+
state: dashboardState,
121+
}
122+
123+
/**
124+
* Create the dashboard JSON-render view on a devframe context and register the
125+
* actions its spec dispatches. Shared by the standalone devframe and the hub
126+
* shells (which additionally project it onto a `json-render` dock). Returns the
127+
* view handle so callers can build a dock ref from it.
128+
*/
129+
export function createDashboardView(ctx: DevframeNodeContext): JsonRenderView {
130+
const view = createJsonRenderView(ctx, { id: VIEW_ID, spec: dashboardSpec })
131+
132+
// The action bridge dispatches a spec action as an RPC call of the same
133+
// name. `Refresh` re-samples the metrics.
134+
ctx.rpc.register(defineRpcFunction({
135+
name: REFRESH_ACTION,
136+
type: 'query',
137+
jsonSerializable: true,
138+
handler: () => {
139+
const coverage = 70 + Math.floor(Math.random() * 30)
140+
const bundle = 40 + Math.floor(Math.random() * 55)
141+
view.patchState([
142+
{ op: 'replace', path: '/metrics/coverage', value: coverage },
143+
{ op: 'replace', path: '/metrics/bundle', value: bundle },
144+
])
145+
return { coverage, bundle }
146+
},
147+
}))
148+
149+
// `Deploy` flips the DataTable into a loading state, then appends a module —
150+
// demonstrating the per-action loading + a live spec/state change.
151+
let deployed = 0
152+
ctx.rpc.register(defineRpcFunction({
153+
name: DEPLOY_ACTION,
154+
type: 'query',
155+
jsonSerializable: true,
156+
handler: () => {
157+
view.patchState([{ op: 'replace', path: '/building', value: true }])
158+
setTimeout(() => {
159+
deployed += 1
160+
const modules = [
161+
...(view.value().state?.modules as unknown[] ?? []),
162+
{ name: `@acme/edge-${deployed}`, size: '9 kB', status: 'ok' },
163+
]
164+
view.patchState([
165+
{ op: 'replace', path: '/modules', value: modules },
166+
{ op: 'replace', path: '/building', value: false },
167+
])
168+
}, 1200)
169+
return { building: true }
170+
},
171+
}))
172+
173+
// `Save` receives the bound form values as params (resolved client-side from
174+
// `$state`) and writes the display name into the project metadata.
175+
ctx.rpc.register(defineRpcFunction({
176+
name: SAVE_ACTION,
177+
type: 'query',
178+
jsonSerializable: true,
179+
handler: (params?: { name?: string }) => {
180+
const name = params?.name?.trim()
181+
if (name)
182+
view.patchState([{ op: 'replace', path: '/project/name', value: name }])
183+
return { saved: true }
184+
},
185+
}))
186+
187+
// Live, server-driven state: tick uptime every second in dev. Unref the
188+
// timer so it never keeps a one-shot `build` run alive.
189+
if (ctx.mode === 'dev') {
190+
let uptime = 0
191+
const timer = setInterval(() => {
192+
uptime += 1
193+
view.patchState([{ op: 'replace', path: '/uptime', value: uptime }])
194+
}, 1000)
195+
timer.unref?.()
196+
}
197+
198+
return view
199+
}

0 commit comments

Comments
 (0)