Nova · ADAPTER.md

This is ADAPTER.md as @niscorp/nova 0.5.1 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/nova/ADAPTER.md

Nova adapter contract

Nova's core is framework-free. It renders layout trees into RenderNode[] and owns all state, events, and subscriptions. An adapter binds that to a UI framework. Adapters live under src/adapters/, one folder per framework with its own export subpath. Five reference implementations ship: the React adapter (@niscorp/nova/adapters/react), the Vue adapter (@niscorp/nova/adapters/vue — Vue 3 render functions in plain TS, no SFC compiler; provide/inject for the render context, composables bridging the shell subscriptions into shallowRefs, children handed to a component as its default slot, the same primitive kit at /adapters/vue/components over the SAME props schemas as React's), the plain-DOM adapter (@niscorp/nova/adapters/dom, createDomView — no framework at all), the line-terminal adapter (@niscorp/nova/adapters/tty, createTtyView — a pure render to { text, interactives }; the host maps commands onto the numbered interactives), and the full-screen terminal kit (@niscorp/nova/adapters/ink — an Ink component vocabulary riding the React adapter's walker; Tab cycles focus, Enter activates). A Svelte adapter would be a sibling folder, built the same way.

A react-shaped host that is not the DOM threads three optional seams through NovaRenderProvider: fallback (unregistered names render their children instead of an error marker), textWrapper (ink forbids bare strings outside <Text>; the DOM renders them raw), and errorMarker (the default is a <span>, which crashes a non-DOM renderer). Browser consumers omit all three. A ref'd node rendered by a permissive fallback must still be actionable — the TTY walker marks refs universally, so a kit-level fallback (ink's) carries the focus + click convention itself.

An adapter imports only from @niscorp/nova's public surface (plus its own framework). Core never imports from an adapter.

Obligations

An adapter must provide six things.

1. A RenderNode walker

Map RenderNode[] to framework elements:

  • text → a text node.
  • fragment → its children, keyless grouping.
  • error → a visible marker carrying code and message (the React adapter renders a <span data-nova-error>; match that convention).
  • component → look the name up in the registry; unknown names render an error marker with code COMPONENT_NOT_FOUND, never throw.
  • a head (isHeadNode(node), the component named HEAD_NAME) → nothing, and nothing for its children. It is the document's, not the screen's: it is never looked up in the registry, and never handed to its parent component as a child (a kit that gives every child a cell would give it an empty one). An adapter for a surface with a head of its own keeps that head on headOf(api) — in a page, createHeadKeeper from @niscorp/nova/document.

Inject framework props from the node using the core constants: node.model → NOVA_MODEL_PROP ({ ref, path }), node.ref → NOVA_REF_PROP (string). Spread node.props on top.

List identity comes from core's renderNodeKey(node, index). Do not invent key rules.

2. Dependency injection for registry, dispatch, publish

Rendered components never touch the shell. The adapter provides a component registry, a dispatch(event) function, and a publish(channel, payload) function through the framework's DI (React context, Vue provide/inject). Without a shell, dispatch and publish are no-ops so static layouts render with no event infrastructure.

3. Subscription bindings

Bridge core subscriptions into the framework's reactivity:

  • shell.onStateChange(handler) — whole-shell snapshots.
  • shell.onCanvasChange(canvasId, handler) — one canvas; the shell owns the equality check and only fires on meaningful change.
  • runtime.onDataChange(handler) / runtime.onStatusChange(handler) — one action instance (via shell.getRuntime(instanceId)).

Trees come from shell.getShellRenderTree(), shell.getCanvasRenderTree(id), and runtime.render(). Recompute on the matching subscription, not on a timer or a broader signal.

A remote renderer (a moss terminal) has no shell to subscribe to; it is handed core's RenderApi instead — frame(), canvasTree(canvasId), dispatch(canvasId, event), publish(channel, payload?) — and re-renders when its host says so. The DOM adapter's createDomView consumes exactly this shape.

A LOCAL shell is read as that same shape by core's shellView(shell) — { api, subscribe }: the shell's own trees as they stand, its own dispatch (an event that names no origin goes to the canvas's active instance), and one coalesced call per burst of changes. So an adapter written against RenderApi draws a shell beside it with no second code path — the DOM adapter's mountShell(root, registry, shell) is createDomView over a shellView — and an empty canvas is [] either way (canvasTreeOf).

Drawing to a string. An adapter that can draw where there is no browser lets a shell arrive as markup: React and Vue through their own server renderers, the DOM adapter through renderToString(registry, api, { window }) (@niscorp/nova/adapters/dom/server — the host hands in a DOM; nova depends on none). Await shellSettled(shell) first: an instance is initializing until its mount hook has been awaited, so that is the moment the first screen is whole. Adapters that adopt in place (React, Vue) hydrate over the markup; the DOM adapter's first render replaces it with the same elements in one step, and keeps those from then on (see "The DOM adapter keeps what did not change").

4. Structural slots and the component vocabulary

Register two structural components under the core names CANVAS_SLOT_NAME and ACTION_SLOT_NAME — the shell's default layouts reference them:

  • CanvasSlot ({ canvasId }): renders that canvas's tree; nothing when canvasId is missing.
  • ActionSlot ({ instanceId }): renders that instance's tree; nothing when instanceId is missing. Wrap the dispatch handed to the instance's subtree with core's scopeDispatch(dispatch, instanceId) so its UI events reach its own triggers only. An event that already carries an origin passes through unchanged. Do not reimplement the stamping rule.

flattenRenderTree resolves CanvasSlot markers away, but the ActionSlot marker survives flattening: it arrives as a component node with props: { instanceId, canvasId, definitionId }, key: instanceId, and the instance's rendered tree as children. An adapter rendering flattened (served) trees must key by instance so a swap remounts, and may read the identity props (a slot wrapper's seam).

The registered ActionSlot's propsSchema stays the authoring contract — instanceId only. The flatten-stamped identity props are runtime output, never authored, so they don't belong in the schema (the layout agent's palette reads it as "props you may set"). Do not validate served trees against registry schemas — that checks output against an authoring contract.

Ship the primitive vocabulary — Stack, Text, Input, Button, Box at minimum, plus the introspection primitives Panel and JsonTree (nova's devtools compose against them) — with static meta (description + props schema) so registries and agent tooling can introspect them. The React and Vue kits ship exactly that set plus the two slots (registerNovaReactComponents, registerNovaVueComponents). The DOM kit additionally ships Row, Grid, Checkbox, Textarea, and Table, with Select and Switch as aliases of Input and Checkbox; the TTY and Ink kits ship the DOM kit's names plus Badge (each a defaultRegistry()).

Table, in the three kits that ship it, reads columns ([{ label, cell: { key } }], key a dotted path into a row) and rows. With rowRef set, a press on a row dispatches ui:click on that ref with the row's clickKey value as payload; clickKey defaults to rowKey, and rowKey to id.

createDomView, mountShell and renderToString take a fallback component in their options for a name the registry does not hold; without one, such a name draws a COMPONENT_NOT_FOUND marker. The DOM kit exports one (fallback): a div holding the node's children, or with no children its label or title prop and a count.

5. SlotWrapper persistence

If the adapter supports an app-supplied slot wrapper (animation, gating, logging at the ActionSlot seam), the wrapper renders persistently — with content or with none — so a presence-managing wrapper can animate an instance leaving. The adapter hands the wrapper identity only (canvasId, instanceId, the ActionDefinition), never live state; identity fields are undefined while the slot is empty or exiting. Nova owns no timing.

6. Remote round-trip obligations

The shell may be authoritative over a socket (moss serves canvas trees down, NovaEvents up), so a bound input's echo is asynchronous — the tree carrying the value you just typed arrives a round trip later. Two behaviours that a local shell gets for free (its echo is synchronous) an adapter must implement itself, or bound inputs drop keystrokes the moment the shell is remote:

  • Preserve the in-progress value of a focused input. While a model-bound input is focused, its local editing value is authoritative; an incoming tree must not overwrite it. Release to the server value on blur. The React, Vue and Ink Inputs hold a local draft (null = not editing, server wins); the DOM adapter does not build a field again for a change of its value alone while it is being typed in, and shows the tree's value again at the first render after it is left. Same rule, framework-shaped mechanism.
  • Honour the debounce prop on a model-bound node. props.debounce (milliseconds, default 0) coalesces ui:model dispatches; flush any pending value on blur. The React, Vue, DOM and Ink adapters implement it — omitting it silently makes a served debounce a no-op in your terminal, so the same layout behaves differently across renderers.

These belong to every adapter because they are artifacts of the transport, not of taste — unlike styling, hover, or animation, which should differ per kit. The line is exactly that: an adapter shares nova's treatment of the round trip and nothing about how pixels look.

A line renderer discharges both trivially: the TTY adapter has no focused input (a set <n> <text> command is one atomic, complete value, so there is no in-progress draft for an echo to clobber) and therefore nothing for debounce to coalesce. The obligations bind any adapter that does hold live input focus — a full-screen TUI included.

The DOM adapter keeps what did not change

React and Vue hand nova's trees to a reconciler. The DOM adapter has none to hand them to, so it is one: createDomView keeps the tree it last drew beside the DOM that tree produced, and a render walks the two together. What is the same as last time keeps its element — the same DOM node, never taken off the page — and only what differs is built.

That is the difference between a page that is drawn and a page that is drawn again. An element that stays keeps everything the tree does not hold: focus, scroll position, a text selection, an open <details>, a CSS animation in flight, a playing <video>, a timer or an observer its component started. And the cost of a change is the size of the change, not the size of the page: one changed string is one text node written.

What a render does

  • The same node keeps its element. A component whose props, ref, model and children are what they were is not asked for an element again.
  • A text that changed is written into the text node already there.
  • Siblings are matched by renderNodeKey, as obligation 1 says. Two that share a key (looped rows that share a ref and carry no key) are told apart by order. A list that is reordered has its elements moved — as few as will do — not built; one that gains or loses an item gains or loses that one element.
  • A component whose own props, ref or model changed is asked again. What is below it was reconciled all the same and is handed in, so a descendant that did not change is the same node under the new element. It was moved, though, and a move is not free: the browser restarts a moved element's animations and drops its scroll.
  • A different instance or canvas in the same place is all new. An ActionSlot whose instanceId changed, or a CanvasSlot whose canvasId did, keeps nothing — with or without a key (obligation 4's "a swap remounts"). Its listeners dispatch as the old instance; kept, a press would reach an instance that is no longer there.
  • A change in one instance or on one canvas leaves the others alone. That follows from the rest and is the point of it.
  • The first render replaces whatever the root held, once: markup drawn ahead of time is replaced with the same elements. Somebody already typing in that markup keeps their words, their caret and their focus — the adapter finds the field's twin by where it sits. Adopting the elements themselves, rather than replacing them, is not done.

What a kit can rely on, and what it owes

A DomComponent is still a builder: props and already-built children in, one element out, no update path. What changed is when it is called — only when it has to be. It is not called because something else on the page changed, so:

It is a function of its props and its children, and of nothing else. A component that reads the clock or the window while it builds keeps what it read until its own props change. Something that has to move on its own — a countdown — moves itself (a timer), and stops through onRemove.

onRemove(cleanup) is the other end of anything a component starts. The cleanup runs once, when the element leaves the page for good: it was removed, it was replaced because its own props changed, or the view was destroyed (renderToString destroys the view it drew with, so nothing is left running where only markup was wanted). An element that stays is not told anything.

dispatch(event) is for a component with presses of its own — a table's rows, a panel's close button. The event goes to the canvas the component sits in, as the instance it sits in; in the frame, outside any CanvasSlot, it goes nowhere. A node's own ref and model need none of it: the adapter wires those on the element the component returns.

Children are patched in place only under an element that holds exactly them. The adapter looks at what the component returned:

The component… When its children change
put them, in order and with nothing else, straight into the element it returned they are put in, taken out and moved under that element; the component is not asked
did anything else with them — an inner wrapper, a cell around each, a heading of its own before them, read them and placed none it is asked again whenever it would be handed different children. A change inside a child is still patched where it stands
goes from having children to having none, or back it is asked again, whichever of the above it is — it may draw itself differently when empty

The middle row is not a penalty for being unusual; it is the only safe answer. A kit that wraps each child in a cell looks, with a single child, exactly like one that wraps them all in one box, and the adapter cannot ask what it would do with a second. So: for a list whose items come and go — a thread, a feed, a set of cards — put the children straight into the element that holds them (and that scrolls, if it scrolls). A wrapper inside it means every arrival moves every item.

A component that depends on its children says so. One that holds its children and marks them, counts them, or treats the first unlike the rest looks like one that only holds them — and a child patched in beside the others would go unmarked. It calls dependsOnChildren() while it builds, and is then asked again whenever it would be handed different children. It is handed the children that stayed as it left them, so what it does to them it must be able to do twice: set a mark and clear it, not only set it.

What changes often goes in as children, not as a prop. A component fed by a prop is built again when that prop changes — a new element, without the scroll or the animation of the old one. The same text handed in as a child is a text node written in place, under an element that stays:

{ component: 'Code', props: { text: '$.source' } }   // a new <pre> on every change
{ component: 'Code', children: '$.source' }          // the same <pre>, its text written

This is where the DOM adapter is still a step behind React and Vue, which run the component again and keep the element. An update path on a component would close it; nothing has needed one yet.

Bound fields and focus

  • A field that is being typed in is not replaced for its value. A tree that differs from the last only in a bound field's value — the echo of what was just typed, or a value from elsewhere — leaves the field alone while the person is in it: its caret, an IME composition and a pending debounce are the element's, and stay with it. Once it is left, the next render shows the tree's value again (the kit is asked for the field afresh, because how a value is shown is the kit's business). A checkbox or a radio is not "typed in": the tree's answer shows at once.
  • A field that had to be built again while focused (another of its props changed) is given what was being typed, the caret, and focus.
  • Focus follows the element. One that stayed has it still. One that was moved has it given back; one that was built again has it given to the element built in its place — a button as much as a field.
  • Leaving a field sends what debounce was holding, as obligation 6 says.

What it costs

Keeping a tree beside the DOM is not free, and the cases where it costs more than it saves are the ones in which nothing could be kept. Measured in Chrome 154 (2026-10-03) on a list of 2,000 rows, each change drawn and laid out, against the adapter that drew everything again:

drawn again patched
one row's text changes 5.0 ms 0.9 ms
one row is added, or moved 5.1 ms 1.4 ms
every row's text changes 7.2 ms 4.9 ms
the first draw 5.6 ms 6.3 ms
the whole list reversed 5.1 ms 5.6 ms
every row's own props change 5.3 ms 6.7 ms
every row replaced by a new one 5.2 ms 6.8 ms

So: a first draw costs about a tenth more, and a render that replaces everything about a third more. Any render that keeps something costs less, and the usual one — a little changed on a large page — a small fraction. What the view holds does not grow with use: after 24,000 renders that each replaced a whole list it held what it held after 3,000.

How it is held to that

test/adapters/dom-retained.test.ts asserts identity (toBe) and "never taken off the page" (a MutationObserver over the root — an element removed and put straight back is the same node and has still lost its scroll). test/adapters/dom-differential.test.ts draws long runs of random trees and holds the patched page, after every one, to a page drawn from nothing: the same markup, the same dispatch from every press, and no write at all when the same tree is drawn twice. Its kit has one component for each way of treating children named above. A change to the adapter that lets a patched page drift from a drawn one fails there, by seed.