# The illustrated product manual (shared across all Norsk ctl product repos)

How a Norsk ctl product builds, writes, and publishes its **illustrated manual** —
the self-contained HTML page a prospect or operator reads to understand what the
product does. This is the shared half; the per-product half is the manual's
actual page content, which lives in the product repo.

Like [`personas.md`](./personas.md) this is a **shipped reference**, not a
drift-gated copy: consume it from
`node_modules/@norskvideo/ctl-dev-kit/conventions/docs.md`. The two things that
ARE single-sourced and gated are the workflow (`publish-docs.yml`) and the
toolchain library (`@norskvideo/ctl-dev-kit/doc-guide`); this file is the
convention that ties them together.

## What the manual is (and is not)

The manual is one hash-routed HTML page assembled from **two cross-linked
collections**, plus an overview:

- **Functionality pages** — one capability per page (remote talent, live mixing,
  output shapes, …). "What the product does."
- **Example-deployment pages** — worked shows, each a concrete shape a reader can
  recognise as close to their own. "What a real deployment looks like." Named as
  starting points, never as a fixed menu or a tier ladder.
- **Overview** — a hero capture and two ways in: by function, or by example.

It is NOT the reference (`CommentaryConfig` / CI author material — that stays in
`docs/reference.md` + `examples/`), NOT the operator runbook (host footprint,
limitations — mostly ctl's), and NOT a feature checklist. It is the
capability-first, illustrated front door.

### Who reads it

Name the reader before writing a page (see `personas.md` and the product's own
`docs/personas.md`):

- **Evaluator** — the overview, the example gallery, first light. Wants "does
  this do what I need?" in minutes.
- **Builder** — the illustrated task pages. The domain-fluent operator who stands
  the show up and runs it.

Integrator and Administrator are served elsewhere (reference / ctl). Runtime
actors (a commentator, a contributor, a producer) are NOT readers of the
manual — whoever runs the show reads as the Builder; the others receive join
links and onboarding kit.

## Voice

Customer- and Builder-facing, plain, capability-led. The rules that keep every
product's manual in one voice:

- **No internal/test vocabulary.** Never `engine`, `fixture`, `Playwright`,
  `assertion`, `harness`, `instance`, `SSR`, a slug, or a DOM id in reader-facing
  copy. The reader does not know or care how the shot was produced.
  **Scoped and checked (October 2026):** the rule applies to the light half of
  a manual, which is a site's Start here pages (`index`, `quickstart`) and its
  `guides/`, plus a repo's `docs/quickstart.md`. `docs/light-pages.ts` in this
  package lints exactly those pages for the deny list and a word budget, skipping
  code, comments and imports, and runs from `bun run docs:check`. A repo whose
  own vocabulary is on the list allows the word (`--allow`); `instance` is not
  on it, because it is the label on norsk-ctl's own screens. How-to and
  Reference may use any of it.
- **Lead with the capability, not the UI state.** "A commentator joins from a
  browser with a link" — not "the connections page shows a slot in the pending
  state."
- **Plain, not markety.** Describe what it does; skip the superlatives.
- **No floor / ceiling / max / tier framing.** A deployment is described by its
  shape ("compose and two delivery groups"), never as "the maximal build" or "the
  ceiling" — the ceiling is wherever the customer needs it to be. Internal example
  slugs (e.g. `commentary-max`) may keep the word; the copy a reader sees must not.
- **Examples are starting points.** "Worked deployments — the pieces combine
  freely; any show is described in config," not a menu the reader must pick from.

## Captures

Every screenshot is a REAL capture written by a doc-guide driving a real surface
that the guide also asserts — so a shot can never depict a UI that does not
exist. A guide runs at one of three tiers:

- **Fixture** — the product's Vite app booted alone, fed representative state (no
  daemon, no license). Proves the UI renders correctly. Fast; runs in CI.
- **Frontend** — the standalone configure form, same model.
- **Engine** — a REAL launched instance, driven live (On Air actually lighting
  up, a real feed composited). Slow, licensed, nightly.

Per-step treatments (`CaptureTreatment`, from the doc-guide library):

- **full page** (default) — the whole surface, for orientation shots.
- **`crop`** — a tight element screenshot of one component, by CSS selector, for
  a close-up. Anchor crops on a stable `data-region` / `data-*` attribute the
  component renders, not on incidental structure.
- **`spotlight`** — the whole screen with one region lit and everything else
  dimmed, for "look here, in context."
- **`livePicture`** — a live video tile the shot must not be taken without.
  Studio's shared MoQ player (`norsk-studio-built-ins/lib/shared/moq-player`)
  stamps `data-moq-phase` on the container it mounts into: `connecting`, then
  `live` on the first decoded frame, or `error` with the reason in
  `data-moq-error`. The player mounts at the moment the source connects, which
  is also when a guide's "it's running" assertion passes — so a capture taken
  then is of a spinner, and a sleep only hides a preview that is broken. The
  phase alone is not enough, though: Commentary's standby slate and its "Enter
  the booth" modal both passed it (2026-10-07), because the player underneath
  was live. So the gate judges the visible composite as well: the element at
  the tile's centre (`document.elementFromPoint`) must be the tile or inside
  it, nothing matching the slate/modal selectors (`[data-slate]`,
  `[role="dialog"]`, `.modal`, or visible text matching
  `/temporarily unavailable|standby/i`) may be visible inside it, and two
  32x18 luma samples of its video or canvas taken 500 ms apart must differ —
  a slate, however bright, is still. Name the tile here, or call
  `awaitLivePicture(page, selector, gate?)` before the capture (the verify run
  skips captures, so an explicit wait is also the assertion); either way a
  tile that never shows a picture fails the guide, naming the check it never
  passed. Add a product's own slate markers with `livePictureGate.
  occluderSelectors`. A tile with no video or canvas in it (an audio-only
  player) cannot pass, so do not name one.

**Captures are shot at 2x in a `DOCS_GENERATE` run.** `guidesConfig` defaults
`deviceScaleFactor` from `captureScaleFactor()`: 2 when `DOCS_GENERATE=1`, 1
otherwise, and `launchLiveBrowser` (the engine tier) reads the same default.
The video spike measured the manual's component crops at 383 to 778 px wide;
blown up to sit in a 1080p frame they were legible but soft, and no renderer
step recovers detail that was never captured. A plain run of a guide writes no
captures, so it stays at 1x and nothing else about it changes; pass
`deviceScaleFactor` explicitly to pin either. Expect the PNGs to grow with the
pixel count.

### When a guide runs

A fixture-tier guide costs seconds and needs no licence, so it runs in the
**PR check** (`checks.yml`), where a broken capture is found by its author;
the engine tier runs **nightly** in the docs workflow, where a licence and a
real launch are affordable. A site is never hostage to a capture: on a
checkout with no `docs/generated`, the dev-kit's `docs/guide-stubs.ts stub`
writes a placeholder page for every guide the site's symlinks name, so
`docs:site` builds, and `docs/guide-stubs.ts check` **refuses to publish** a
placeholder, in every publish workflow the dev-kit ships. The placeholder
sentence is the marker the gate matches on; do not write it by hand.

## The toolchain

All single-sourced in `@norskvideo/ctl-dev-kit/doc-guide` — products import it,
they do not copy it:

- `@norskvideo/ctl-dev-kit/doc-guide` — `DocGuide` (a guide narrates itself into
  `docs/generated/<slug>/`), `screenshotFor`, `CaptureTreatment`. Writes under
  `NORSK_DOCS_ROOT` (the repo-level `docs/generated`); the config factory sets it.
  Step text is also the video narration, so in a `DOCS_GENERATE` run `step()`
  (heading plus description) and `text()` fail the guide over 50 spoken words,
  on a raw URL (say "the address"), or on a slash between words (write "or" or
  "and"); the failure names the guide, the step and the count. `captionWords`
  on `guidesConfig` or on the `DocGuide` options tightens the budget. A plain
  run is unchanged.
- `@norskvideo/ctl-dev-kit/doc-guide/guides-config` — `guidesConfig({ port,
  docsRoot, … })`, the Playwright config for the fixture/frontend tiers, carrying
  the CI defaults (bind+probe `127.0.0.1`, 120s webServer timeout, piped output).
- `@norskvideo/ctl-dev-kit/doc-guide/build-manual` — `buildManual(spec, {
  docsRoot, provenance })`, the generic assembler. The product supplies ONLY the
  page content (`ManualSpec`: brand, overview, functionality[], examples[], and
  `links` — the way out: the product's page on norsk.video, a contact); layout,
  CSS and routing live in the assembler so every product's manual looks the
  same. Every export ends in a provenance footer (build time, source sha, CI
  run id — `provenanceFromEnv()` reads them from a GitHub Actions run).
- `@norskvideo/ctl-dev-kit/doc-guide/bundle` — `buildBundle(spec, { docsRoot,
  outDir, provenance })`, the docs bundle: `bundle.json` + `pages/<id>.md` +
  `captures/` + the derived `manual.html`/`manual-linked.html`, in one
  directory. Write it to `docs/generated/bundle/`: the image build stages that
  directory as the image's `docs/` and the backend serves it at `/docs` with
  the SDK's `serveDocs(app, docsBundleDir(import.meta.dir))` — one line in
  `server.ts`, a no-op when there is no bundle. Declare the nav entry in the
  manifest: `ui.sidebarEntries: [{ label: "Documentation", route: "/docs/" }]`;
  ctl links it under `/products/<name>/`. An older ctl simply does not link it
  (additive: nothing about launch changes).
- `@norskvideo/ctl-dev-kit/doc-guide/{instance-proxy,live-browser}` — engine-tier
  support (serve the baked dashboard under its advertised instance prefix; launch
  the nix chromium for a live capture).
- `doc-guide/with-display.sh` — wrap the run in an X display for headless chromium
  on a true-headless Linux box. Copy it to the repo's `scripts/` (it must be a
  repo-local executable the package.json guide scripts invoke).

Per-repo, the product provides: its guide suites, a thin `build-manual.ts` that
supplies the `ManualSpec` and does the file IO, and
`scripts/doc-guide/regen-manual.sh` — the one command a release runs to drive
every tier with capture-writing on, then assemble. `regen-manual.sh` owns the
slug list and which tiers run (set `SKIP_ENGINE=1` to regenerate only the fast
tiers). An engine tier needing images beyond the universal media+studio pair
pulls them in an optional `scripts/doc-guide/prepull-extra.sh`.

## The publish contract

The unit is the **docs bundle** (`buildBundle` -> `docs/generated/bundle/`:
`bundle.json`, `pages/*.md`, `captures/`, and the derived `manual.html`).
Exposure is declared by which docs workflow the repo carries — exactly one,
single-sourced here and drift-gated:

- **`build-docs.yml` — embedded.** Nightly: regenerate the bundle, bake it into
  the product image through `deployment/build-image.sh`, read it back out of the
  image as the proof. No upload, no write permission on the repo, no token. A
  turnkey (`norsk-ctl-turnkey-*`) carries this and nothing else: its
  documentation reaches exactly one customer, inside the image they already run.
- **`publish-docs.yml` — public.** The same build, then three things on one
  moving `docs-latest` GitHub Release:
  - `manual.html`, clobbered each night — what norsk-ctl's docs site fetches and
    mounts at `/products/<slug>/`
    (`https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html`).
  - `bundle-<version>.tar.gz`, the whole bundle, named by the product commit it
    was built from, plus `latest.json` naming the current one. Versioned assets
    on ONE release rather than a release per commit: a release per commit would
    leave hundreds of prereleases on the repo, and a single clobbered asset would
    leave nothing to roll back to. Only the pointer moves, so a rollback is a
    pointer edit and the site never reads an asset being overwritten underneath
    it. `BUNDLE_KEEP` older bundles are pruned, never the one the pointer names.
  - a `product-docs-published` `repository_dispatch` to `id3as/norsk-ctl`, so the
    public site rebuilds rather than waiting for its own cron. The payload
    carries the repo, not a product key — norsk-ctl resolves the key through
    ci-workflows' registry, so a new product needs no new mapping.

  **Only a repo GitHub calls `norsk-ctl-product-*` may carry it.** `check:drift` refuses it anywhere else,
  refuses a repo carrying both, and refuses the public variant when it cannot
  tell the repo's name (`GITHUB_REPOSITORY` in CI, the origin remote locally).

### Per-page visibility

Every page in `bundle.json` carries a `visibility`, `public` or `possession`,
defaulting to the bundle's own (`buildBundle(spec, { visibility })`, itself
`public`). The public fetcher stages only the public pages; the image serves all
of them either way, so `possession` means "reaches customers only inside the
image they already run".

**No page is special-cased — mark `possession` only when you mean it.** In
particular `known-limitations` is not the page to hide: a candid limitations
page is a trust asset for a public product, and hiding the honest page is an odd
signal to an evaluator.

This is not what protects a turnkey, and must not be read as if it were. Turnkey
privacy is a repo/bundle-level guarantee — the `^norsk-ctl-product-` repo-name
rule, the registry's `docsVisibility`, the per-repo workflow variant, and the
fetcher's own gate. A turnkey's whole bundle never reaches the public plane
whatever its pages say.

Either way the image serves the bundle at `/docs` (SDK `serveDocs`), reached
through the daemon at `/products/<name>/docs/` behind its auth guard and linked
from the product's hub via `ui.sidebarEntries`. That is the sole channel for a
turnkey and the version-true channel for a product; the public site is the
faster-cadence copy, for products only.
