# Terminal

> Record a terminal session as text, render it as a GIF, an SVG, a still, a slide or a video, embed it in any page — and check in CI that the demo still says what it claims

`@getnarro/terminal` is a terminal session as **text**. Not a video of one, not a screen
recording: a file that names the commands, the pauses and the moments worth naming, plus — once
you have run it — the bytes the commands actually printed.

That one decision is what everything else follows from. Text diffs, so CI can tell you the demo
went stale. Text has no resolution, so the same file renders a GIF, an SVG, a still, a slide and
a shot in a video. Text carries palette *indices* rather than colours, so one recording is
re-themable. And a screen is a pure function of the frame number, which is what lets a video
renderer's parallel workers each draw frame 500 without having drawn the 499 before it.

```bash
npx narro-terminal render demo.term.json          # a GIF
npx narro-terminal render demo.term.json --svg    # vector, no browser needed
npx narro-terminal still  demo.term.json          # one frame, at the poster mark
npx narro-terminal check  demo.term.json          # what will be wrong before you render it
npx narro-terminal record demo.term.json          # run it for real, and write down what happened
npx narro-terminal record demo.term.json --check  # …and fail when the output changed
```

## The one rule

**Rendering never executes anything.** A playbook names commands; drawing one draws them being
typed. Only `narro-terminal record` runs them, it prints them and asks first, and it requires
`--yes` when nothing is attached to the terminal.

This is a security boundary, not an implementation detail. A deck arrives by download, from a
template, from a marketplace, from a model. If `narro build` spawned a shell for a fence, opening
a document would be a way to execute its author's commands, and the blast radius would be every
user of the CLI. So: **in markdown, only authored mode exists.** `narro build`, `narro dev`,
`narro check` and `narro export` draw text and never spawn a process.

## The ladder

Four ways to write the same object. Every one goes through the same validator, and nothing
downstream can tell them apart.

### A transcript you already wrote

The floor. A ` ```bash ` block with prompts in it becomes a terminal by changing one word:

````markdown
```term
$ npm create narro@latest my-deck
✔ Scaffolded my-deck in 8.4s
$ cd my-deck && npm run dev
  ➜  Local:   http://localhost:5173/
```
````

Lines beginning with the prompt were typed; everything else is what came back. ANSI colour
survives, because a pasted transcript has it. It is drawn at **build time** as positioned HTML —
no emulator in the browser, no measurement pass, nothing to load — which is what makes it work in
single-file mode, in a PDF and in a PPTX.

### A tape, when you need marks, waits and keys

The rung above, and it reads VHS `.tape` syntax, because thousands of demos are already written
in it:

````markdown
```tape {window=mac poster=ready}
Set TypingSpeed 50ms
Type "npm create narro@latest my-deck"
Enter
Sleep 2s
Wait /Scaffolded/
Mark ready
```
````

A tape says what *happens*; a transcript says what was *printed*. Narro adds four directives VHS
has no equivalent for — `Mark` (a named moment), `Mask` (what varies between runs), `Cue`
(narration alignment) and `Expect` — and reports every directive that did not map instead of
dropping it. `Source` is refused inside a fence: a fence is a document, and a document does not
get to read `~/.ssh`.

<!-- generated:terminal-fences -->
| Fence | Attributes |
| --- | --- |
| `term` | `theme`, `prompt`, `cols`, `rows`, `window`, `title`, `width` |
| `tape` | `theme`, `cols`, `rows`, `window`, `title`, `width`, `poster` |
<!-- /generated:terminal-fences -->

### A playbook file

`demo.term.json` beside the deck, shared by a slide, a README, a docs page and a video:

```json
{
  "cols": 92,
  "rows": 24,
  "steps": [
    { "id": "install", "type": "npm create narro@latest my-deck", "enter": true },
    { "id": "done", "output": "✔ Scaffolded my-deck in 8.4s\r\n$ ", "mask": ["\\d+\\.\\d+s"] },
    { "id": "ready", "expect": "Scaffolded" }
  ]
}
```

One action per step, from this set: <!-- generated:terminal-step-actions-inline -->`type`, `key`, `paste`, `output`, `wait`, `expect`, `clear`, `resize`, `mark`, `hide`, `show`<!-- /generated:terminal-step-actions-inline -->.
A `key` step takes <!-- generated:terminal-keys-inline -->`Enter`, `Tab`, `Backspace`, `Delete`, `Space`, `Escape`, `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `Insert`<!-- /generated:terminal-keys-inline -->,
with `Ctrl+`, `Alt+` and `Shift+` in front of them.

### TypeScript, for the programmatic case

```ts
import { defineTerminal } from "@getnarro/terminal";

export default defineTerminal({
  steps: [{ id: "run", type: "npm run build", enter: true }],
});
```

`expect` may be written as a regular expression here and is stored as a string, because a
playbook has to survive a trip through JSON.

## Recording, and the check that keeps a demo honest

`record` runs the playbook in a real terminal — a `script(1)` pty, so nothing native to install —
and writes `demo.capture.json` beside it: the bytes, with the second each arrived.

Then the point of the exercise:

```bash
npx narro-terminal record demo.term.json --check
```

It re-records, compares against the stored capture without writing, and exits 1 when a step's
output changed:

```
✔ «before»   matches
✘ install    output changed
    stored  ✔ Scaffolded my-deck in ‹masked›
    now     ✔ Created my-deck in ‹masked›
```

**This is what nothing else in this field can do.** A `.cast` carries what a terminal emitted and
not what the author meant; a VHS `.tape` carries the intent and not the reality. Neither can tell
you that the tool being demonstrated stopped saying what the demo claims it says. A playbook plus
its capture carries both — and because the capture is text, the answer is a diff a person reads.

Two things make it survivable in CI:

- **Output is attributed to a command by the shell's own echo of it**, not by timestamp. Two
  recordings of one session are never timed alike, and time-based attribution reports jitter as a
  change on every run.
- **Masks are session-wide.** A duration, a version, a temp path and a port change every run and
  mean nothing. A mask is written on the step whose `expect` it relates to and applies to every
  step, because output arrives while the *previous* step is still running.

`check` reports a mask that matches nothing, because a mask that hides nothing means `--check`
will report the very difference it was written to absorb.

## Output

| Command | What you get |
| --- | --- |
| `render` | An animated GIF. Differenced frames, a counted palette, per-frame delays, and a `--max-mb` ladder that retries down to a size you asked for |
| `render --svg` | An animated SVG: vector, usually smaller, and it needs **no browser at all** |
| `still` | One PNG, at a mark, a second or a frame |
| `<narro-terminal>` | The embed — 14.7 KB gzipped, no framework, playable and seekable |
| `<TerminalScene>` | A terminal in a `@getnarro/video` composition, aligned to a narration cue |
| ` ```term ` / ` ```tape ` | A terminal on a slide, drawn at build time |

> **The SVG does not animate in a GitHub README.** GitHub serves README images through its camo
> proxy as `<img>`, and animation inside an `<img>`-loaded SVG is unreliable across browsers —
> reported broken in Firefox while working in Chrome. Use the GIF there, and the SVG on a docs
> site, a blog post or a slide. The CLI says so every time it writes one.

Themes: <!-- generated:terminal-themes-inline -->`narro-dark`, `narro-light`, `asciinema`, `dracula`, `monokai`, `nord`, `solarized-dark`, `solarized-light`, `github-dark`, `github-light`<!-- /generated:terminal-themes-inline -->. A
cell stores a palette *index*, not a colour, which is what makes one recording re-themable — the
same session in a docs site's light theme and a launch video's dark one, from one file.

## Coming from somewhere else

```bash
npx narro-terminal import session.cast --explain   # asciicast v2 or v3
npx narro-terminal import demo.tape                # a VHS tape
npx narro-terminal import old.ttyrec               # a ttyrec
npx narro-terminal export demo.term.json --cast-version 3
```

The format is decided by what is *in* the file, not by its name. What an import cannot recover is
intent: a cast records what a terminal emitted, so it arrives as `output` and `wait` steps —
honestly a transcript — and everything downstream works on it unchanged. Export goes the other
way, including from an authored playbook, which produces a recording of a session that never
happened, typing and all, that `asciinema play` plays.

## What `check` reports

<!-- generated:terminal-findings-inline -->`unsupported-sequence`, `unsupported-sgr`, `expect-never-matched`, `output-truncated`, `line-too-wide`, `mask-never-matches`, `long-idle-gap`, `session-very-long`, `typing-faster-than-frames`, `glyph-not-in-face`, `font-not-embedded`<!-- /generated:terminal-findings-inline -->

`--glyphs` adds the two that need a browser. Every glyph in a monospace face advances by exactly
one cell, so a glyph whose advance is anything else came from a **fallback** face — and a fallback
glyph is what drifts a row off the grid. It measures in the same engine that will do the render,
with the same face embedded.

```bash
npx narro-terminal render demo.term.json --font ./JetBrainsMono.woff2
```

Without `--font` the render uses whatever monospace face the machine has, and two machines produce
different pixels. `check` says so rather than leaving it to be found by a frame diff.

## Closed sets

Everything above is published as data in [catalog.json](https://getnarro.com/catalog.json), under
its `terminal` key: the commands, the step actions, the key names, the themes, the findings, the
tape directives and settings, the fences and their attributes, and every entry point. A name that
is not in that file does not exist.

Directives: <!-- generated:terminal-tape-directives-inline -->`Output`, `Require`, `Env`, `Set`, `Source`, `Type`, `Sleep`, `Hide`, `Show`, `Screenshot`, `Copy`, `Paste`, `Wait`, `Mark`, `Mask`, `Cue`, `Expect`<!-- /generated:terminal-tape-directives-inline -->.

Settings: <!-- generated:terminal-tape-settings-inline -->`Set Shell`, `Set Theme`, `Set TypingSpeed`, `Set Width`, `Set Height`, `Set Cols`, `Set Rows`, `Set FontSize`, `Set FontFamily`, `Set LineHeight`, `Set LetterSpacing`, `Set Padding`, `Set Framerate`, `Set PlaybackSpeed`, `Set LoopOffset`, `Set WindowBar`, `Set WindowBarSize`, `Set BorderRadius`, `Set Margin`, `Set MarginFill`, `Set CursorBlink`, `Set WaitTimeout`, `Set WaitPattern`<!-- /generated:terminal-tape-settings-inline -->.

Entry points: <!-- generated:terminal-entry-points-inline -->`@getnarro/terminal`, `@getnarro/terminal/react`, `@getnarro/terminal/player`, `@getnarro/terminal/hast`, `@getnarro/terminal/renderer`, `@getnarro/terminal/record`, `@getnarro/terminal/convert`, `@getnarro/terminal/video`, `@getnarro/terminal/embed`<!-- /generated:terminal-entry-points-inline -->.