Skip to content

Commit 74a41da

Browse files
committed
docs: expand minimal-json-render into an all-components dashboard
- Rework the example spec into a project dashboard that exercises every base-catalog component (Stack, Card, Text, Badge, Button, Icon, Divider, TextInput, Switch, KeyValueTable, DataTable, CodeBlock, Progress, Tree), with live state, two-way bindings, and three actions (Refresh re-samples the Progress bars, Deploy toggles DataTable loading and appends a module, Save sends bound form params the server writes back). - Make Button icons dependency-free: render the runtime-resolved Icon and a CSS spinner instead of static `i-ph:*` classes; use a text caret in Card so neither component requires an icon font in the consuming app. Created with the help of an agent.
1 parent ace6ec7 commit 74a41da

5 files changed

Lines changed: 176 additions & 35 deletions

File tree

examples/minimal-json-render/README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@
22

33
A standalone devframe that serves a **JSON-render view**: the server authors an
44
`@json-render/core` spec once, and the browser renders it with the official
5-
`@devframes/json-render-ui` Vue frontend. It exercises the whole opt-in
6-
JSON-render stack end to end:
5+
`@devframes/json-render-ui` Vue frontend. The spec is a small **project
6+
dashboard** that exercises **every base-catalog component**`Stack`, `Card`,
7+
`Text`, `Badge`, `Button`, `Icon`, `Divider`, `TextInput`, `Switch`,
8+
`KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`, and `Tree` — end to end:
79

810
- **`@devframes/json-render/node`**`createJsonRenderView(ctx, { id, spec })`
911
registers the spec as shared state, validates element props at ingress, and
1012
hands back a handle with `update` / `patchState` / `dispose`.
1113
- **live state** — the server ticks `uptime` every second via `patchState`, so
12-
the view updates without replacing the whole spec.
13-
- **action bridge** — the `Refresh` button's `press` action is dispatched as an
14-
RPC call of the same name; the handler bumps a counter and patches state.
14+
bound values (`{ $state: '/…' }`) update without replacing the whole spec.
15+
- **two-way bindings** — the `Display name` input and the two switches write back
16+
into state via `{ $bindState: '/form/…' }`.
17+
- **action bridge**`Refresh` re-samples the coverage/bundle `Progress` bars;
18+
`Deploy` flips the `DataTable` into a loading state and appends a module;
19+
`Save` sends the bound form values as action params and the server writes the
20+
name into the `KeyValueTable`. Each is dispatched as an RPC call of the same
21+
name, with per-action loading and error surfacing.
1522
- **`@devframes/json-render-ui`** — the SPA (`src/client`) subscribes to the
1623
view's shared state and renders it with `JsonRenderView`. The app supplies the
1724
frontend lib; devframe serves the SPA.

examples/minimal-json-render/src/devframe.ts

Lines changed: 152 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,123 @@ import { createJsonRenderView } from '@devframes/json-render/node'
44
import { defineRpcFunction } from 'devframe'
55
import { defineDevframe } from 'devframe/types'
66
import pkg from '../package.json' with { type: 'json' }
7-
import { REFRESH_ACTION, VIEW_ID } from './shared.ts'
7+
import { DEPLOY_ACTION, REFRESH_ACTION, SAVE_ACTION, VIEW_ID } from './shared.ts'
88

99
const BASE_PATH = '/__minimal-json-render/'
1010
const distDir = fileURLToPath(new URL('../dist/client', import.meta.url))
1111

12-
// A server-authored spec. Values marked `{ $state: '/...' }` resolve from the
13-
// view's live state at render time, so patching state updates the UI without a
14-
// whole-spec replacement.
12+
const VITE_CONFIG = `import { defineConfig } from 'vite'
13+
14+
export default defineConfig({
15+
base: './',
16+
build: { outDir: 'dist/client' },
17+
})
18+
`
19+
20+
// Initial state model. Every value the spec reads via \`{ $state: '/…' }\`
21+
// resolves from here and updates live as the server patches it.
22+
const initialState = {
23+
project: { name: 'acme-app', version: '1.4.2', license: 'MIT', repository: 'github.com/acme/app' },
24+
metrics: { coverage: 82, bundle: 64 },
25+
modules: [
26+
{ name: '@acme/core', size: '42 kB', status: 'ok' },
27+
{ name: '@acme/ui', size: '88 kB', status: 'ok' },
28+
{ name: '@acme/cli', size: '17 kB', status: 'stale' },
29+
],
30+
deps: {
31+
runtime: { vue: '^3.5', birpc: '^4.0' },
32+
dev: { vite: '^8.1', tsdown: '^0.22', vitest: '^4.1' },
33+
},
34+
form: { name: '', darkMode: true, notifications: false },
35+
building: false,
36+
uptime: 0,
37+
}
38+
39+
// A server-authored spec exercising every base-catalog component. Values marked
40+
// `{ $state: '/…' }` resolve from live state; `{ $bindState: '/…' }` are
41+
// two-way inputs that write back into it.
1542
const spec: DevframeJsonRenderSpec = {
1643
root: 'root',
1744
elements: {
18-
root: { type: 'Stack', props: { gap: 14 }, children: ['title', 'card', 'actions'] },
19-
title: { type: 'Text', props: { text: 'JSON-render demo', variant: 'heading' }, children: [] },
20-
card: { type: 'Card', props: { title: 'Live metrics' }, children: ['metrics'] },
21-
metrics: { type: 'Stack', props: { gap: 8 }, children: ['uptimeRow', 'refreshRow'] },
22-
uptimeRow: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['uptimeLabel', 'uptimeValue'] },
23-
uptimeLabel: { type: 'Text', props: { text: 'Uptime (s)', variant: 'caption' }, children: [] },
24-
uptimeValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'body' }, children: [] },
25-
refreshRow: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['refreshLabel', 'refreshValue'] },
26-
refreshLabel: { type: 'Text', props: { text: 'Manual refreshes', variant: 'caption' }, children: [] },
27-
refreshValue: { type: 'Text', props: { text: { $state: '/refreshes' }, variant: 'body' }, children: [] },
28-
actions: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['refresh'] },
29-
refresh: {
45+
root: { type: 'Stack', props: { gap: 16 }, children: ['header', 'overview', 'settings', 'modules', 'config', 'tree', 'footerDivider', 'footer'] },
46+
47+
// ── Header ────────────────────────────────────────────────────────────
48+
header: { type: 'Stack', props: { direction: 'row', gap: 10, align: 'center', justify: 'between' }, children: ['headLeft', 'headRight'] },
49+
headLeft: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['logo', 'headTitle'] },
50+
logo: { type: 'Icon', props: { name: 'ph:cube-duotone', size: 26 }, children: [] },
51+
headTitle: { type: 'Text', props: { text: 'Acme Dashboard', variant: 'heading' }, children: [] },
52+
headRight: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['status', 'refreshBtn', 'deployBtn'] },
53+
status: { type: 'Badge', props: { text: 'healthy', variant: 'success' }, children: [] },
54+
refreshBtn: {
3055
type: 'Button',
31-
props: { label: 'Refresh', variant: 'primary', icon: 'arrow-clockwise' },
56+
props: { label: 'Refresh', variant: 'ghost', icon: 'ph:arrow-clockwise' },
3257
on: { press: { action: REFRESH_ACTION } },
3358
children: [],
3459
},
60+
deployBtn: {
61+
type: 'Button',
62+
props: { label: 'Deploy', variant: 'primary', icon: 'ph:rocket-launch' },
63+
on: { press: { action: DEPLOY_ACTION } },
64+
children: [],
65+
},
66+
67+
// ── Overview: KeyValueTable + Progress ────────────────────────────────
68+
overview: { type: 'Card', props: { title: 'Overview' }, children: ['overviewBody'] },
69+
overviewBody: { type: 'Stack', props: { gap: 14 }, children: ['meta', 'coverage', 'bundle'] },
70+
meta: { type: 'KeyValueTable', props: { data: { $state: '/project' } }, children: [] },
71+
coverage: { type: 'Progress', props: { label: 'Test coverage', value: { $state: '/metrics/coverage' }, max: 100 }, children: [] },
72+
bundle: { type: 'Progress', props: { label: 'Bundle budget', value: { $state: '/metrics/bundle' }, max: 100 }, children: [] },
73+
74+
// ── Settings: TextInput + Switch + Divider + Button ───────────────────
75+
settings: { type: 'Card', props: { title: 'Settings', collapsible: true }, children: ['settingsBody'] },
76+
settingsBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greeting', 'prefsDivider', 'darkSwitch', 'notifSwitch', 'saveBtn'] },
77+
nameInput: { type: 'TextInput', props: { label: 'Display name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] },
78+
greeting: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'caption', color: 'primary' }, children: [] },
79+
prefsDivider: { type: 'Divider', props: { label: 'preferences' }, children: [] },
80+
darkSwitch: { type: 'Switch', props: { label: 'Dark mode', value: { $bindState: '/form/darkMode' } }, children: [] },
81+
notifSwitch: { type: 'Switch', props: { label: 'Email notifications', value: { $bindState: '/form/notifications' } }, children: [] },
82+
saveBtn: {
83+
type: 'Button',
84+
props: { label: 'Save settings', variant: 'secondary', icon: 'ph:floppy-disk' },
85+
// Bound values are resolved into the action params before dispatch, so the
86+
// server receives whatever the user typed/toggled.
87+
on: { press: { action: SAVE_ACTION, params: { name: { $state: '/form/name' } } } },
88+
children: [],
89+
},
90+
91+
// ── Modules: DataTable (loading bound to /building) ───────────────────
92+
modules: { type: 'Card', props: { title: 'Modules' }, children: ['modulesTable'] },
93+
modulesTable: {
94+
type: 'DataTable',
95+
props: {
96+
columns: [
97+
{ key: 'name', label: 'Module' },
98+
{ key: 'size', label: 'Size' },
99+
{ key: 'status', label: 'Status' },
100+
],
101+
rows: { $state: '/modules' },
102+
height: 180,
103+
loading: { $state: '/building' },
104+
},
105+
children: [],
106+
},
107+
108+
// ── Config: CodeBlock ─────────────────────────────────────────────────
109+
config: { type: 'Card', props: { title: 'Config' }, children: ['code'] },
110+
code: { type: 'CodeBlock', props: { filename: 'vite.config.ts', language: 'ts', code: VITE_CONFIG }, children: [] },
111+
112+
// ── Dependency tree: Tree ─────────────────────────────────────────────
113+
tree: { type: 'Card', props: { title: 'Dependency tree', collapsible: true, defaultCollapsed: true }, children: ['depTree'] },
114+
depTree: { type: 'Tree', props: { data: { $state: '/deps' }, defaultExpanded: true }, children: [] },
115+
116+
// ── Footer ────────────────────────────────────────────────────────────
117+
footerDivider: { type: 'Divider', props: {}, children: [] },
118+
footer: { type: 'Stack', props: { direction: 'row', gap: 6 }, children: ['footerLabel', 'footerValue', 'footerSuffix'] },
119+
footerLabel: { type: 'Text', props: { text: 'Rendered from a JSON-render spec · uptime', variant: 'caption', color: 'faint' }, children: [] },
120+
footerValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'caption', color: 'faint' }, children: [] },
121+
footerSuffix: { type: 'Text', props: { text: 's', variant: 'caption', color: 'faint' }, children: [] },
35122
},
36-
state: { uptime: 0, refreshes: 0 },
123+
state: initialState,
37124
}
38125

39126
export default defineDevframe({
@@ -50,25 +137,65 @@ export default defineDevframe({
50137
port: 9877,
51138
distDir,
52139
// Single-user localhost demo — skip the trust handshake so the served SPA
53-
// can call the action RPC without an OTP round-trip.
140+
// can call the action RPCs without an OTP round-trip.
54141
auth: false,
55142
},
56143
spa: { loader: 'none' },
57144
setup(ctx) {
58145
const view = createJsonRenderView(ctx, { id: VIEW_ID, spec })
59146

60147
// The action bridge dispatches a spec action as an RPC call of the same
61-
// name. Register the handler for the Button's `press` action; it bumps a
62-
// counter and patches the view state (which broadcasts to the SPA).
63-
let refreshes = 0
148+
// name. `Refresh` re-samples the metrics.
64149
ctx.rpc.register(defineRpcFunction({
65150
name: REFRESH_ACTION,
66151
type: 'query',
67152
jsonSerializable: true,
68153
handler: () => {
69-
refreshes += 1
70-
view.patchState([{ op: 'replace', path: '/refreshes', value: refreshes }])
71-
return { refreshes }
154+
const coverage = 70 + Math.floor(Math.random() * 30)
155+
const bundle = 40 + Math.floor(Math.random() * 55)
156+
view.patchState([
157+
{ op: 'replace', path: '/metrics/coverage', value: coverage },
158+
{ op: 'replace', path: '/metrics/bundle', value: bundle },
159+
])
160+
return { coverage, bundle }
161+
},
162+
}))
163+
164+
// `Deploy` flips the DataTable into a loading state, then appends a module —
165+
// demonstrating the per-action loading + a live spec/state change.
166+
let deployed = 0
167+
ctx.rpc.register(defineRpcFunction({
168+
name: DEPLOY_ACTION,
169+
type: 'query',
170+
jsonSerializable: true,
171+
handler: () => {
172+
view.patchState([{ op: 'replace', path: '/building', value: true }])
173+
setTimeout(() => {
174+
deployed += 1
175+
const modules = [
176+
...(view.value().state?.modules as unknown[] ?? []),
177+
{ name: `@acme/edge-${deployed}`, size: '9 kB', status: 'ok' },
178+
]
179+
view.patchState([
180+
{ op: 'replace', path: '/modules', value: modules },
181+
{ op: 'replace', path: '/building', value: false },
182+
])
183+
}, 1200)
184+
return { building: true }
185+
},
186+
}))
187+
188+
// `Save` receives the bound form values as params (resolved client-side from
189+
// `$state`) and writes the display name into the project metadata.
190+
ctx.rpc.register(defineRpcFunction({
191+
name: SAVE_ACTION,
192+
type: 'query',
193+
jsonSerializable: true,
194+
handler: (params?: { name?: string }) => {
195+
const name = params?.name?.trim()
196+
if (name)
197+
view.patchState([{ op: 'replace', path: '/project/name', value: name }])
198+
return { saved: true }
72199
},
73200
}))
74201

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Shared constants used by both the node devframe and the browser SPA.
22

33
/** Author-supplied, stable view id. */
4-
export const VIEW_ID = 'demo'
4+
export const VIEW_ID = 'dashboard'
55

66
/**
77
* The view's shared-state key. `createJsonRenderView` derives it as
@@ -11,5 +11,8 @@ export const VIEW_ID = 'demo'
1111
*/
1212
export const STATE_KEY = `devframe:json-render:global:${VIEW_ID}`
1313

14-
/** The action the demo Button dispatches — an RPC method the server registers. */
14+
// Spec action names dispatched by the bridge — each is an RPC method the server
15+
// registers.
1516
export const REFRESH_ACTION = 'minimal-json-render:refresh'
17+
export const DEPLOY_ACTION = 'minimal-json-render:deploy'
18+
export const SAVE_ACTION = 'minimal-json-render:save'

packages/json-render-ui/src/components/controls.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { JrComponent } from './_shared'
22
import { useBoundProp } from '@json-render/vue'
33
import { h } from 'vue'
4+
import { Icon } from './icon'
45

56
interface ButtonProps {
67
label?: string
78
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
9+
/** Icon name resolved at runtime (e.g. `ph:arrow-clockwise`). */
810
icon?: string
911
disabled?: boolean
1012
loading?: boolean
@@ -33,10 +35,12 @@ export const Button: JrComponent<ButtonProps> = ({ props, on }) => {
3335
},
3436
},
3537
[
38+
// Dependency-free CSS spinner (no icon font needed); the dynamic Icon
39+
// resolves the button's icon at runtime, like the standalone Icon.
3640
busy
37-
? h('span', { class: 'i-ph:spinner animate-spin' })
41+
? h('span', { class: 'inline-block h-3.5 w-3.5 rounded-full border-2 border-current border-t-transparent animate-spin' })
3842
: props.icon
39-
? h('span', { class: `i-ph:${props.icon}` })
43+
? Icon({ props: { name: props.icon, size: 16 } } as Parameters<typeof Icon>[0])
4044
: null,
4145
props.label ? h('span', props.label) : null,
4246
],

packages/json-render-ui/src/components/layout.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const Card: JrComponent<CardProps> = ({ props, children, loading }) => {
6262
h(
6363
'summary',
6464
{ class: 'flex items-center justify-between px2 py1.5 border-b border-base color-base font-medium text-sm cursor-pointer select-none list-none' },
65-
[h('span', props.title ?? ''), h('span', { class: 'i-ph:caret-down color-faint' })],
65+
[h('span', props.title ?? ''), h('span', { class: 'color-faint text-xs' }, '▾')],
6666
),
6767
h('div', { class: 'p2' }, [body]),
6868
])

0 commit comments

Comments
 (0)