The spec
Everything in a ViewSpec is JSON-safe: no functions, no dates, no JSX. The
spec names things; the app supplies implementations at render time.
{
"version": 2,
"title": "Accounts overview",
"data": { "accounts": [{ "name": "Initech", "status": "active" }] },
"root": {
"id": "layout",
"type": "stack",
"props": { "gap": 4 },
"children": [
{ "id": "title", "type": "heading", "props": { "text": "Accounts", "level": 1 } },
{
"id": "list",
"type": "data-grid",
"props": {
"columns": [
{ "field": "name", "header": "Account" },
{ "field": "status", "header": "Status", "cell": "badge",
"badgeTones": { "active": "success" } }
],
"rows": { "$bind": "accounts" },
"onRowClick": { "$action": "openAccount" }
}
}
]
}
}
Nodes
Every node is { type, id?, props?, children?, hidden?, param? }.
type— a key in the registry. Unknown types render a placeholder, never throw.id— stable and unique. Required on any node that customization patches should target, so generators are encouraged to id every meaningful node (assignNodeIds(spec)retrofits the rest).children— nodes and bare text runs, for components whose definition accepts them.hidden— skips rendering without removing the node; the reversible form of "remove" that user customizations toggle.param— publishes the node's UI state as a named param, readable anywhere via{ "$bind": "$params.<name>" }.
A spec may also carry params (initial param values), data (sample or
bundled datasets), and title / description metadata.
Markers
Three $-marker shapes may appear anywhere in props, including nested
inside arrays and objects:
| Marker | Meaning |
|---|---|
{ "$bind": "path.to.data", "default"?: … } | Reads from the data context by dot path (arrays by index). |
{ "$action": "name", "params"?: { … } } | Becomes a callback that calls the app's handler of that name. |
{ "$node": { … } } / { "$nodes": [ … ] } | A component in a prop position — a slot (card.footer, accordion item content). |
The data context is spec.data merged with — and overridden by — the
datasets prop on <ViewRenderer>. A stored view can carry sample datasets for
preview; the live app supplies real ones under the same names. Two roots are
special: dataset rows are pre-narrowed by the view's
active filters, and $params holds the live
param values ({ "$bind": "$params.region" }).
Actions dispatch to actions[name] and then to the onAction catch-all.
Named handlers receive params first, then a ViewActionEvent — { action, params, nodeId, nodeType, args }, where args holds whatever the component
passed to its callback (a row, a value, a DOM event). The catch-all receives
just the event, which is enough to log every interaction or hand it back to
an LLM:
<ViewRenderer
spec={spec}
registry={registry}
actions={{ openAccount: (params, event) => navigate(`/accounts/${event.args[0].id}`) }}
onAction={(event) => analytics.track('view-action', { name: event.action, node: event.nodeId })}
/>
Validation
Gate untrusted specs — LLM output, user-edited JSON — before rendering or saving:
import { parseViewSpec, validateViewSpec } from '@zuilib/apps'
const spec = parseViewSpec(json) // throws with a clear message
const issues = validateViewSpec(spec, registry, { data, datasets, actions })
// [{ severity: 'error' | 'warning', code, path, message }]
Errors (unknown-type, missing-prop, invalid-enum, duplicate-id) mean
the renderer would drop or placeholder something; warnings (unresolved-bind,
unexpected-children, unknown-dataset, unknown-column, unknown-action)
mean the spec renders but is suspicious. Passing declared
dataset schemas and an action catalog turns on the
reference checks — a bind to a declared dataset counts as resolvable even
before rows arrive, and column and $action names are verified. Issues carry
spec paths (root.children.2.props.rows) — feed them back to the model for a
repair pass, or show them in an editor.
For specs loaded from storage, prefer migrateViewSpec(stored) over a bare
JSON.parse: it upgrades older spec versions through registered migration
steps and rejects versions newer than the package — the compatibility
contract for specs your users saved in your database.
The registry
createViewRegistry(...sources) merges definition lists; later sources win on
type collisions, so an app can override a preset entry by listing its own
definition after it. A definition carries the component, prop metadata (used
by validation and the LLM catalog), a children policy,
and optionally a render override for components whose React API isn't a
flat prop bag:
defineViewType({
type: 'customer-card',
description: 'The application customer card.',
component: CustomerCard,
props: {
customerId: { valueType: 'string', required: true },
},
})
component accepts any React element type. Use a native tag such as
component: 'section', a typed component from your application, or a component
from another design system directly—none of them need to use ZUI primitives.
The registry's props metadata is the serializable boundary used by the
validator, editor, and model catalog. Use render only when that flat resolved
prop bag needs adapting to a compound or otherwise different React API.
The component receives the same runtime behavior as a preset component. The
renderer resolves $bind markers to live datasets, turns $action markers
into callbacks, wires declared state, applies authorization and loading policy,
and gives the node responsive placement. Updating a host dataset rerenders the
component with the new rows. The visual builder lists the definition and builds
its inspector from the same props metadata.
You can omit coreViewTypes entirely for an application-owned registry. Keep
your component library's provider, theme, CSS, and fonts around the renderer as
usual. ZUI's prebuilt editor and app builder use ZUI primitives for their own
authoring controls, but components inside the canvas retain their own design
system.
Set editorRole: 'layout' on structural containers. The visual editor then
offers those definitions only in layout-item insertion controls and offers
ordinary content definitions inside the container. For compatibility,
definitions in the Layout category are treated as layout types as well.