@norskvideo/ctl-dev-kit 0.2.54 → 0.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,12 +13,27 @@
13
13
  # So, for every open PR whose branch a bot owns:
14
14
  # - every check on its head has passed and it merges cleanly -> squash-merge it
15
15
  # (the PR's checks + smoke ARE the verdict; integration then runs on main);
16
- # - a check failed, it conflicts with main, or it is a draft (the bot's own
17
- # gate was red) -> list it, and report this repo's `bot-prs` cell RED on the
18
- # product dashboard, which is the Discord message people actually read;
16
+ # - it conflicts with main -> re-run the bot that owns the branch, ONCE per
17
+ # head, so it rebuilds the branch from today's main (see below);
18
+ # - a check failed, it still conflicts after that rebuild, or it is a draft
19
+ # (the bot's own gate was red) -> list it, and report this repo's `bot-prs`
20
+ # cell RED on the product dashboard, which is the Discord message people
21
+ # actually read;
19
22
  # - checks still running -> leave it for the next trigger.
20
23
  # The cell goes green again once nothing is red.
21
24
  #
25
+ # Why the conflict rebuild: the three bots fire within minutes of each other
26
+ # each morning and all write package.json and bun.lock, each from main as it
27
+ # stood. Whichever lands first leaves the other's open PR conflicting, and the
28
+ # bot only rebuilds its branch on its next DAILY run -- so on 2026-10-09
29
+ # commentary, probe and reuters each had a sync-ctl-packages PR "needing a
30
+ # human" that a re-run of sync-ctl-packages fixed outright. Every bot rebuilds
31
+ # from main when run: sync-ctl-packages force-pushes a fresh branch, and
32
+ # upgrade-latest / sync-dev-kit either land on main and close the PR they
33
+ # superseded, or force-push their branch. The marker comment caps it at one
34
+ # rebuild per head, so a rebuild that still conflicts goes to a human instead
35
+ # of looping every hour.
36
+ #
22
37
  # Runs when a PR's checks or smoke finish (the prompt path), hourly as a sweep,
23
38
  # and on demand. No checkout: it only talks to the GitHub API, so it needs none
24
39
  # of the runner-workspace steps the building workflows carry.
@@ -41,6 +56,8 @@ permissions:
41
56
  contents: read
42
57
  checks: read
43
58
  pull-requests: read
59
+ # The rebuild dispatch and its marker comment go through the PAT (WRITE_TOKEN),
60
+ # like the merges; this token only reads.
44
61
 
45
62
  concurrency:
46
63
  # One at a time per repo, and never cancelled: a merge half-done by a run that
@@ -86,8 +103,19 @@ jobs:
86
103
  | select(.head.ref | test("^(upgrade-latest/|sync-dev-kit/|chore/sync-ctl-packages$)"))
87
104
  | {number, ref: .head.ref, sha: .head.sha, draft, url: .html_url}]' <<<"$pulls")"
88
105
 
106
+ # The workflow that owns each bot branch, i.e. the one that rebuilds it.
107
+ producer() {
108
+ case "$1" in
109
+ upgrade-latest/*) echo upgrade-latest.yml ;;
110
+ sync-dev-kit/*) echo sync-dev-kit.yml ;;
111
+ chore/sync-ctl-packages) echo sync-ctl-packages.yml ;;
112
+ *) echo "" ;;
113
+ esac
114
+ }
115
+
89
116
  red=()
90
117
  merged=()
118
+ rebuilt=()
91
119
  while IFS= read -r pr; do
92
120
  [ -n "$pr" ] || continue
93
121
  n="$(jq -r .number <<<"$pr")"; ref="$(jq -r .ref <<<"$pr")"; sha="$(jq -r .sha <<<"$pr")"
@@ -106,7 +134,29 @@ jobs:
106
134
  [ "$mergeable" != "null" ] && break
107
135
  sleep 3
108
136
  done
109
- if [ "$mergeable" != "true" ]; then red+=("#$n $ref: does not merge cleanly with main"); continue; fi
137
+ if [ "$mergeable" != "true" ]; then
138
+ # One rebuild per head: the marker names the sha it was asked for.
139
+ marker="bot-prs: rebuild requested for head $sha"
140
+ wf="$(producer "$ref")"
141
+ comments="$(gh_api "$api/issues/$n/comments?per_page=100")"
142
+ expect "reading comments on #$n" 'type == "array"' "$comments"
143
+ if [ -z "$wf" ]; then
144
+ red+=("#$n $ref: does not merge cleanly with main, and no bot is known to rebuild it")
145
+ elif jq -e --arg m "$marker" 'any(.[]; .body | contains($m))' >/dev/null <<<"$comments"; then
146
+ red+=("#$n $ref: still does not merge cleanly with main after $wf rebuilt it")
147
+ else
148
+ code="$(gh_write -o /dev/null -w '%{http_code}' -X POST "$api/actions/workflows/$wf/dispatches" -d '{"ref":"main"}')"
149
+ if [ "$code" = "204" ]; then
150
+ body="$marker -- it conflicted with main (another bot landed first), so $wf was re-run to rebuild it from today's main. If the rebuilt branch conflicts too, this PR is reported for a human."
151
+ gh_write -o /dev/null -X POST "$api/issues/$n/comments" -d "$(jq -nc --arg b "$body" '{body:$b}')" || true
152
+ rebuilt+=("#$n")
153
+ echo "#$n $ref: conflicts with main; re-ran $wf to rebuild it"
154
+ else
155
+ red+=("#$n $ref: does not merge cleanly with main, and re-running $wf failed (HTTP $code)")
156
+ fi
157
+ fi
158
+ continue
159
+ fi
110
160
  # sha pins the merge to what was checked: a bot force-pushing the
111
161
  # branch meanwhile makes this refuse rather than land unchecked code.
112
162
  res="$(gh_write -X PUT "$api/pulls/$n/merge" -d "$(jq -nc --arg sha "$sha" '{merge_method:"squash", sha:$sha}')")"
@@ -128,7 +178,9 @@ jobs:
128
178
  state=success
129
179
  # Never empty: the dashboard step reads an empty detail as missing.
130
180
  merged_detail="${merged[*]:+merged ${merged[*]}}"
131
- detail="${merged_detail:-no bot PRs waiting}"
181
+ rebuilt_detail="${rebuilt[*]:+rebuilding ${rebuilt[*]}}"
182
+ detail="$(printf '%s' "$merged_detail${merged_detail:+${rebuilt_detail:+; }}$rebuilt_detail")"
183
+ detail="${detail:-no bot PRs waiting}"
132
184
  fi
133
185
  echo "state=$state" >> "$GITHUB_OUTPUT"
134
186
  echo "detail=${detail%; }" >> "$GITHUB_OUTPUT"
@@ -3,7 +3,8 @@
3
3
  // gated file uses the loosest mechanism that still pins what is shared:
4
4
  // - byte-verbatim: CLAUDE.md fenced core, nix/ctl-shell.nix, biome.json,
5
5
  // tsconfig.base.json, the .gitignore core block, scripts/demo (only a
6
- // product with tests/demo.spec.ts is asked for it)
6
+ // product with tests/demo.spec.ts is asked for it), publish-docs-site.yml
7
+ // (only a repo with a docs site may carry it, and never a turnkey)
7
8
  // - masked per-repo line: checks.yml + upgrade-latest.yml (`product:` key)
8
9
  // - base + sanctioned extension block: dprint.json (repo-specific excludes)
9
10
  // - must import the shared module: flake.nix (otherwise the product's own)
@@ -272,6 +273,10 @@ export interface CanonicalBytes {
272
273
  publishDocs: string;
273
274
  /** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */
274
275
  buildDocs: string;
276
+ /** conventions/publish-docs-site.yml — the docs SITE's publish (website/ ->
277
+ * Cloudflare Pages on every push to main). Verbatim; allowed only where
278
+ * there is a site to publish and never in a turnkey. */
279
+ publishDocsSite: string;
275
280
  checks: string;
276
281
  biome: string;
277
282
  tsconfigBase: string;
@@ -506,6 +511,10 @@ export function resolveRepoName(repoRoot: string, env: NodeJS.ProcessEnv = proce
506
511
  }
507
512
 
508
513
  const PUBLIC_DOCS_REPO = /^norsk-ctl-product-/;
514
+ const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
515
+
516
+ /** The docs site's publish workflow, where a repo carries one. */
517
+ export const DOCS_SITE_WORKFLOW = ".github/workflows/publish-docs-site.yml";
509
518
 
510
519
  /** ADR-0012: exposure is declared by which docs workflow a repo carries, so the
511
520
  * gate is what makes that declaration safe — exactly one variant; the public
@@ -530,6 +539,37 @@ export function docsExposureProblems(hasPublish: boolean, hasBuild: boolean, rep
530
539
  return problems;
531
540
  }
532
541
 
542
+ /** Where a PUBLIC docs site may exist. The rule is negative on the repo name
543
+ * where publish-docs.yml's is positive, because sites live outside
544
+ * norsk-ctl-product-* (norsk-relay has one, and so do norsk-ctl and the
545
+ * Manager); what is certain is that a turnkey has none -- its documentation
546
+ * reaches one customer, inside the image they run (ADR-0012). And a site has
547
+ * to exist to be published: a repo without website/astro.config.mjs carrying
548
+ * the workflow would deploy nothing, or whatever a stale dist held. */
549
+ export function docsSiteExposureProblems(
550
+ hasWorkflow: boolean,
551
+ hasSite: boolean,
552
+ repoName: string | undefined,
553
+ ): string[] {
554
+ if (!hasWorkflow) return [];
555
+ const problems: string[] = [];
556
+ if (!hasSite) {
557
+ problems.push(
558
+ `${DOCS_SITE_WORKFLOW} publishes a docs site, but this repo has none (no ${DOCS_SITE_DIR}/astro.config.mjs). Add the site (conventions/docs-site.md) or delete the workflow.`,
559
+ );
560
+ }
561
+ if (repoName === undefined) {
562
+ problems.push(
563
+ `${DOCS_SITE_WORKFLOW} publishes this repo's docs site publicly, and the gate cannot tell which repo this is (no GITHUB_REPOSITORY, no origin remote). A turnkey (norsk-ctl-turnkey-*) may not carry it.`,
564
+ );
565
+ } else if (TURNKEY_REPO.test(repoName)) {
566
+ problems.push(
567
+ `${DOCS_SITE_WORKFLOW} publishes a public docs site, but this repo is ${repoName}, a turnkey. A turnkey has no public site: its documentation reaches one customer, inside the image they run (ADR-0012). Delete the workflow.`,
568
+ );
569
+ }
570
+ return problems;
571
+ }
572
+
533
573
  export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: CheckDriftOptions = {}): DriftReport {
534
574
  const problems: string[] = [];
535
575
  const push = (problem: string | undefined) => {
@@ -808,6 +848,20 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
808
848
  // checked, and one clause covers both.
809
849
  for (const p of docsThemeProblems(repoRoot, opts.themeCanonical)) push(p);
810
850
 
851
+ // publish-docs-site.yml: the site's publish, verbatim (no per-repo line: the
852
+ // project name is a repo variable, the credentials are secrets), and only
853
+ // where docsSiteExposureProblems allows a public site at all.
854
+ const siteWorkflowPath = join(repoRoot, DOCS_SITE_WORKFLOW);
855
+ if (existsSync(siteWorkflowPath)) {
856
+ const actual = readFileSync(siteWorkflowPath, "utf8");
857
+ if (actual !== canonical.publishDocsSite) {
858
+ push(
859
+ `${DOCS_SITE_WORKFLOW} has drifted from @norskvideo/ctl-dev-kit conventions/publish-docs-site.yml (${firstDiffLine(actual, canonical.publishDocsSite)}). ${RESYNC}`,
860
+ );
861
+ }
862
+ }
863
+ for (const p of docsSiteExposureProblems(existsSync(siteWorkflowPath), hasDocsSite(repoRoot), opts.repoName)) push(p);
864
+
811
865
  for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p);
812
866
  for (const p of productLogsProblems(repoRoot)) push(p);
813
867
  for (const p of ctlPinProblems(repoRoot)) push(p);
@@ -827,6 +881,7 @@ if (import.meta.main) {
827
881
  botPrs: readFileSync(join(import.meta.dir, "bot-prs.yml"), "utf8"),
828
882
  publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
829
883
  buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
884
+ publishDocsSite: readFileSync(join(import.meta.dir, "publish-docs-site.yml"), "utf8"),
830
885
  withDisplay: readFileSync(join(import.meta.dir, "..", "doc-guide", "with-display.sh"), "utf8"),
831
886
  checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
832
887
  biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
@@ -8,7 +8,7 @@ Like [`docs.md`](./docs.md) and [`personas.md`](./personas.md) this is a **shipp
8
8
  reference**, not a drift-gated copy: read it from
9
9
  `node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md`. What IS gated is
10
10
  the theme itself — see "The theme is a copy, and the gate is what makes that
11
- safe" below.
11
+ safe" below — and the publish workflow, see "Publishing".
12
12
 
13
13
  **`create-product` renders all of it.** A repo generated by
14
14
  `bunx @norskvideo/ctl-dev-kit create-product` comes out with a `website/` that
@@ -78,10 +78,24 @@ each section serves, in the vocabulary of [`personas.md`](./personas.md):
78
78
 
79
79
  - **Start here** — the Evaluator. What this does, and where to go next.
80
80
  - **Guides** — the Builder. One task, start to finish, in the order it is done.
81
- These are the pages worth **capturing** rather than illustrating; see
82
- [`docs.md`](./docs.md) for the toolchain and the `capturedAgainst` front matter
83
- the theme renders at the foot of such a page.
81
+ **A Guides page is the recorded chapter where one exists**: a symlink from
82
+ `website/src/content/docs/guides/<slug>` into the `docs/generated/<slug>`
83
+ tree a doc-guide writes, with at most a prose introduction above it. That
84
+ is what the Manager's site does for every guide and what makes the page
85
+ unable to describe a screen that does not exist; see [`docs.md`](./docs.md)
86
+ for the toolchain, the `capturedAgainst` front matter the theme renders at
87
+ the foot of such a page, and the placeholder the dev-kit writes so the site
88
+ builds on a checkout with no captures. **When a product has more than one
89
+ surface** (a console and an API, or a CLI as well), show the same task on
90
+ each: `<task>`, `<task>-ui`, `<task>-api`, grouped under the task in the
91
+ sidebar, as the Manager does for launching a show format, draining a node
92
+ and API tokens. Not every task needs every surface; the first task on the
93
+ Builder's route should have them all.
84
94
  - **How-to** — the Administrator. Looked up, repeatedly, while running it.
95
+ Its first page, **"What this product adds to the host"** (images, ports,
96
+ volumes, GPU, state), is generated from the manifest and the product
97
+ template, where those facts already live, and never hand-written; the
98
+ rest of the section ends with a link into norsk-ctl's manual.
85
99
  - **Concepts** — the vocabulary the rest of the manual uses. Not architecture.
86
100
  - **Reference** — the Integrator. Exhaustive and generated from the source of
87
101
  truth where possible.
@@ -89,6 +103,16 @@ each section serves, in the vocabulary of [`personas.md`](./personas.md):
89
103
  Add sections below these if a product genuinely needs them. Do not rename or
90
104
  reorder these five: the shape is the shared part.
91
105
 
106
+ **Start here and Guides are the light half**, written for readers who do not
107
+ know our engineering vocabulary: one command per step, no flags, the result
108
+ before the prerequisites, a single "Next" link at the foot of each page, and a
109
+ word budget. `docs/light-pages.ts` lints those pages (see `docs.md`, "Voice").
110
+ How-to and Reference may be as exhaustive as the Administrator and the
111
+ Integrator want. The install section of Start here is the theme's
112
+ `components/InstallProduct.astro`: the product's one-line install, one sentence
113
+ on what it leaves behind, and one link into norsk-ctl's manual. It is repeated
114
+ on every product site on purpose and generated so the wording cannot drift.
115
+
92
116
  ## The theme is a copy, and the gate is what makes that safe
93
117
 
94
118
  The theme reaches a site as **files copied into it**, not as an import, and
@@ -138,6 +162,27 @@ Astro derives a component's scoped-style class from its path, so moving
138
162
  Then `bun install --cwd website` (the repo-root install does NOT cover a
139
163
  standalone package) and `bun run docs:site`.
140
164
 
165
+ ### Adding a recorded guide
166
+
167
+ Two lines. A symlink from the site's Guides into the tree the doc-guide
168
+ writes, and a sidebar entry:
169
+
170
+ ```sh
171
+ ln -s ../../../../../docs/generated/<slug> website/src/content/docs/guides/<slug>
172
+ ```
173
+
174
+ ```js
175
+ { label: "<Title>", slug: "guides/<slug>" }
176
+ ```
177
+
178
+ `docs:site` runs the dev-kit's `docs/guide-stubs.ts stub` first, so the site
179
+ builds on a checkout that has never captured; `publish-docs-site.yml` runs
180
+ `guide-stubs.ts check` after the build and refuses to deploy while any guide
181
+ is still a placeholder. That refusal is deliberate: a product that declares
182
+ recorded guides must also restore (or regenerate) its captures in that
183
+ workflow before the build, and until it does, the publish stops rather than
184
+ shipping an empty page.
185
+
141
186
  ## The two builds
142
187
 
143
188
  One content collection, two outputs, selected by `ASTRO_BUILD`:
@@ -159,22 +204,56 @@ of compressed docs on a ~68 MB binary.
159
204
 
160
205
  ## Publishing
161
206
 
162
- Per site, and none of it is needed to build or review one locally:
207
+ The site publishes from **`.github/workflows/publish-docs-site.yml`**,
208
+ single-sourced in this package (`conventions/publish-docs-site.yml`) and
209
+ **drift-gated** the way the bundle's `publish-docs.yml` is: `bun run check:drift`
210
+ holds the copy byte-identical, and `bun run sync:drift` writes it — creating it
211
+ where the repo has a site and is not a turnkey. On every push to `main` (and on
212
+ demand) it installs `website/`, builds the public variant and deploys
213
+ `website/dist-public` to Cloudflare Pages with `wrangler`. It never cancels a
214
+ run in flight, because a cancelled deploy is a half-published site. It restores
215
+ no capture cache: a product site builds from the committed prose, so there is
216
+ nothing a nightly run would have that a push does not.
217
+
218
+ Where it may exist is the gate's business (ADR-0012, and the documentation
219
+ plan): a repo with `website/astro.config.mjs` may carry it; a repo without a
220
+ site may not, since there is nothing to publish; a turnkey
221
+ (`norsk-ctl-turnkey-*`) may not, since a turnkey has no public site. The rule
222
+ is negative on the repo name where `publish-docs.yml`'s is positive, because
223
+ sites exist outside `norsk-ctl-product-*` — Relay has one.
224
+
225
+ What the repo needs, per site, and none of it to build or review one locally:
163
226
 
164
227
  1. **A Cloudflare Pages project**, e.g. `norsk-acme-docs`.
165
228
  2. **A custom domain** on it — `<product>.norsk.video` — and the DNS record.
166
- 3. **API token scope.** The existing `CLOUDFLARE_API_TOKEN` is deliberately narrow
167
- (Pages:Edit). It must cover the new project: widen it to all Pages projects in
168
- the account, or issue a second token.
169
- 4. **A publish step** that runs the public build and `wrangler pages deploy`. Take
170
- the project name from a repo variable rather than a literal, and skip the
171
- deploy with a message when it is unset — so main is green before Cloudflare
172
- exists and publishes the day the variable is set.
173
-
174
- There is no shared `publish-docs-site.yml` in this package yet: the two existing
175
- sites publish from norsk-ctl's own monorepo workflow. The first product repo to
176
- publish one should bring the workflow back here and drift-gate it, the way
177
- `publish-docs.yml` (the bundle's) already is.
229
+ 3. **Two repo secrets.** `CLOUDFLARE_API_TOKEN`, a Pages:Edit token — the
230
+ existing one is deliberately narrow and must cover the new project: widen it
231
+ to all Pages projects in the account, or issue a second token — and
232
+ `CLOUDFLARE_ACCOUNT_ID`. The workflow verifies the token at the API (both the
233
+ user-owned and the account-owned endpoint), never with `wrangler whoami`,
234
+ which a Pages:Edit token cannot answer.
235
+ 4. **One repo variable.** `NORSK_DOCS_SITE_PROJECT`, the project name from
236
+ step 1. It has **no default**: unset, the workflow still builds the site,
237
+ prints exactly these steps and exits 0 — so `main` is green before
238
+ Cloudflare exists and publishes the day the variable is set, with no change
239
+ to the workflow.
240
+
241
+ A `workflow_dispatch` from a branch other than `main` deploys a **preview** of
242
+ that branch (`--branch` is the ref that ran), never over production.
243
+
244
+ ### Wiring it into a repo that has a site
245
+
246
+ Two steps, once a dev-kit carrying the workflow is published:
247
+
248
+ ```
249
+ bun update @norskvideo/ctl-dev-kit # bump the pin (or let sync-dev-kit.yml do it)
250
+ bun run sync:drift # writes .github/workflows/publish-docs-site.yml
251
+ ```
252
+
253
+ Commit the result; `bun run check:drift` passes. The nightly `sync-dev-kit.yml`
254
+ does both hands-free, so a product with a site acquires the workflow on its
255
+ next sync without anyone touching the repo. Then create the Pages project, set
256
+ the two secrets and the variable, and the next push to `main` publishes.
178
257
 
179
258
  ## Search does not cross products
180
259
 
@@ -52,6 +52,14 @@ product's manual in one voice:
52
52
  - **No internal/test vocabulary.** Never `engine`, `fixture`, `Playwright`,
53
53
  `assertion`, `harness`, `instance`, `SSR`, a slug, or a DOM id in reader-facing
54
54
  copy. The reader does not know or care how the shot was produced.
55
+ **Scoped and checked (October 2026):** the rule applies to the light half of
56
+ a manual, which is a site's Start here pages (`index`, `quickstart`) and its
57
+ `guides/`, plus a repo's `docs/quickstart.md`. `docs/light-pages.ts` in this
58
+ package lints exactly those pages for the deny list and a word budget, skipping
59
+ code, comments and imports, and runs from `bun run docs:check`. A repo whose
60
+ own vocabulary is on the list allows the word (`--allow`); `instance` is not
61
+ on it, because it is the label on norsk-ctl's own screens. How-to and
62
+ Reference may use any of it.
55
63
  - **Lead with the capability, not the UI state.** "A commentator joins from a
56
64
  browser with a link" — not "the connections page shows a slot in the pending
57
65
  state."
@@ -89,11 +97,44 @@ Per-step treatments (`CaptureTreatment`, from the doc-guide library):
89
97
  `live` on the first decoded frame, or `error` with the reason in
90
98
  `data-moq-error`. The player mounts at the moment the source connects, which
91
99
  is also when a guide's "it's running" assertion passes — so a capture taken
92
- then is of a spinner, and a sleep only hides a preview that is broken. Name
93
- the tile here, or call `awaitLivePicture(page, selector)` before the capture
94
- (the verify run skips captures, so an explicit wait is also the assertion);
95
- either way a tile that never shows a picture fails the guide, naming the
96
- phase it was stuck in and the player's own error.
100
+ then is of a spinner, and a sleep only hides a preview that is broken. The
101
+ phase alone is not enough, though: Commentary's standby slate and its "Enter
102
+ the booth" modal both passed it (2026-10-07), because the player underneath
103
+ was live. So the gate judges the visible composite as well: the element at
104
+ the tile's centre (`document.elementFromPoint`) must be the tile or inside
105
+ it, nothing matching the slate/modal selectors (`[data-slate]`,
106
+ `[role="dialog"]`, `.modal`, or visible text matching
107
+ `/temporarily unavailable|standby/i`) may be visible inside it, and two
108
+ 32x18 luma samples of its video or canvas taken 500 ms apart must differ —
109
+ a slate, however bright, is still. Name the tile here, or call
110
+ `awaitLivePicture(page, selector, gate?)` before the capture (the verify run
111
+ skips captures, so an explicit wait is also the assertion); either way a
112
+ tile that never shows a picture fails the guide, naming the check it never
113
+ passed. Add a product's own slate markers with `livePictureGate.
114
+ occluderSelectors`. A tile with no video or canvas in it (an audio-only
115
+ player) cannot pass, so do not name one.
116
+
117
+ **Captures are shot at 2x in a `DOCS_GENERATE` run.** `guidesConfig` defaults
118
+ `deviceScaleFactor` from `captureScaleFactor()`: 2 when `DOCS_GENERATE=1`, 1
119
+ otherwise, and `launchLiveBrowser` (the engine tier) reads the same default.
120
+ The video spike measured the manual's component crops at 383 to 778 px wide;
121
+ blown up to sit in a 1080p frame they were legible but soft, and no renderer
122
+ step recovers detail that was never captured. A plain run of a guide writes no
123
+ captures, so it stays at 1x and nothing else about it changes; pass
124
+ `deviceScaleFactor` explicitly to pin either. Expect the PNGs to grow with the
125
+ pixel count.
126
+
127
+ ### When a guide runs
128
+
129
+ A fixture-tier guide costs seconds and needs no licence, so it runs in the
130
+ **PR check** (`checks.yml`), where a broken capture is found by its author;
131
+ the engine tier runs **nightly** in the docs workflow, where a licence and a
132
+ real launch are affordable. A site is never hostage to a capture: on a
133
+ checkout with no `docs/generated`, the dev-kit's `docs/guide-stubs.ts stub`
134
+ writes a placeholder page for every guide the site's symlinks name, so
135
+ `docs:site` builds, and `docs/guide-stubs.ts check` **refuses to publish** a
136
+ placeholder, in every publish workflow the dev-kit ships. The placeholder
137
+ sentence is the marker the gate matches on; do not write it by hand.
97
138
 
98
139
  ## The toolchain
99
140
 
@@ -103,6 +144,12 @@ they do not copy it:
103
144
  - `@norskvideo/ctl-dev-kit/doc-guide` — `DocGuide` (a guide narrates itself into
104
145
  `docs/generated/<slug>/`), `screenshotFor`, `CaptureTreatment`. Writes under
105
146
  `NORSK_DOCS_ROOT` (the repo-level `docs/generated`); the config factory sets it.
147
+ Step text is also the video narration, so in a `DOCS_GENERATE` run `step()`
148
+ (heading plus description) and `text()` fail the guide over 50 spoken words,
149
+ on a raw URL (say "the address"), or on a slash between words (write "or" or
150
+ "and"); the failure names the guide, the step and the count. `captionWords`
151
+ on `guidesConfig` or on the `DocGuide` options tightens the budget. A plain
152
+ run is unchanged.
106
153
  - `@norskvideo/ctl-dev-kit/doc-guide/guides-config` — `guidesConfig({ port,
107
154
  docsRoot, … })`, the Playwright config for the fixture/frontend tiers, carrying
108
155
  the CI defaults (bind+probe `127.0.0.1`, 120s webServer timeout, piped output).
@@ -131,3 +131,28 @@ Reconcile them like this:
131
131
  Map each product's named audiences (from its `PRODUCT.md`) onto the four
132
132
  personas above; don't invent a parallel scheme. Keep the per-product mapping in
133
133
  the product's `docs/personas.md`.
134
+
135
+ ## The journey table
136
+
137
+ A persona is who you write for; a journey is where they go. Each product's
138
+ `docs/personas.md` carries one table, in this shape, and `docs/journeys.ts` in
139
+ this package checks it from `bun run docs:check`: every persona has one row,
140
+ every page it names exists, no route is longer than five pages, and every route
141
+ ends in a hand-off.
142
+
143
+ | Persona | Starts at | Then, in order | Hands off to |
144
+ | ------------- | ----------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
145
+ | Evaluator | `/docs/` | `/docs/quickstart/`, `/docs/guides/gallery/` | Guides (build with it) or ctl (run it for real) |
146
+ | Builder | `/docs/guides/first-…/` | the task pages, `/docs/guides/troubleshooting/` | Reference, for the settings they just used |
147
+ | Integrator | `/docs/reference/` | config, CLI subtree, API, recipes | norsk-ctl's CLI and API reference |
148
+ | Administrator | `/docs/how-to/` | what this product adds to the host, known limitations | norsk-ctl's manual, for everything about the machine |
149
+
150
+ The Integrator's route may start at a recorded API walkthrough (the `-api`
151
+ variant of the Builder's first task) rather than at Reference; the Manager's
152
+ "API tokens, HTTP API" is the pattern. Reference stays where it ends.
153
+
154
+ Site paths are `/docs/...` (the embedded base; the public build strips it) and
155
+ resolve to `website/src/content/docs`; a bare `name.md` resolves under `docs/`.
156
+ The hand-off is where "see norsk-ctl for details" lives, once per route, so no
157
+ page needs the caveat sprinkled through it. A repo with no site and no
158
+ `docs/quickstart.md` (a backend-only turnkey) is not asked for the table.