@@ -4,36 +4,123 @@ import { createJsonRenderView } from '@devframes/json-render/node'
44import { defineRpcFunction } from 'devframe'
55import { defineDevframe } from 'devframe/types'
66import 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
99const BASE_PATH = '/__minimal-json-render/'
1010const 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.
1542const 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
39126export 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
0 commit comments