# @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

```bash
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](ADAPTER.md) is the whole contract; the terminal adapters' APIs
are in [TERMINAL_DOCS.md](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

```ts
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.

```ts
{ 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 `CanvasConfig`s (`{ 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:

```typescript
{ 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 `RenderErrorNode`s 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](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](../strata/README.md):

- **`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.

```ts
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

```bash
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.

