Nova · README.md

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

nisc.dev/nova/README.md

@niscorp/nova

Declarative, framework-agnostic UI runtime for actions composed from JSON layouts and effects.

Status: on npm and live. The version is below 1.0; releases are compatible all the same — an app that works on one works on the next, and what should go is deprecated, not removed.

Install

pnpm add @niscorp/nova @niscorp/prism @niscorp/strata zod

@niscorp/prism, @niscorp/strata and zod are required peers. Everything else is optional and installed per surface: react for /adapters/react and /adapters/ink, vue for /adapters/vue, ink and ink-text-input for /adapters/ink, @niscorp/cortex for /agent. Node >=22.12.

One tree, five renderers

Nova core renders layouts to plain RenderNode[] — no framework, no DOM, no terminal. What consumes that tree is an adapter, and five ship here:

adapter surface interaction
adapters/react browser, your styled kit pointer + keyboard
adapters/vue browser, your styled kit pointer + keyboard
adapters/dom browser, zero framework pointer + keyboard
adapters/tty any terminal, line REPL every interactive carries a [n] marker — type the number
adapters/ink any terminal, full-screen the same [n] markers, plus focus and live typing

Same trees down, same ui:click / ui:model / ui:key events up — an app renders in all five without changing a line, and a served host (see @niscorp/moss) cannot tell them apart. The TTY numbering is computed once and shared: [7] is the same interactive in the REPL and the TUI.

An adapter is a walker plus a component kit of ~15 domain-blind primitives. ADAPTER.md is the whole contract; the terminal adapters' APIs are in TERMINAL_DOCS.md; a Svelte or react-native adapter is a sibling folder, built the way the Vue one is.

Components are decoupled from the shell — they receive only the layout's props plus children / novaModel, and emit events via context hooks. The React adapter additionally ships <NovaRenderProvider>, <NovaShellProvider>, <RenderTree>, and the shell hooks; see REACT_DOCS.md.

An optional slotWrapper prop wraps every action instance's content at its mount/unmount seam — one pluggable point for animation, auth / feature gates, logging, or error boundaries, with nova owning none of that logic. See REACT_DOCS.md.


What it is

Nova is a runtime for actions: stateful units of work backed by data, endpoints, triggers, lifecycle hooks, and a JSON layout. A Shell hosts a stack of action instances per canvas (a logical surface — a screen, a panel, a modal stack), drives their lifecycle, and routes events and messages between them.

The core is pure TypeScript with zero framework dependencies. The renderer produces a RenderNode[] tree — a plain JSON-shaped structure that any framework adapter (React, Vue, headless test harness) can turn into real elements. Layout, action, and shell logic are all testable without ever importing a UI framework.

What makes it different from "just another UI library":

  • Declarative. Layouts and actions are JSON. The runtime walks them.
  • Headless core. No framework lock-in. Adapters are thin.
  • Two-way binding built into the layout DSL via model: "$.path".
  • Strict / lax modes that decide whether errors throw or are surfaced via onError.

What it isn't

Explicitly out of scope right now:

  • No LLM features in the core. No prompt scaffolding, no plan generation. The one agent nova ships — the layout agent — is opt-in at /agent (see below).
  • No JSON Schema generation / catalog. Use z.toJSONSchema() on the exported Zod schemas if you need JSON Schema externally.
  • No mandatory components. The component registry is empty by default; opt-in reference kits ship at the adapter subpaths (/adapters/react/components, /adapters/vue/components, /adapters/dom/components, /adapters/tty/components, /adapters/ink).
  • No page around the screen. Nova draws a shell to markup where there is no browser (the React hooks, Vue's server renderer, the DOM adapter's renderToString) and picks that markup up in the page — but the document, the paths and the checks a build makes are a host's (@niscorp/cli's nisc export, or moss for a served shell). See the React compatibility section below.
  • No wire protocol. Definitions are in-process objects, not a serialization format. The wire lives in moss; nova ships the render surface a remote terminal targets (RenderApi, flattened trees that keep ActionSlot identity).

Quick example

import { createShell, createLayoutStore, type ActionDefinition } from '@niscorp/nova';
import { defaultRegistry } from '@niscorp/nova/adapters/tty/components';

const counter: ActionDefinition = {
  id: 'counter',
  data: { count: 0 },
  triggers: [
    {
      event: 'ui:click',
      ref: 'inc',
      do: [{ increment: 'count' }],
    },
  ],
  layout: {
    component: 'Box',
    children: [
      { component: 'Text', children: 'Count: {{$.count}}' },
      { component: 'Button', ref: 'inc', children: '+1' },
    ],
  },
};

const shell = createShell({
  canvases: [{ id: 'main' }],
  registry: defaultRegistry(),
  layoutStore: createLayoutStore(),
  actions: { counter },
});

const id = shell.push('main', 'counter');
const runtime = shell.getRuntime(id);
shell.dispatch({ type: 'ui:click', ref: 'inc' });
console.dir(runtime?.render(), { depth: null });

It prints the RenderNode[] tree, after the press:

[
  {
    type: 'component',
    name: 'Box',
    props: {},
    children: [
      {
        type: 'component',
        name: 'Text',
        props: {},
        children: [ { type: 'text', value: 'Count: 1' } ]
      },
      {
        type: 'component',
        name: 'Button',
        props: {},
        children: [ { type: 'text', value: '+1' } ],
        ref: 'inc'
      }
    ]
  }
]

There is no React in that example, and no browser. The registry is empty until a kit is registered, and a layout that names a component it does not hold renders an error node (COMPONENT_NOT_FOUND) in its place; defaultRegistry() is the terminal kit. A framework adapter would consume the same RenderNode[] and produce real elements.


The three subsystems

Layout

A JSON tree of components, conditionals, loops, refs, and primitives. renderLayout(node, data) walks the tree, resolves bindings, and emits RenderNode[]. Bindings come in two forms:

  • "$.user.name" — bare path, returns the raw value.
  • "Hello {{$.user.name}}" — interpolated string.

Conditionals at the value level use { $if, $then, $else }. Layout nodes also have first-class if / for / ref shapes. See LayoutNodeSchema.

Action

A stateful unit. An ActionDefinition carries:

  • data — the initial state record.
  • input — optional JSON Schema of the data keys an opener may seed when loading the action (its openable-input contract; descriptive, not enforced by the runtime).
  • endpoints — named HTTP or local-function call configurations, with optional request/response transform configs run by the injected evaluator.
  • triggers — (event | message) + ref → do: Step[] bindings.
  • lifecycle — mount, unmount, suspend, resume hooks, each a Step[].
  • layout — the layout to render.

A Step is either a mutation (one of set, toggle, increment, decrement, push, pop, removeAt, move, clear, reset) or an effect (call, emit, navigation push/pop/replace/popTo/resetTo/ removeInstance/removeSelf/reconcile, reload). Mutations are op-per-file under action/mutations/ops/.

Every navigation step moves ONE action. reconcile is the one that moves as many as data says: it makes a canvas hold exactly the actions a list in the action's data names — missing ones pushed, unlisted ones removed, the rest kept mounted (shell/reconcile.ts, as a step). A row naming an action the shell does not have is skipped, as an ungranted initial candidate is.

{ reconcile: { canvas: 'tools', to: '$.tools', action: 'tool_id', own: 'canvas' } }

action names the row field holding the action id; input (optional) the field holding its input. own: 'pushed' (default) removes only what this action placed there — stamped with its definition id — while 'canvas' treats the whole canvas as the action's. Added at grammar nisc.nova 1: a reader at 0 refuses an action that uses it (TOO_NEW).

What reconciling means is nova's own unless the canvas names a reconciler the app handed the shell (CanvasConfig.reconcile, ShellConfig.reconcilers): then that function decides, from the same step, handed each row as written — several instances of one action matched by a key and kept in the list's order, say. It may move an instance without re-mounting it (moveInstance) and ask which key one was pushed with (keyOf). See SHELL_DOCS.md, reconcileCanvas.

The runtime is a closure factory (createActionRuntime) — no classes. It owns a reactive data store, an AbortController, the trigger handles, and the model-binding listeners.

An ActionFragment is a reusable partial action (every field optional, a kind: 'fragment' marker) — layout chrome plus wired triggers/data. It is composed into a concrete action at the call site via a push/replace with: ['id']: the fragment wraps the action, dropping the action's layout into its { slot: 'body' }; the action wins on conflict. Pure data, so it ships in a DB row and can be referenced rather than inlined.

Shell

The orchestrator. createShell(config) validates every action definition at construction (boundary Zod validation) and returns a Shell with push, pop, popTo, removeInstance, replace, clear, back, originOf, registerAction, removeAction, addCanvas, removeCanvas, setCanvasLayout, setLayout, setPhrases, getPhrases, getCanvasState, getRuntime, getState, getShellRenderTree, getCanvasRenderTree, flattenRenderTree, dispatch, publish, onStateChange, onDataChange, onEndpoint, onCanvasChange, dispose. Canvases are CanvasConfigs ({ id, mode?, actionLayout?, initial?, reconcile? }) — a canvas can pre-seed its stack, declare how its instances are arranged, and hold them as a stack (default: the top is active, the rest suspended) or a list (every instance stays live). The shell maintains a stack per canvas, drives lifecycle hooks via the runtime, composes any with fragments into the action at push/replace time, and routes navigation effects emitted from action steps back into shell calls.

The head

nova:head is the document's <head>, as a node in a layout. Its children are the elements a head holds, named as HTML names them, and bound to the action's data like anything else in the layout:

{ component: 'nova:head', children: [
  { component: 'nova:title', children: '{{$.page.title}} · the site' },
  { component: 'nova:meta', props: { name: 'description', content: '$.page.lead' } },
  { component: 'nova:meta', props: { property: 'og:image:alt', content: '$.page.title' } },
  { component: 'nova:link', props: { rel: 'alternate', hreflang: 'de', href: '$.page.german' } },
  { component: 'nova:script', props: { type: 'application/ld+json', data: '$.page.about' } },
]}

A child's props are that element's attributes, written as given. Nova keeps no list of them: any name, property or rel there is — or will be — can be said without nova learning it. nova:title's text is its children; nova:script takes a type and its data (any JSON). Loops and conditions work in a head as they do anywhere in a layout.

Nothing in it is drawn on the screen. No adapter builds an element for a head or for what it holds, no registry has to have the names, and a component is never handed a head as a child. It rides in the render tree, so it reaches every place a screen does — a shell in the page, a snapshot a server drew, the trees on a wire — and headOf(api) reads it off any of them: { elements, actions, refused }. Each of elements is { tag, attributes, text? }, where text is a title's words or a script's data as JSON. actions lists the ids of the actions that said any of them; it is empty when only the shell's own layouts did.

A screen can hold more than one head: the shell's chrome says what holds on every screen, an action says its own, a dialog opens over a page. They are read in the order the screen is drawn, and an element that says the same thing as an earlier one — the title, a <meta> of that name or property, the canonical address — takes its place. Anything else stands beside its like. An element whose value is not answered yet (no content, no href, no text) is left out.

What a head does not hold is what runs or styles: a script a browser would execute (nova:script is a data block — a JSON type, never a src), a stylesheet, http-equiv, a handler attribute. A layout is data, and data that reaches every page must not be able to run. Such an element is left out and named in refused. Those belong in the app's own index.html, which holds anything, or in the kit component that needs them.

@niscorp/nova/document is the surface that has a <head>. placeHead writes the elements into an HTML document being drawn to a string (what @niscorp/cli and moss's renderDocument call): an element takes the place of the tag that said the same thing, and anything else is added. Its third argument is { site, path }: given both, with site an origin (https://example.com), it also writes that path's canonical <link> and og:url, built from site and path without its query or fragment, as the document's own tags, so a screen that says either takes its place. Given only one of the two it writes neither. In the page, the DOM, React and Vue adapters keep document.head on the screen (createHeadKeeper): its elements go in, the document's own tags give way to them and come back when the screen stops saying them. To offer the names to a layout agent, register them with HEAD_META so the palette lists them.


Authoring

The Zod schemas are the source of truth and are validated at every boundary. They are exported from the package root:

  • LayoutNodeSchema, ComponentNodeSchema, ConditionalNodeSchema, LoopNodeSchema, LayoutRefNodeSchema, SlotNodeSchema
  • ActionDefinitionSchema, ActionFragmentSchema, MutationSchema, StepSchema, EffectSchema, TriggerConfigSchema, EndpointConfigSchema, LifecycleConfigSchema

Boundary throws use DefinitionValidationError with a structured failures array.


Errors and strict mode

ShellConfig accepts strict?: boolean and onError?: (err) => void.

  • Lax mode (default). Lifecycle failures route through onError. Renderer subtree failures become RenderErrorNodes and siblings continue.
  • Strict mode. Lifecycle failures rethrow as LifecycleError. Shell methods stay synchronous, so the error is stored in a one-slot buffer and surfaces at the next public shell call (push, pop, replace, clear, getCanvasState). Renderer failures throw out of render().

The error hierarchy lives in shared/errors.ts:

NovaError
├── RenderError
├── ComponentNotFoundError
├── LayoutRefNotFoundError
├── DefinitionValidationError
├── UnknownActionError
├── UnknownFragmentError
├── UnknownFunctionError
├── ShellDisposedError
├── LifecycleError
└── MutationError

All carry a stable code (ErrorCodes.*), an optional context, and a JS-native cause.


Two-way binding

A layout component can declare model: "$.user.name". The renderer emits model: { ref, path: 'user.name' } on the corresponding RenderComponentNode. The action runtime auto-installs a ui:model event listener for that ref. The framework adapter is responsible for emitting ui:model events on the event bus when its inputs change; the runtime responds by applying a set mutation to the configured path.

model paths inside loops are resolved to absolute paths (items.0.value) by the renderer using its scope-path tracker, so loop items round-trip correctly.


Async safety

Every StepContext carries an AbortSignal from a runtime-owned AbortController. callEndpoint forwards the signal to fetch. On unmount, the controller is aborted, so in-flight calls bail out without leaks. Unmount hooks themselves run on a fresh signal so they can perform their own final async work (e.g. telemetry flush).


React compatibility

  • React 19 required (peer react ^19.2.4). The adapter uses useSyncExternalStore.
  • Concurrent rendering: fully supported. useSyncExternalStore is tearing-safe by design — the snapshot getters in useRenderTree and useCanvas return referentially-stable values when the underlying data or canvas hasn't changed, which the reference-stability tests verify.
  • StrictMode: supported. Snapshot caches correctly handle effect double-invocation; nothing tears and no "snapshot returned different values" warning appears.
  • Suspense: not used as a loading model. Loading state is explicit data on the action (e.g. {loading: true} as a regular field), so the layout renderer reacts to it through data bindings. Consumers can still wrap nova components in <Suspense> but it never activates — nova hooks never throw promises. This is intentional: actions model their async state as data so layouts can bind to it directly, rather than unwinding the tree for Suspense to catch.
  • SSR: supported both ways a screen can be held. A shell that lives with its adapter — <NovaShell> and the shell-backed hooks — draws under renderToString (each hook's server snapshot is the shell's own value) and is adopted by hydrateRoot over a second boot of the same shell: await shellSettled(shell) before either, so both draw the whole first screen. A served screen — NovaRenderProvider + RenderTree over a RenderApi — renders with no store at all; that is how moss draws a page server-side and how its React target adopts it. shellView(shell) reads a local shell as that same RenderApi, which is how the DOM adapter draws one (mountShell, and renderToString from /adapters/dom/server). The DOM adapter replaces drawn markup on its first render and patches from then on: what did not change keeps its element (ADAPTER.md, "The DOM adapter keeps what did not change" — a DOM kit's contract is there).
  • React Server Components: not tested. The hooks are client-only.

Reflection and devtools

  • @niscorp/nova/reflect — read-only introspection over definitions and live shells; pure and framework-free. walkNodes / componentsOf / refsOf / loopVarsOf (layout walks), snapshotShell / describeInstance (running state), actionGraph (emit/message wiring), classifyAudit / auditCatalog (closure-audit triage), livenessOf (what an action can still do once drawn: gestures, the channels it waits on, every endpoint and whether it is called on open or later).
    • walkNodes(value, visit) calls visit on every plain object in a tree, an object before the ones inside it. refsOf returns only the refs inside a component's props (a Table's rowRef, a cell's ref), not a node's own ref.
    • snapshotShell(shell, exclude?) returns { canvases, layouts, components }: every canvas with its instances, top of the stack first, the ids in the layout store, and every component name in the registry. exclude lists canvas ids to leave out.
    • describeInstance(shell, instanceId) returns one live instance's definition wiring, its current data and its classified audit, or undefined when no instance with that id is mounted.
    • describeShell(shell, options?) returns the shell as text: a line per canvas with its stack, bottom to top and the last marked *, then a line per instance with its data as JSON. only names the canvases to write (all of them without it). mark(instanceId) returns a note written beside that instance. An array longer than collapseOver (5 by default; 0 collapses nothing) is cut to its first head items (3 by default) and a count of the rest. clean(data) maps each instance's data before it is written.
    • actionGraph(definitions) returns { nodes, dangling }: dangling lists the push / replace / resetTo targets that no definition in the set has; a target written as a {{…}} template is left out.
    • auditCatalog(definitions) audits each definition against a catalog and a channel list built from the whole set, so the wiring between actions is checked. It returns a row { id, address, issues } for each definition that has findings: every issue is classified by classifyAudit(issue) — a push to a {{…}} template target is info, known only when it runs; everything else is address — the address ones first, and the rows are ordered by how many address findings they hold, most first.
    • livenessOf(definition): gestures is true when the action has an event trigger or a component with a model. An HTTP endpoint also carries its url and request as authored (a template stays a template). When the layout is a store id it is not read: opaqueLayout is true, and gestures: false then does not mean nothing can be pressed.
    • chainCycles(definitions) returns the loops in the set's trigger chains, one { steps, path } per group of chains that lead back into each other. A chain starts at a message: trigger or a mount hook and runs on through emit, reload, push, replace and resetTo steps; only literal channels and the ids of actions in the set are followed. A step is { from, to, action, via }: from and to are channel:<name> or mount:<action id>, action is the definition whose trigger or mount hook holds the step, and via is the step's kind. path is the loop on one line: x —emit (loop)→ x.
    • gesturedKeys, declaredKeys, loadedKeys and lifecycleKeys return a definition's top-level data keys by who writes them: the mutation steps of its ui: triggers, the keys its input declares, every endpoint's target and errorTarget, and the mutation steps of its lifecycle hooks. mountInputKeys returns the declared input keys that the request of an endpoint called from mount reads: writing one into a live instance leaves the previous record's data on screen, so a caller that re-aims the instance re-mounts it.
  • @niscorp/nova/devtools — a shell inspector built as pure nova: devtoolsActions (devtools.dock, devtools.inspect — plain ActionDefinitions over generic primitives), DEVTOOLS_CANVAS, and createDevtoolsFunctions answering the devtools.* functions as { id, description, execute } (the endpoint timeline is a ring buffer fed by shell.onEndpoint). An app opts in by granting devtools.* to a dev role, adding the canvas plus a frame slot, and spreading the definitions into the list its functions(session) answers.
    • createDevtoolsFunctions({ shell, definitions }): definitions is the app's catalog (a record or an array) and is what the audit tab checks; without it the audit is empty.
    • The devtools.setEnabled fn mounts and unmounts the dock: it reads the calling action's devtools data key, pushes devtools.dock onto the devtools canvas when that is true, and clears the canvas otherwise. devtools.enabled answers whether the dock is mounted.
    • While the dock is mounted, a ui:click with ref inspect and an instance id as its payload, dispatched on the devtools canvas, pushes devtools.inspect for that instance. An app's own control (a chip in a slotWrapper) opens the inspector that way.
    • A timeline row's action is the calling instance's definition id, read when the call is recorded, so it is there after the instance unmounts.

Agent and i18n

  • @niscorp/nova/agent — the layout-authoring surface: layoutAgent (a @niscorp/cortex agent definition — intent + component palette + data example in, a LayoutNode out, validated against LayoutNodeSchema), paletteFromRegistry (the palette, read off a component registry's meta), and collectInteractive (a layout's refs, models and bound data keys, derived by walking the tree). Needs the optional peer @niscorp/cortex.
    • paletteFromRegistry(registry, { include, omitProps }): include limits the palette to the named components (every registered one without it); omitProps removes the named props from every component's schema, so the agent cannot set them.
    • paletteEntryOf(name, meta, omitProps?), exported from the package root, is the entry paletteFromRegistry builds for one component: { name, description, propsSchema? }, with propsSchema the component's props schema as draft-07 JSON Schema, left out when the component has none.
    • collectInteractive(layout) returns { refs, models, boundKeys, suspectBindings, componentCount }. A ref's via is 'ref' for a node's own ref, or the path of the prop that holds it, at any depth (rowRef, menu.items.0.ref). A model's path is the binding as written ('$.search'). boundKeys are the $.key roots, less what an enclosing for/as binds (its variable, index, items). suspectBindings lists each $name. whose name is not a loop variable of an enclosing for/as; auditAction reports each one as a malformed binding.
  • @niscorp/nova/i18n — nova is language-blind: a host hands the shell a phrasebook (phrases on ShellConfig, shell.setPhrases at runtime) and nova swaps prose at render. The subpath holds the pieces around that: translateRenderTree, fillPhrase, and the harvest (harvestLayout, harvestDefinition, harvestDefinitions, missingFrom) that lists the phrases a dictionary must cover. See I18N_DOCS.md.

The grammar and its versions

nova's documents — actions, fragments, layouts — are a grammar with a version, published at @niscorp/nova/migrations for strata:

  • NOVA_SEQUENCE — the grammar sequence nisc.nova: its kinds (nisc.nova/action, /fragment, /layout), where documents nest inside them (a layout's children, children[], then, else, do; an action's layout and its endpoints' request/response, which are Prism configs), and its migrations. Stored and submitted documents carry the stamp they were written at and are upgraded where they are read (moss does this for integration actions).
  • NOVA_SCHEMAS — the Zod schema behind each kind, snapshotted by the repo's grammar gate (pnpm check:grammars).

Changing a schema here is a migration (AGENTS.md rule 18): an empty marker appended to NOVA_SEQUENCE for an addition, document steps — a Prism config over one node — for anything else. The gate refuses the change until it has one, and checks every captured lab-app document still upgrades and parses.


Examples

@niscorp/nova/examples exports the reference as data. NOVA_EXAMPLES is a list of small apps, each with what is done to it and what it must then say; NOVA_EXAMPLE_GROUPS names the groups they come in (layouts, actions, endpoints, composition, shells, i18n), in reading order.

import { NOVA_EXAMPLES } from '@niscorp/nova/examples';

const counter = NOVA_EXAMPLES.find((example) => example.id === 'counter');
// counter.action is an ActionDefinition: mount it in a shell and draw it with your own kit
// counter.presses: [{ ref: 'more' }, { ref: 'more' }, { ref: 'fewer' }]
// counter.expected: { data: { count: 1 }, says: ['Count: 1', 'One more', 'One fewer'] }

An example is one action alone on a canvas (action) or a small shell (shell: canvases, actions, and how the canvases are arranged). Beside either stand what it needs from its host: fragments, stored layouts, a phrase book (phrases, phraseKeys), and what its endpoints are answered (replies for fn:, fetches for a URL). Every id an example brings begins with the example's own, so a host can hold all of them in one shell. replies is keyed by the function's name and holds what each returns; fetches is keyed by method and address ('GET /examples/<id>/…') and holds the status and body each is answered with.

presses are what is done to it, in order. A press is { ref, type?, payload?, canvas? }: a ui:click on the component with that ref, or with type: 'ui:model' the payload typed into it. In a shell example canvas names the canvas and the press goes to the action on top of it; without canvas it goes to the first canvas. A press of { phrases } is the host changing the language: it hands the shell that book, or with null takes the book away.

expected is what must come out once the presses are done. says is every text the screen holds, in reading order, with what stands in a prop at a prose key (a placeholder, a label). data is what the one action holds (an action example). stacks is the action ids on each canvas, bottom to top (a shell example). asked is what was sent to each URL the example called: method, url, and headers and body where there were any.

The examples name only the plain components every kit has (Stack, Text, Button, Input) and nova's two slots, with no prop about looks. One that says stage: false (the ones on i18n) cannot simply be mounted beside a host's own screens, and is shown as what it comes to. The package's tests run each one, so what they say is what this version does.

Building / dev

pnpm build       # tsup ESM + CJS + DTS
pnpm test        # vitest run
pnpm typecheck   # tsc --noEmit

The repo's root tsconfig.json, which this package's tsconfig.json extends, carries an ignoreDeprecations workaround for tsup's DTS bundler injecting baseUrl. This is intentional and expected.