Skip to main content

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:

MarkerMeaning
{ "$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.