@niscorp/nova — Design
Declarative, framework-agnostic UI runtime. JSON layouts, action lifecycles,
shell orchestration. The core is pure TypeScript with zero framework
dependencies. Adapters (React, Vue, plain DOM, TTY, Ink) consume the
RenderNode[] output.
This document describes the package as it actually exists today. Anything not implemented is called out as Future work.
Architecture
Four core areas, one package. Each area imports across boundaries via the
path aliases @shared, @layout, @action, @shell. Local imports stay
relative.
┌─────────────────────────────────────────────────────────────┐
│ shell/ Public factory, canvas stacks, navigation, │
│ lifecycle bookkeeping, telemetry. │
├─────────────────────────────────────────────────────────────┤
│ action/ Definitions (Zod schemas), runtime, mutation │
│ ops, step executor, triggers, endpoints, │
│ lifecycle hooks, model bindings. │
├─────────────────────────────────────────────────────────────┤
│ layout/ Layout schemas, renderer, layout store, │
│ component registry, RenderNode output. │
├─────────────────────────────────────────────────────────────┤
│ shared/ Errors, ids, common utilities, bindings │
│ (paths, scope chain, unified resolver), │
│ data store, event bus, message bus. │
└─────────────────────────────────────────────────────────────┘
The shared/ module is the only one any other area imports from. Layout,
action, and shell never import from each other transitively except via
@layout / @action / @shell aliases at well-defined seams (the shell
wires everything together at construction time).
Around the core sit six subpath areas:
adapters/— framework bindings (react/,vue/,dom/,tty/,ink/), each with its own export subpath. Adapters import only the core public surface; core never imports an adapter. See ADAPTER.md.reflect/— read-only introspection (@niscorp/nova/reflect): layout walks, shell snapshots, the action graph, audit classification, and what an action can still do once it has been drawn (livenessOf). Imports@action/@shell/@layout; pure and framework-free.devtools/— the shell inspector (@niscorp/nova/devtools), built as plain ActionDefinitions plus fns overreflect/.agent/— cortex agents owned by nova (@niscorp/nova/agent); importing the subpath is what pulls in the optional cortex peer.i18n/— the language surface (@niscorp/nova/i18n): the phrase-key vocabulary, the harvest,translateRenderTree,fillPhrase. The swap itself runs in the renderer. See I18N_DOCS.md.migrations/— nova's grammar as a strata sequence (@niscorp/nova/migrations:NOVA_SEQUENCE,NOVA_SCHEMAS).
shared/
The substrate. Everything else builds on this.
shared/errors.ts
Domain error hierarchy. Classes are permitted only here because
identity (instanceof) is required for typed catch sites.
NovaError (base; carries code + context + cause)
├── RenderError (renderer subtree failure)
├── ComponentNotFoundError (registry miss)
├── LayoutRefNotFoundError (layout store miss)
├── DefinitionValidationError (Zod failure at boundary)
├── UnknownActionError (shell.push of unregistered id)
├── UnknownFragmentError (push `with` names an unregistered fragment)
├── ShellDisposedError (post-dispose call)
├── UnknownFunctionError (`fn` endpoint names an unregistered function)
├── LifecycleError (hook failure; carries hook + cause)
└── MutationError (a mutation op that cannot apply)
Subclasses are added only when there is a real throw site. No dead exports.
shared/bindings/
Unified resolver. The core function is
resolve(value: unknown, chain: ScopeChain, extras?: ExtraScopes): unknown
It is the only binding-resolution entry point. Every binding site —
component props, conditional if, loop for, endpoint url/headers, trigger
emit channel, mutation operands — calls it. (An endpoint's request/response
are the exception: they run through the injected transform evaluator, not the
binding resolver — see endpoints.ts.)
Resolution rules:
- String, sole
{{ expr }}→ returns the raw resolved value (preserves type — number stays number, object stays object). - String,
"text {{expr}} text"→ string interpolation. - String, bare
"$.path"or"$item.field"→ raw resolved value. - String, anything else → returned literally.
- Object
{ $if, $then, $else }→ conditional. The only directive form.$elseis optional. - Array → walked recursively.
- Plain object → walked recursively.
The previous BindingSchema value-form objects ({ template: ... },
{ if, then, else }) no longer exist. The unified resolver replaced
them.
Scope chains are arrays of records, innermost first. createScopeChain(data)
returns [data]. Loops push a new scope on the front ({ [as]: item }).
extras is a separate flat record for non-scoped names like @error.
shared/data-store/
A small reactive Record<string, unknown> store with get, update, and
subscribe. Used as the single source of truth per action runtime: the
runtime writes to it, the layout renderer reads from it via a narrow
DataStoreView ({ get }-only) interface.
shared/event-bus/
Topic-based pub/sub. Validates events against NovaEventSchema before
dispatch. Used for ambient UI events such as ui:model. Handlers receive
typed NovaEvent payloads. Subscriptions return cleanup functions.
shared/message-bus/
Channel-based pub/sub, distinct from the event bus. Carries
MessageEnvelope structures with a channel and arbitrary payload.
Trigger configs with a message field bind to this bus.
The two buses are independent. The previous msg:<channel> prefix
bridge through the event bus is gone.
shared/ids.ts, shared/common.ts
createIdFactory(prefix) for instance/shell ids; small type guards
(isObject, isString, etc.); shared Unsubscribe type.
layout/
Schemas (layout/schemas.ts)
Zod schemas for the layout DSL:
LayoutPrimitiveSchema— string, number, boolean, null.ComponentNodeSchema—{ component, props?, children?, ref?, model?, events? }.ConditionalNodeSchema—{ if, then, else? }.LoopNodeSchema—{ for, as, key?, do }.LayoutRefNodeSchema—{ ref }. Resolves against the layout store.SlotNodeSchema—{ slot }. Filled at compose time (see "ActionFragment composition").LayoutNodeSchema— discriminated union of all of the above plus arrays.
Renderer (layout/renderer.ts)
Walks a LayoutNode and emits a RenderNode[] tree:
RenderComponentNode { type: 'component'; name; props; children;
ref?; key?; model?: { ref, path } }
RenderTextNode { type: 'text'; value }
RenderFragmentNode { type: 'fragment'; children }
RenderErrorNode { type: 'error'; code; message; nodeRef?; key? }
The renderer:
- Resolves every binding via
@shared/bindings.resolve. - Wraps every child render in a try/catch boundary. In lax mode,
subtree failures become
RenderErrorNodes; siblings continue. In strict mode, the throw escapes. - Looks up component metadata in the
ComponentRegistry. Missing components throwComponentNotFoundError. - Resolves
LayoutRefNodeagainst theLayoutStore. Missing refs throwLayoutRefNotFoundError. - Tracks absolute scope paths. When a
model: "$item.field"appears inside a loop, the renderer materializes the absolute path (items.0.field) on the emittedRenderComponentNode.model.path. This is what makes two-way binding work for loop items.
Layout store and registry
LayoutStore.set validates every layout against LayoutNodeSchema and
throws DefinitionValidationError on failure — boundary validation.
ComponentRegistry is a name → metadata map. Adapters fill it with their
own component implementations later.
action/
Schemas (action/schemas/)
Split into focused files:
effects.ts—CallEffect,EmitEffect, navigation effects (Push,Pop,Replace,PopTo,ResetTo,RemoveInstance,RemoveSelf,Reconcile,Reload), andStepSchema = Mutation | Effect.triggers.ts—TriggerConfigSchema { event?, message?, ref?, do }.endpoints.ts—EndpointConfigSchema: a function call{ fn, target?, errorTarget? }or an HTTP call{ method, url, headers?, request?, response?, target?, errorTarget? }.requestis run by the injectedtransformevaluator over the action data to build the body;responseis run over the reply exactly as received ($is the reply — object, array, or scalar; no wrapping) to produce the value stored attarget. Both are opaque to Nova (the host injects the interpreter, e.g. Prism), so Prism stays an optional dependency; declaring either without an injected transform errors. The injected transform is endpoint-only — initial data is never transformed.lifecycle.ts—LifecycleConfigSchema { mount?, unmount?, suspend?, resume? }. Each isStep[].index.ts—ActionDefinitionSchema,ActionFragmentSchema, exports.
Mutations (action/mutations/)
Op-per-file. Each operator lives in action/mutations/ops/{name}.ts
and exports a match predicate plus an apply function. A registry
(action/mutations/registry.ts) dispatches by walking the ops and using
the first match. Adding a mutation is a new file, not an edit to a
giant switch.
Current ops: set, toggle, increment, decrement, push, pop,
removeAt, move, clear, reset.
Each op has its own Zod schema. The combined MutationSchema is the
union.
Runtime (action/runtime/)
createActionRuntime(config) produces an ActionRuntime:
runtime.ts— assembles the data store, abort controller, trigger handle, model listeners, status subscribers. Owns the lifecycle methods (mount,unmount,suspend,resume).lifecycle.ts—buildInitialDatamergesdefinition.datawith input (clone only — no transform);runLifecycleHookinvokes the hook's steps withlifecycleHookset on the step context. In strict mode it rethrows hook failures asLifecycleError; in lax mode it routes them throughctx.onError.steps.ts—executeStepswalks aStep[]. Mutations are buffered and flushed in batches; effects (call,emit, navigation) flush first, then run.runCallinvokes endpoints viacallEndpointand routes success/failure throughonSuccess/onErrorstep branches. When a step is running inside a lifecycle hook (ctx.lifecycleHookset) and an unknown endpoint or unhandled endpoint failure occurs,runCallthrowsLifecycleErrorso the lifecycle wrapper can route it.endpoints.ts— purecallEndpoint(...)returning aResult. HonorsAbortSignalfromctx.signal.triggers.ts— wires trigger configs to the event bus or message bus and runs theirdosteps when fired.render.ts— callsrenderLayoutFromStore(or inline layout) with the action's data, returningRenderNode[].model-bindings.ts— walks aRenderNode[]collecting(ref, path)pairs frommodelfields.
Later bodies, and the calls an instance has made
A response may change after it resolved. FetchResponse.onChange is a
transport saying so — moss offers it for a reactive vex read. runCall
applies every later body exactly as it applied the first: the endpoint's
response transform, then target. Nothing else runs — no onSuccess, no
telemetry, no loading flag — because a later body is data, not a call. A body
the transform rejects is dropped; the screen keeps its last good value. Nova
does not know why a body changed, and there is nothing in a definition to
declare: an endpoint whose transport never offers onChange behaves exactly
as before.
The runtime keeps a small ledger per endpoint name (EndpointCalls), and it
does two jobs:
- An older response never writes over a newer one. Each call takes a
ticket; a response that lands after a newer call to the same endpoint
already landed leaves
targetalone. (Before, the slower of two searches won — whichever answered last.) ItsonSuccessstill runs; the call happened. - One followed answer per endpoint. A newer call that lands stops following the older one — an old search's updates would otherwise keep writing over the new one's rows — and unmount (or dispose) stops them all.
A bare step context (a test driving executeSteps directly) has no ledger:
every response lands as before and none is followed.
Two-way binding
When the layout author writes model: "$.user.name" on a component, the
renderer emits model: { ref, path: 'user.name' } on the
RenderComponentNode. After every render, the runtime reconciles its
ui:model event-bus listeners against the live set of model refs:
- New ref → install a listener that, on
ui:modelevents for that ref, applies asetmutation topath. - Removed ref or changed path → tear down the old listener.
The framework adapter is responsible for emitting ui:model events on
the event bus when its inputs change.
AbortSignal threading
Each runtime owns an AbortController. Its signal is threaded into every
StepContext and forwarded to fetch calls inside callEndpoint. On
unmount, the controller is aborted before any unmount hooks run, so
in-flight endpoint calls bail out. Unmount hooks themselves run on a
fresh (non-aborted) controller so they can perform their own final
async work.
Public vs internal runtime types
The shell exposes runtimes via shell.getRuntime(id) typed as
PublicActionRuntime:
PublicActionRuntime = {
readonly instance, definition;
getData(); setData(next); render();
onDataChange(handler); onStatusChange(handler);
}
The wider internal ActionRuntime adds mount, unmount, suspend,
resume, applyMutations, executeSteps, updateData, dispose, and
the underlying dataStore. It is not re-exported from the package
root. Tests that exercise internals reach it via
@shell/shell-internals.getInternalRuntime.
shell/
The user's entry point. Splits responsibilities across small files:
shell.ts— publiccreateShellfactory. Composes everything, defines the public methods, holds thependingStrictErrorslot, and owns the canvas map.runtime-registry.ts— instance-id → runtime map with subscribe hooks for telemetry.telemetry.ts— state-change, data-change, and endpoint subscriber lists. Endpoint events (EndpointEvent: name,fn/http, ok, status, ms, stamped with instance and canvas) are reported byrunCallat thecallEndpointseam — the one point every call passes through, so a server shell whose actions are allfn:is observed exactly like an HTTP one. Aborted calls are not reported.navigation.ts— convertsNavigationEffect(from inside an action's steps) intopush/pop/replacecalls on the shell.lifecycle-ops.ts—unmountInstance,suspendTop,resumeTopbookkeeping. Idempotent unmount via an internalSet.canvas.ts— pure stack:pushInstance,popInstance,replaceTop,clearStack,peek. No I/O.shell-internals.ts—validateActions(boundary Zod validation on the action map at construction),snapshotCanvas,createRuntimeFactory, and the test escape hatchgetInternalRuntime.
Public API
createShell(config: ShellConfig): Shell
Shell exposes:
push(canvasId, actionId, input?, fragments?, options?): string
originOf(instanceId): string | undefined
pop(canvasId): void
popTo(canvasId, instanceId): void
removeInstance(canvasId, instanceId): void
replace(canvasId, actionId, input?, fragments?): string
clear(canvasId): void
back(): boolean
registerAction(definition): void // an action or a fragment
removeAction(id): void // an action or a fragment
addCanvas(config): void
removeCanvas(canvasId): void
setCanvasLayout(layout): void
setPhrases(phrases): void
getPhrases(): Phrasebook | undefined
setLayout(refId, layout): void
getCanvasState(canvasId): CanvasState
getRuntime(instanceId): PublicActionRuntime | undefined
getState(): StateSnapshot
getShellRenderTree(): RenderNode[]
getCanvasRenderTree(canvasId): RenderNode[]
flattenRenderTree(tree): RenderNode[]
dispatch(event): void
publish(channel, payload?): void
onStateChange(handler): Unsubscribe
onDataChange(handler): Unsubscribe
onEndpoint(handler): Unsubscribe
onCanvasChange(canvasId, handler): Unsubscribe
dispose(): void
fragments is a list of ActionFragment ids to compose the action with
before it is instantiated (same as a push/replace effect's with: [...]).
See "ActionFragment composition" below.
push, pop, replace, clear, getCanvasState are synchronous
in both lax and strict mode. The runtime's lifecycle methods are async
underneath, fired with .catch(handleLifecycleRejection).
Strict mode and error surfacing
ShellConfig.strict: boolean and ShellConfig.onError?: (err) => void
are plumbed through ActionRuntimeConfig into every StepContext.
Lax mode (default). Lifecycle hook failures are caught inside
runLifecycleHookand routed throughonError. Renderer subtree failures becomeRenderErrorNodes. The shell never throws from a lifecycle hook.Strict mode.
runLifecycleHookrethrows failures asLifecycleError. The shell catches the rejection inhandleLifecycleRejectionand stores it in a singlependingStrictErrorslot. The next public shell call (push/pop/replace/clear/getCanvasState) consumes the slot and throws the stored error before doing any work. Renderer failures in strict mode throw out ofrender().
This is the documented contract: shell method signatures stay
synchronous; strict-mode lifecycle errors surface deterministically at
the next operation boundary. It is the only honest way to expose async
fire-and-forget failures via a synchronous API without making
push/pop return promises.
Definition validation at boundaries
createShellvalidates every entry inconfig.actionsviaActionDefinitionSchema.safeParse, aggregating failures into a singleDefinitionValidationError.LayoutStore.setvalidates every layout the same way.runtime.executeStepsvalidatesStep[]input viaStepSchema.safeParsebefore running.
ActionFragment composition
An ActionFragment is a reusable partial action — every
ActionDefinition field optional, plus a kind: 'fragment' discriminator.
It is abstract: it can never be pushed as an action (that throws
UnknownActionError), and only exists once merged. A shell keeps actions and
fragments in one registry, by id, and tells them apart by kind: both are
added with registerAction and taken out with removeAction, and an id names
one or the other, never both (DefinitionValidationError). An app writes its
fragments down in ShellConfig.fragments, beside actions; they are
validated at construction as actions are.
Because it is pure data it ships in a DB row and an agent can author it; a
reference (with: ['modal']) collapses an arbitrary amount of chrome to a
single token in the model's context.
Composition is call-site, not on the action — an action never knows where
it renders, so the modal-ness lives on the push: { push: { action: 'new-contact', with: ['modal'], canvas: 'modal' } }. The same with: [...] is
accepted on a replace effect and on a canvas initial seed ({ action, with }), and as the fragments? arg of shell.push/replace.
composeAction(action, fragments[]) produces the effective ActionDefinition
that spawns:
- layout —
fillSlots(fragment.layout, { body: action.layout }): the fragment is the outer chrome, the action's own layout is dropped into the fragment's{ slot: 'body' }node. The fragment wraps; the action is the innermost content. Multiple fragments fold in array order (last listed is outermost). An unfilled slot renders nothing. - data / endpoints — shallow-merge, action wins on conflict.
- triggers — concat, fragment's first (so a fragment's
close/canceland the action'sconfirmcoexist; authors keep refs distinct). - lifecycle — per-hook concat, fragment steps run before the action's.
- id / name / description — the action's. The result is a full
ActionDefinitionwith nokind— composition, not inheritance.
slot is a fourth layout placeholder (beside LayoutRef): { slot: name },
filled only at compose time by fillSlots. A slot that survives to the
renderer was never filled, so the renderer emits nothing for it. This is the
single seam a fragment uses to wrap an action; named/multiple slots are future
work (v1 fills one body slot with the action's layout).
Data flow (push to render)
- User calls
shell.push('main', 'A', input). validateActionsalready passed at construction.getDefinition('A')returns the validatedActionDefinition. When the call carriesfragments(or the effect awith: [...]),composeActionfolds the named fragments into the definition first (see "ActionFragment composition"); the merged result is what spawns.ops.suspendTop(canvas)runs the previous top'ssuspendhook (fire-and-forget; strict mode wires errors back).spawncallsbuildRuntime→createActionRuntime. The runtime builds its data store fromdefinition.datamerged withinput, creates anAbortController, registers triggers.runtime.mount(input)is fired and not awaited. It runs themountlifecycle hook viarunLifecycleHook, then transitions toactive. Failures route through the strict/lax pipeline.- The new instance is pushed onto the canvas;
fireState()notifies state subscribers. - The adapter (when one exists) calls
runtime.render()which returnsRenderNode[]. The runtime reconciles model bindings against the produced tree.
Future work
The following are explicitly not implemented and have no design yet beyond a placeholder:
React adapter.Implemented. See "React adapter" below.Headless component primitives.Implemented. Reference kits ship at/adapters/react/components,/adapters/vue/components,/adapters/dom/components,/adapters/tty/components, and/adapters/ink.- Wire protocol. No serialization format for shipping definitions
across processes. Serving rendered trees over a socket is moss's job;
nova ships the surface it targets (
RenderApi, ActionSlot-preservingflattenRenderTree). Schema versioning.Implemented. The action, fragment and layout grammars are a strata sequence at@niscorp/nova/migrations(NOVA_SEQUENCE).- LLM tooling / JSON Schema generation / catalog. Kept out of the core
— nova is a runtime, not a generator. What ships is the optional
@niscorp/nova/agentsubpath (layoutAgent,paletteFromRegistry,collectInteractive); catalogs are the host's.
React adapter
The React adapter lives under src/adapters/react/ and ships at the
@niscorp/nova/adapters/react subpath. It is a thin layer over the
framework-agnostic core — no shell logic lives in React, and React is a
peer (optional) dependency of the core package.
Two-provider pattern
There are deliberately two providers:
<NovaRenderProvider>— pairs a component registry with adispatchandpublishfunction. It does NOT require a shell. This is the minimum needed to render aRenderNode[]into React. Use it for static layouts, snapshot tests, or any scenario where you have data but no orchestrator.<NovaShellProvider>— a thin wrapper around<NovaRenderProvider>that ALSO exposes aShellvia context. Internally it derivesdispatch/publishfromshell.dispatch/shell.publishand forwards them to the inner render provider. Hooks likeuseShellState,useCanvas,useRenderTree,useActionData,useActionStatusrequire a<NovaShellProvider>ancestor; the bare hooks (useNovaDispatch,useNovaPublish,useNovaRegistry) work under either.
The provider does NOT own the shell
This is a deliberate contract. NovaShellProvider never calls
shell.dispose() on unmount or on shell-prop change — it merely
re-points its context. Lifecycle ownership stays with the consumer that
created the shell. This is verified by test/react/shell-swap.test.tsx,
which spies on shell.dispose and asserts it is never invoked. Swapping
the shell prop causes hooks to resubscribe to the new shell on the
next render via the standard useSyncExternalStore plumbing.
Component prop injection is minimal
A nova component registered with the React adapter receives:
children?: ReactNode— populated when the layout node has childrennovaModel?: { ref, path }— populated when the layout node has amodel:binding (so the component can dispatchui:modelevents)novaRef?: string— the layout node'sref, when set- ...the layout node's
propsspread on top
That's it. Components do not receive a shell, an instanceId, an
onChange, or any other framework-supplied callback. If a component
needs to emit, it calls useNovaDispatch() or useNovaPublish(). If a
component needs to introspect the registry, it calls
useNovaRegistry(). New props on the NovaComponentProps bag require
explicit justification.
The slotWrapper seam
Cross-cutting rendering concerns — enter/leave animation, auth / feature
gates, logging, error boundaries — do not belong in the core. Animation in
particular is fickle and opinionated (framer-motion vs react-transition-group
vs plain CSS), so the core owns none of it. Instead the React adapter
exposes a single seam: an optional slotWrapper component, threaded through
NovaRenderContext (so it reaches deeply-nested slots) and accepted on
<Nova.Shell> and both providers.
Deliberate boundaries:
- Adapter-only, never core.
slotWrapperis a React component — it lives insrc/adapters/react, not inShellConfigor any schema. The core stays serializable data + a state machine; the Vue adapter exposes its own equivalent (a Vue component handed the same identity props). Nothing here touchesLayoutNode, so a model-authored (or DB-stored) layout never carries animation/gate concerns. - One seam:
ActionSlot. That is the single place an instance's content mounts and unmounts.CanvasSlotis a router (it expands toActionSlots), so it is not wrapped — there is exactly one place a wrapper exists, which is why the wrapper takes nokinddiscriminator. - Identity, not state. The wrapper receives
{ canvasId, instanceId, action }(resolved fromshell.getRuntime(instanceId)), never the live data.canvasId/actionare the policy axes (route by region or byaction.id);instanceIdis the keying axis. Passing live data would turn a presence/gate seam into a reactive render path — the wrong tool. - Persistent render, no timing. When a
slotWrapperis present,ActionSlotrenders it even with no active instance (childrenbecomesnull, identity becomesundefined), so a presence-managing wrapper can hold and animate the leaving content. The core disposes the instance immediately on pop; nova never waits onanimationendor any completion callback — the wrapper (and whatever library it plugs in) owns the exit lifecycle entirely. - Passthrough default. With no
slotWrapper,ActionSlotrenders exactly as before. Generated layouts that introduce new canvases flow through the same single wrapper and hit its default branch — nothing is registered per slot.
Verified by test/react/slot-wrapper.test.tsx (correct identity, passthrough
default, and that the wrapper still mounts for an empty slot so exits can run).
Snapshot caching strategy
Both useRenderTree and useCanvas cache their snapshot via useRef
so useSyncExternalStore's getSnapshot returns a stable reference
when nothing has changed. This is mandatory for tearing-safety in
React's concurrent rendering.
useRenderTreecaches by data identity. The runtime exposes the current data object; if the cached data reference is===to the current one, the cachedRenderNode[]is returned unchanged. When the runtime mutates data, the new data object replaces the old, the cache misses, the layout is re-rendered, and the newRenderNode[]becomes the cached snapshot. A missing instance returns a singletonEMPTYconstant so re-renders are also stable in that case.useCanvascaches via a structural comparison helper (sameActive) over the canvas's stack length, active id, and per- frame(id, status)tuples. If two consecutivegetCanvasStateresults compare equal under that helper, the cached value is reused; otherwise the new state is captured and the React store is notified.
Both contracts are verified by test/react/snapshot-stability.test.tsx,
which asserts referential equality across re-renders without state
changes and reference inequality after a real data or stack change.
React compatibility
- Requires React 19 (the
reactpeer is^19.2.4; usesuseSyncExternalStore). - Concurrent rendering: tearing-safe by construction.
- StrictMode: works correctly; snapshot caches survive effect double- invocation.
- Suspense: not used as a loading model. Loading state is explicit data on the action. Nova hooks never throw promises.
- SSR: the shell-backed hooks draw where there is no browser. Each passes its
own
getSnapshotasuseSyncExternalStore's server snapshot — a shell is the same object on a server, at a build, and in the page, so the value it holds is the value to draw. That is what lets an app with its OWN shell (no server holding one) be drawn ahead of time: its boot runs at build,shellSettledwaits for the first screen to be whole,renderToStringdraws it through these hooks, and the page's boot adopts the markup withhydrateRoot. A SERVED tree needs none of this — it renders throughRenderTreeover aRenderApi, which touches no store (moss'sterminal/react/server).shellView(shell)is the bridge between the two: a local shell, read as aRenderApi. - React Server Components: not tested. Hooks are client-only.
Serving trees: RenderApi and flattening
A remote renderer (a moss terminal) never holds a shell; it is handed a
RenderApi — frame(), canvasTree(canvasId), dispatch(canvasId, event),
publish(channel, payload?). One shape shared by the DOM adapter, the React
terminal target, and moss's conductor, aliased rather than redeclared, so a
renderer written against one host drops onto another. Origin stamping is the
host's job, not the renderer's.
flattenRenderTree resolves CanvasSlot markers away but keeps the
ActionSlot marker: a component node with
props: { instanceId, canvasId, definitionId }, key: instanceId, children
= the instance's rendered tree. Deliberate: a served tree must still carry
instance identity, so a remote renderer can key by instance (a swap remounts
— no stale DOM state crossing instances, enter animations fire) and a
terminal-side slot wrapper has a seam. Identity only, never the definition —
wire weight.
DOM adapter
src/adapters/dom/ (@niscorp/nova/adapters/dom) renders a served tree to
vanilla DOM against a RenderApi: events wired by convention (ref →
ui:click, model → ui:model/ui:key), debounce honoured. It keeps the
tree it drew beside the DOM that tree produced and patches the page: an
element whose part of the tree did not change is the same node, never taken
off the page, so focus, scroll and a running animation are kept, and a field
being typed in is not replaced (ADAPTER.md, "The DOM adapter keeps what did
not change"). Its components
kit (/adapters/dom/components: defaultRegistry, fallback,
DEFAULT_CSS, ROOT_CLASS) is the batteries-included reference set. It
exists as the proof that a terminal needs no framework — and as the adapter
sibling that keeps the adapter contract honest.
Terminal adapters
src/adapters/tty/ renders a served tree to plain text: createTtyView is
a pure RenderApi → { text, interactives } — no framework, no I/O, no
node builtins. The walker applies the event conventions itself (ref →
a numbered [n] marker registered as a click, model → model/toggle by
value type), so the interactives table is a complete, ordered action space
a host maps commands (or an agent's tool calls) onto. src/adapters/ink/
is the full-screen sibling: an Ink component kit riding the REACT walker
(the walker is renderer-agnostic — zero react-dom), with the same [n]
markers resolved through CanvasMarkersContext — the host computes the
numbering by running the TTY walker over the same trees, so both terminals
agree on what [7] is. Marker identity is the click payload for
click-kinds (a list's rows share one ref) and sibling order for
model-kinds. See TERMINAL_DOCS.md.
Reflect and devtools
reflect/ formalizes the walks moss and app devtools kept re-deriving.
Everything in nova is closed, validated data — definitions, layouts, the
live shell — so introspection can be complete, a thing no code-based UI
framework offers. Structural walkers (walkNodes, componentsOf, refsOf,
loopVarsOf), shell state (snapshotShell, describeInstance), the action
adjacency (actionGraph), and audit triage (classifyAudit,
auditCatalog). Pure, framework-free, read-only.
devtools/ is the consumer proof: the dock and inspector are plain
ActionDefinitions over generic primitives (Panel, JsonTree), so they render
in any terminal. createDevtoolsFunctions builds the devtools.* fns per
session — a closure over that session's shell, no module globals, so
introspection is per-session-correct under moss. The endpoint timeline is a
capped ring buffer fed by shell.onEndpoint (notify-then-pull: the fn is
the pull), and the devtools canvas excludes itself so the inspector never
observes its own traffic.