Nova · guide

Data and a layout

An action is its data and a layout over that data. Four ways a layout reads the data, and one way it writes it back.

Install it
pnpm add @niscorp/nova @niscorp/prism @niscorp/strata zod
Step 1 of 4

The kit

Nova draws nothing itself. It resolves a layout to a tree, and an adapter’s components draw the tree. This file uses the terminal kit, so it runs anywhere Node does; in an app the registry holds your own components.

A canvas has no look of its own here: it is drawn as whatever stands on it.

The kit
import { createShell, shellSettled, shellView } from '@niscorp/nova';
import type { ActionDefinition } from '@niscorp/nova';
import { createTtyView } from '@niscorp/nova/adapters/tty';
import { defaultRegistry } from '@niscorp/nova/adapters/tty/components';

const registry = defaultRegistry();
registry.register('CanvasSlot', ({ children }) => ({ lines: children.flatMap((child) => child.lines) }));
Step 2 of 4

An action, as a document

The action is one object: data, and a layout that reads it. A string that starts with $. is a path into the data. A string with {{…}} in it is a template. A node with if draws one branch or the other. And model binds a field both ways: it shows what the data holds, and what is typed is written back.

Nothing in the layout is code. It is resolved against the data, never run.

An action, as a document
const pass: ActionDefinition = {
  id: 'pass',
  data: { show: 'The Tempest', sold: 87, name: '' },
  layout: {
    component: 'Stack',
    children: [
      { component: 'Text', children: '$.show' },
      { component: 'Text', children: '{{$.sold}} of 120 sold' },
      {
        if: '$.name',
        then: { component: 'Text', children: 'For {{$.name}}' },
        else: { component: 'Text', children: 'For nobody yet' },
      },
      { component: 'Input', ref: 'name', model: '$.name' },
    ],
  },
};
Step 3 of 4

A shell, and the first draw

A shell holds canvases, and an action is pushed onto one. shellSettled waits until the shell has nothing left to do, and draw prints the screen as the terminal kit draws it.

A shell, and the first draw
const shell = createShell({ canvases: [{ id: 'main' }], registry, actions: { pass } });
shell.push('main', 'pass');
await shellSettled(shell);
const draw = (): void => console.log(createTtyView(registry, shellView(shell).api).render().text);
draw();
Step 4 of 4

Type into the field

A component says what happened as an event. Typing is a ui:model carrying the text, and the runtime writes it to the path the field is bound to. The layout is resolved again, and the conditional takes its other branch.

Type into the field
shell.dispatch({ type: 'ui:model', ref: 'name', payload: 'Grace' });
await shellSettled(shell);
draw();
Then

Run it.

The same layout, drawn twice. The second time the data holds a name, so the field shows it and the line above it changed.

Run it
npx tsx layout.mts
It prints
── main ────────────────────────────────────────────────────
The Tempest
87 of 120 sold
For nobody yet
[1] ⟨⟩
── main ────────────────────────────────────────────────────
The Tempest
87 of 120 sold
For Grace
[1] ⟨Grace⟩