Layout — Author Guide
How to write nova layouts. A layout is a JSON tree describing what to render. The renderer walks it, resolves any bindings against a data object, and emits a RenderNode[] tree that a framework adapter (the React adapter ships with nova) turns into actual UI.
This guide is for authoring layouts. For the architecture (why it's JSON, how the unified resolver works, etc.) see DESIGN.md.
A first layout
import { renderLayout, createComponentRegistry, createLayoutStore } from '@niscorp/nova';
import { registerNovaReactComponents } from '@niscorp/nova/adapters/react/components';
const registry = createComponentRegistry();
registerNovaReactComponents(registry);
const store = createLayoutStore();
const layout = {
component: 'Stack',
props: { direction: 'column', gap: 16, padding: 24 },
children: [
{ component: 'Text', props: { size: 'xl', weight: 'bold' }, children: 'Hello, {{$.name}}' },
{ component: 'Text', children: 'You have {{$.count}} new messages.' },
],
};
const data = { name: 'Ada', count: 3 };
const nodes = renderLayout(layout, data, {
store,
registry,
strict: false,
onError: console.error,
});
nodes is a RenderNode[] tree. Hand it to the React adapter (<RenderTree nodes={nodes} />) and it renders.
Six node kinds
A LayoutNode is one of:
| Kind | What it does |
|---|---|
| Component | Instantiates a registered component |
| Conditional | Renders one of two branches based on a condition |
| Loop | Iterates an array and renders the body for each item |
| LayoutRef | Inlines another layout from the layout store |
| Slot | A placeholder a fragment-merge fills with the composing action's layout |
| Primitive | A bare string/number/boolean — rendered as text |
Plus arrays of any of the above (treated as a fragment).
That's it. Six things. Everything you write is a tree of those.
Component nodes
The most common one. References a component by name; the registry supplies the implementation.
{
component: 'Stack',
props: { direction: 'row', gap: 8 },
children: [
{ component: 'Text', children: 'left' },
{ component: 'Text', children: 'right' },
],
ref: 'header-stack', // optional — used by triggers and model binding
model: '$.draftValue', // optional — two-way data binding (see below)
}
Fields
component(required) — the registry name. Must match a name passed toregistry.register(name, ...).props(optional) — an object of props. Every value is a binding — strings starting with$are resolved as paths, strings with{{...}}are interpolated, plain values pass through. See Bindings.children(optional) — a singleLayoutNodeor an array of them. Components decide how to render their children slot.ref(optional) — a stable identifier. Used by triggers (event: 'ui:click', ref: 'header-stack') and as the model binding key. If unset, the renderer auto-generates one for model-bound components.model(optional) — two-way binding path. See Two-way binding.events(optional) — a record of event name → configuration. The schema accepts it; the renderer does not read it and it does not reach theRenderNode. Interaction isref+ an action trigger.
The node is strict: any other key fails LayoutNodeSchema.
Resolved render output
A component node renders to a RenderNode of type 'component':
{
type: 'component',
name: 'Stack',
props: { direction: 'row', gap: 8 }, // values resolved against the data
children: [...], // recursive RenderNode[]
ref: 'header-stack', // if set on the source node
model: { ref: 'header-stack', path: 'draftValue' }, // if model was set
}
When model is set and the node has no explicit value prop, the renderer also adds props.value — the model path's current value.
The framework adapter (React) reads this and instantiates the React component with these props.
Conditional nodes
Render one of two branches based on a condition.
{
if: '$.user.isLoggedIn',
then: { component: 'Text', children: 'Welcome back, {{$.user.name}}' },
else: { component: 'Text', children: 'Please log in.' },
}
Fields
if(required) — a binding evaluated for truthiness. Strings are bindings, primitives are themselves, objects can be{$if}directives. Truthiness rules:null,undefined,false,0,'',[]are all falsy. Everything else is truthy — including an empty object{}.then(required) —LayoutNoderendered when the condition is truthy.else(optional) —LayoutNoderendered when the condition is falsy. If absent, nothing is rendered.
Conditionals inside other places
There are two kinds of conditional in nova:
- Conditional NODE (this one) — appears in the layout tree as a sibling of components. Swaps whole subtrees.
{$if}directive — appears inside a value position (e.g. a prop value, a string content). See Bindings — directives.
Use the node form when you're swapping whole UI sections. Use the directive form when you're picking a value for a single field.
Loop nodes
Iterate an array and render the body once per item.
{
for: '$.users',
as: 'user',
do: {
component: 'Stack',
props: { direction: 'row', gap: 8 },
children: [
{ component: 'Text', children: '{{$user.name}}' },
{ component: 'Text', children: '({{$user.email}})' },
],
},
}
Fields
for(required) — a binding that resolves to an array. If it doesn't resolve to an array, the loop renders nothing (graceful empty).as(required) — the variable name used for the current item insidedo. References as$<name>(no dot).key(optional) — a path on each item (e.g.'id') used for stable identity. The renderer stamps each item's output withkey: the value at that path, or the index whenkeyis omitted. Adapters key by it, so looped rows can share arefwithout colliding.do(required) — theLayoutNoderendered once per item, with the loop variable in scope.
The index and items variables
Inside the loop body, the variable index is automatically bound to the current iteration index (0-based), and items to the array being iterated ($items) — so a control inside the loop can address the whole list. Both names are reserved: with an as of 'index' or 'items' the loop's own value wins and the item is unreachable. Reference the index as $index:
{
for: '$.items',
as: 'item',
do: { component: 'Text', children: '{{$index}}. {{$item.name}}' },
}
Empty arrays
If for resolves to [], the loop renders nothing. To show fallback content, wrap the loop in a conditional:
{
if: '$.items.length',
then: { for: '$.items', as: 'item', do: { component: 'Text', children: '{{$item.name}}' } },
else: { component: 'Text', children: 'No items yet.' },
}
Layout refs
Reuse a stored layout by id. The referenced layout is inlined at the ref position with the current scope chain intact.
// Pre-populate the layout store:
store.set('user-card', {
component: 'Box',
props: { padding: 16, background: '#eef2ff', radius: 8 },
children: [
{ component: 'Text', props: { weight: 'bold' }, children: '{{$.name}}' },
{ component: 'Text', children: '{{$.email}}' },
],
});
// Use it from a layout:
{
component: 'Stack',
children: [
{ ref: 'user-card' },
{ ref: 'user-card' },
],
}
Fields
ref(required) — id of a layout previously stored viastore.set(id, layoutNode).
Behavior
- The referenced layout sees the current scope at the call site. Inside a loop, a
LayoutRefreads from the loop variable just like an inline layout would. - If the ref doesn't resolve, the renderer throws
LayoutRefNotFoundError. In lax mode it's caught and replaced with an error RenderNode; in strict mode it propagates.
Slots
A placeholder that a fragment-merge fills.
{ slot: 'body' }
Only meaningful inside an ActionFragment's layout: when a fragment is composed with an action (a push/replace with: [...]), the fragment's { slot: 'body' } node is replaced by the composing action's own layout. An unfilled slot renders nothing. See ACTION_DOCS.md for fragments.
(The shell's canvas layouts use registered slot components — CanvasSlot / ActionSlot — which are ordinary component nodes, not this node kind.)
Primitives
Bare strings, numbers, booleans, and null are valid layout nodes. They render as text content.
'Hello, world!' // → text 'Hello, world!'
42 // → text '42'
true // → text 'true'
null // → empty text
'Welcome, {{$.name}}!' // → text with interpolation
'$.message' // → text resolved from data
Strings get full binding treatment — templates and bare paths both work. This is how text content typically appears as the children of a component.
Bindings
The single most important concept. Every value in a layout that could be data-driven goes through the same resolver. There are three forms.
1. Bare path strings
A string starting with $. reads a value from the data object.
'$.user.name' // → data.user.name (raw value, type preserved)
'$.user.email'
'$.items.length' // → array length
'$item.name' // → reads `name` from the variable `item` in scope
'$index' // → loop iteration index
The raw value is returned with its type preserved — numbers stay numbers, arrays stay arrays.
Path forms:
$.foo.bar— start from the data root, walkfoothenbar$foo— read variablefoofrom the closest enclosing scope (loop variable, etc.)$foo.bar— read variablefoo, walkbaron it$index— special: the loop iteration index
2. Template strings
A string with {{...}} patterns interpolates each expression and returns a string.
'Hello, {{$.user.name}}!' // → "Hello, Ada!"
'{{$.count}} items remaining' // → "3 items remaining"
'{{$.first}} {{$.last}}' // → "Ada Lovelace"
Each {{expr}} is resolved against the scope and stringified. Numbers, booleans become their string form. Objects/arrays become JSON.
A string that is exactly {{ expr }} (one expression, nothing else) preserves the raw type — useful when you want to feed a number into a numeric prop:
{ component: 'Stack', props: { gap: '{{$.spacing}}' } }
// If $.spacing is 16, props.gap is the number 16, not the string "16".
3. Directives
Inside a value position (a prop value, a children string, etc.), you can use the {$if, $then, $else} directive object.
{
component: 'Text',
props: {
color: { $if: '$.isError', $then: '#dc2626', $else: '#16a34a' },
},
children: 'Status',
}
Truthiness rules are the same as conditional nodes. Without $else, a falsy $if resolves to undefined.
Four more directives resolve in the same positions:
{ $eq: ['$.status', 'open'] } // → true when both resolved operands are strictly equal
{ $exists: '$.record.id' } // → true when the operand resolves to anything but undefined
{ $at: ['$.results', '$.cursor'] } // → the array element at a (dynamic) index
{ $at: ['$.results', '$.cursor', 'id'] } // → one field of that element
{ $prism: <prism node> } // → the node evaluated by Prism against the innermost scope:
// the root data, or inside a loop the loop's own scope
// ({ <as>, index, items } — the root is not reachable there);
// undefined when it fails to evaluate
They compose: { $if: { $eq: ['$.tab', 'all'] }, $then: 'primary', $else: 'ghost' }.
Why both if (node) and $if (directive)? Different layers. The node form swaps whole UI subtrees. The directive form picks a single value. The node form lives in the layout tree; the directive form lives in the value space.
What's NOT a binding
- Numbers, booleans, nulls — pass through unchanged
- Plain strings without
$or{{}}— pass through unchanged - Objects without a directive key (
$if,$eq,$exists,$at,$prism) — walked recursively, each value resolved
Two-way binding
Set model: '<path>' on a component to wire it for two-way data binding. The renderer resolves the path against the current scope chain (so loop variables work) and emits a model: { ref, path } field on the rendered node. The action runtime writes the event's payload back to that path when the component dispatches a ui:model event with that ref — before any ui:model trigger runs.
{
component: 'Input',
model: '$.draftName', // reads + writes data.draftName
props: { placeholder: 'Your name' },
}
The Input component built into @niscorp/nova/adapters/react/components already dispatches ui:model on change, so this just works. To wire your own component, see REACT_DOCS.md.
Loop case
model: '$item.value' inside a loop binds each iteration to its respective item's value field. The renderer materializes the absolute data path per iteration so each input writes to the right place.
{
for: '$.items',
as: 'item',
do: { component: 'Input', model: '$item.value' },
}
Type into the second input → data.items[1].value updates → re-render shows the new value.
Render context
renderLayout(layout, data, ctx) takes a context object:
{
store: LayoutStore, // for resolving LayoutRefs
registry: ComponentRegistry, // to validate component names
strict?: boolean, // throw on errors vs return error nodes (default false)
onError?: (error: NovaError) => void, // telemetry for non-strict errors
phrases?: Phrasebook, // i18n: source phrase → the reader's words (see I18N_DOCS.md)
phraseKeys?: PhraseKeys, // i18n: which prop keys carry prose
onPhraseMiss?: (phrase: string, where: string) => void, // i18n: a phrase with no entry in the book
}
render({ layout, data?, store, registry, ... }) is the object-form alias of renderLayout — same options, named, with data optional.
When strict: false (the default), errors during rendering are caught at each subtree boundary, surfaced via onError, and replaced with an error RenderNode. Sibling subtrees keep rendering. When strict: true, errors propagate.
Errors the renderer can produce
ComponentNotFoundError—node.componentisn't registered.LayoutRefNotFoundError—node.refisn't in the layout store.RenderError— anything else thrown during render (binding resolution, etc.).
All extend NovaError (see DESIGN.md).
RenderNode output
The renderer emits RenderNode[] — a discriminated union:
type RenderNode =
| { type: 'text'; value: string }
| { type: 'fragment'; children: RenderNode[] }
| {
type: 'component';
name: string;
props: Record<string, unknown>;
children: RenderNode[];
ref?: string;
model?: { ref: string; path: string };
}
| { type: 'error'; code: string; message: string; nodeRef?: string };
Every variant may also carry key?: string — the identity key the renderer stamps on a loop item's output (see Loop nodes), distinct from ref.
This is the contract between the renderer and any framework adapter. The React adapter walks this tree and instantiates the registered React components. The Vue, DOM, TTY and Ink adapters (@niscorp/nova/adapters/*) do the same on their own surfaces.
Components
A component is anything you put in the registry. The renderer doesn't know what they look like — that's the framework adapter's job. From the layout's perspective, a component is just a name + a props schema (optional).
Registering a component
const registry = createComponentRegistry();
// Bare registration:
registry.register('MyButton', myButtonComponent);
// With meta (description + Zod props schema for introspection):
registry.register('MyButton', myButtonComponent, {
description: 'A clickable button.',
propsSchema: ButtonPropsSchema,
});
// Bulk registration:
registry.registerAll({
Stack,
Text,
Input,
Button,
Box,
MyButton: { component: myButtonComponent, meta: { description: 'A clickable button.' } },
});
registry.get('MyButton'); // → { component, meta } | undefined
registry.has('MyButton'); // → boolean
registry.list(); // → array of registered names
Meta is { description?, propsSchema?, events? } — events maps an event name to { description?, payloadType? }.
registerAll and static meta
If a component carries a static .meta property, registerAll picks it up automatically. Explicit meta in the call overrides static. See REACT_DOCS.md for the React-specific component shape.
The default component set
@niscorp/nova/adapters/react/components ships nine headless React components:
Stack— flex container withdirection,gap,align,justify,padding,wrapText— typography element withas,size,weight,colorInput— text input bound viamodel; holds a local draft while focused and honours adebounceprop (ms) onui:modeldispatchesButton— clickable button that dispatchesui:clickwith itsrefBox— generic styling containerPanel— titled container with optionalbackRef/closeRefheader buttonsJsonTree— collapsible JSON viewer (devtools' introspection primitive)CanvasSlot/ActionSlot— shell-aware slots used inside shell/canvas layouts; only usable under a<NovaShellProvider>(seeREACT_DOCS.md)
Use registerNovaReactComponents(registry) to install all nine at once.
Layout store
A simple keyed store for reusable layouts.
const store = createLayoutStore();
store.set('user-card', { component: 'Box', children: [...] });
store.get('user-card'); // → the stored layout, or undefined
store.delete('user-card'); // remove it
store.list(); // → array of stored ids
store.resolveReferences(layout); // → the layout with every { ref } it can resolve inlined
set validates the layout against LayoutNodeSchema and throws DefinitionValidationError if it doesn't parse. So you can't accidentally store an invalid layout.
Errors
Layout-time errors all extend NovaError:
ComponentNotFoundError—{name: string}contextLayoutRefNotFoundError—{ref: string}contextRenderError— generic wrapper for thrown exceptions during renderDefinitionValidationError— whenlayoutStore.setrejects an invalid layout
In strict mode (strict: true on the render context) these throw. In lax mode (the default) the renderer catches them at each subtree boundary and emits a {type: 'error', code, message, nodeRef?} RenderNode in place of the broken subtree, then continues. Sibling subtrees are unaffected.
Quick reference
// Component
{ component: string, props?: object, children?: LayoutNode | LayoutNode[], ref?: string, model?: string, events?: object }
// Conditional (node form)
{ if: Resolvable, then: LayoutNode, else?: LayoutNode }
// Loop
{ for: Resolvable, as: string, key?: string, do: LayoutNode }
// LayoutRef
{ ref: string }
// Slot (inside an ActionFragment's layout)
{ slot: string }
// Primitive
'string' | 123 | true | false | null
// Array (fragment)
[LayoutNode, LayoutNode, ...]
// Conditional (directive form, used in value positions)
{ $if: Resolvable, $then: any, $else?: any }
// Other value directives
{ $eq: [Resolvable, Resolvable] }
{ $exists: Resolvable }
{ $at: [Resolvable, Resolvable, field?] }
{ $prism: PrismNode }
For actions that drive data into the layout, see ACTION_DOCS.md. For wiring layouts into a React app, see REACT_DOCS.md.