@norskvideo/ctl-dev-kit 0.2.53 → 0.2.55

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.
@@ -11,6 +11,8 @@
11
11
  // - nix/ctl-shell.nix -> overwrite with canonical (created if absent)
12
12
  // - flake.nix (legacy verbatim copy only) -> the starter, repo's floating ctl pin kept
13
13
  // - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest
14
+ // - publish-docs-site.yml -> verbatim; CREATED where the repo has a
15
+ // docs site and is not a turnkey
14
16
  // Structural per-repo files (root tsconfig.json, deployment/build-image.sh,
15
17
  // manifest.seed.json) have NO canonical bytes to apply — the gate checks their
16
18
  // shape, not their content — so they are left untouched.
@@ -30,6 +32,7 @@ import {
30
32
  CTL_VERSION_LINE,
31
33
  carriesIntegrationMarker,
32
34
  DOCS_SITE_DIR,
35
+ DOCS_SITE_WORKFLOW,
33
36
  END,
34
37
  GITIGNORE_BEGIN,
35
38
  GITIGNORE_END,
@@ -40,6 +43,7 @@ import {
40
43
  type ProductShape,
41
44
  PUBLISH_IMAGE_LINE,
42
45
  RUNNER_LINE,
46
+ resolveRepoName,
43
47
  resolveShape,
44
48
  splitAtMarkers,
45
49
  } from "./check-drift.ts";
@@ -49,6 +53,13 @@ export interface SyncReport {
49
53
  skipped: string[];
50
54
  }
51
55
 
56
+ export interface SyncDriftOptions {
57
+ /** The GitHub repo name (`norsk-ctl-product-probe`), which decides whether the
58
+ * docs site's publish may be CREATED here (never in a turnkey). The CLI
59
+ * resolves it; a fixture passes it. Unknown ⇒ nothing is created. */
60
+ repoName?: string;
61
+ }
62
+
52
63
  function writeIfChanged(path: string, content: string, rel: string, written: string[]): void {
53
64
  const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
54
65
  if (current !== content) {
@@ -267,6 +278,39 @@ function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): vo
267
278
  chmodSync(path, 0o755);
268
279
  }
269
280
 
281
+ // publish-docs-site.yml: the docs SITE's publish. Verbatim (the project name is
282
+ // a repo variable, the credentials are secrets), so an existing copy is always
283
+ // re-derived whole -- the gate, not this writer, judges whether the repo may
284
+ // carry one. It is CREATED only where the gate would accept it: a repo with a
285
+ // site (there is something to publish), named, and not a turnkey (a turnkey has
286
+ // no public site, ADR-0012). That is how a product that grows a site acquires
287
+ // its publish: the next sync-dev-kit run installs it, green until the Pages
288
+ // project exists. A name this writer cannot determine creates nothing, since
289
+ // the gate could only refuse the result.
290
+ const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
291
+
292
+ function syncPublishDocsSite(repoRoot: string, canonical: string, repoName: string | undefined, r: SyncReport): void {
293
+ const path = join(repoRoot, DOCS_SITE_WORKFLOW);
294
+ if (existsSync(path)) {
295
+ writeIfChanged(path, canonical, DOCS_SITE_WORKFLOW, r.written);
296
+ return;
297
+ }
298
+ if (!hasDocsSite(repoRoot)) {
299
+ r.skipped.push(`${DOCS_SITE_WORKFLOW} (no docs site to publish)`);
300
+ return;
301
+ }
302
+ if (repoName === undefined) {
303
+ r.skipped.push(`${DOCS_SITE_WORKFLOW} (cannot tell which repo this is; a turnkey may not carry it)`);
304
+ return;
305
+ }
306
+ if (TURNKEY_REPO.test(repoName)) {
307
+ r.skipped.push(`${DOCS_SITE_WORKFLOW} (${repoName} is a turnkey: no public site)`);
308
+ return;
309
+ }
310
+ mkdirSync(dirname(path), { recursive: true });
311
+ writeIfChanged(path, canonical, DOCS_SITE_WORKFLOW, r.written);
312
+ }
313
+
270
314
  // The docs site's theme copies — the writer behind check-drift's docsThemeProblems.
271
315
  // Only for a repo that HAS a site: sync never creates one (a site is a nested
272
316
  // standalone package with its own install, not a file this writer can conjure),
@@ -286,7 +330,12 @@ function syncDocsTheme(repoRoot: string, r: SyncReport): void {
286
330
  for (const rel of adrift) r.written.push(`${DOCS_SITE_DIR}/${rel}`);
287
331
  }
288
332
 
289
- export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: ProductShape = "studio"): SyncReport {
333
+ export function syncDrift(
334
+ repoRoot: string,
335
+ canonical: CanonicalBytes,
336
+ shape: ProductShape = "studio",
337
+ opts: SyncDriftOptions = {},
338
+ ): SyncReport {
290
339
  const r: SyncReport = { written: [], skipped: [] };
291
340
  // Shape-selected canonicals: an sdk-app's flake + checks are its own variants
292
341
  // (the gate holds it to those). Everything else is shared across shapes.
@@ -349,6 +398,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: Pr
349
398
  syncDemoShim(repoRoot, canonical.demoShim, r);
350
399
  syncWithDisplay(repoRoot, canonical.withDisplay, r);
351
400
  syncDocsTheme(repoRoot, r);
401
+ syncPublishDocsSite(repoRoot, canonical.publishDocsSite, opts.repoName, r);
352
402
 
353
403
  return r;
354
404
  }
@@ -366,6 +416,7 @@ if (import.meta.main) {
366
416
  botPrs: readFileSync(join(dir, "bot-prs.yml"), "utf8"),
367
417
  publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
368
418
  buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
419
+ publishDocsSite: readFileSync(join(dir, "publish-docs-site.yml"), "utf8"),
369
420
  withDisplay: readFileSync(join(dir, "..", "doc-guide", "with-display.sh"), "utf8"),
370
421
  checks: readFileSync(join(dir, "checks.yml"), "utf8"),
371
422
  biome: readFileSync(join(dir, "biome.base.json"), "utf8"),
@@ -381,7 +432,7 @@ if (import.meta.main) {
381
432
  checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"),
382
433
  upgradeLatestSdkApp: readFileSync(join(dir, "upgrade-latest.sdk-app.yml"), "utf8"),
383
434
  };
384
- const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot));
435
+ const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot), { repoName: resolveRepoName(repoRoot) });
385
436
  if (report.written.length === 0) {
386
437
  console.log("sync-drift: all shared-convention copies already match @norskvideo/ctl-dev-kit.");
387
438
  } else {
@@ -307,10 +307,17 @@ ${SITE_DIR}/dist-public/
307
307
  ${SITE_DIR}/.astro/
308
308
  `;
309
309
 
310
+ /** Before either build: a placeholder page for every recorded guide the
311
+ * site symlinks from docs/generated whose capture has not run (dev-kit
312
+ * docs/guide-stubs.ts), so the site builds on a checkout with no captures --
313
+ * as ctl's and the Manager's do. A site that declares no recorded guides is
314
+ * a no-op. The publish workflow refuses to deploy a placeholder. */
315
+ const STUB_GUIDES = `bun run node_modules/@norskvideo/ctl-dev-kit/docs/guide-stubs.ts stub ${SITE_DIR}/src/content/docs/guides`;
316
+
310
317
  /** The root package.json scripts that drive it. Named the same in every repo
311
318
  * that has a site, including the two in the norsk-ctl monorepo. */
312
319
  export const DOCS_SITE_SCRIPTS: Record<string, string> = {
313
320
  "docs:dev": `bun run --cwd ${SITE_DIR} dev`,
314
- "docs:site": `bun run --cwd ${SITE_DIR} build`,
315
- "docs:site:public": `ASTRO_BUILD=public bun run --cwd ${SITE_DIR} build`,
321
+ "docs:site": `${STUB_GUIDES} && bun run --cwd ${SITE_DIR} build`,
322
+ "docs:site:public": `${STUB_GUIDES} && ASTRO_BUILD=public bun run --cwd ${SITE_DIR} build`,
316
323
  };
@@ -302,8 +302,9 @@ function rootPackageJson(ctx: ShapeContext): string {
302
302
  "test:unit": `bun test shared/src/ backend/src/ ${hasComponents ? "components/src/ " : ""}tests/unit/`,
303
303
  "test:image": "bun test tests/image/",
304
304
  "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts",
305
- "docs:check": "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts",
306
- "docs:manual": `bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js --brand "${ctx.name}" --title "${ctx.name} — Documentation" --link "norsk.video=https://norsk.video/" --out docs/generated/bundle docs/handover.md docs/known-limitations.md`,
305
+ "docs:check":
306
+ "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts && bun run node_modules/@norskvideo/ctl-dev-kit/docs/light-pages.ts && bun run node_modules/@norskvideo/ctl-dev-kit/docs/journeys.ts",
307
+ "docs:manual": `bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js --brand "${ctx.name}" --title "${ctx.name} — Documentation" --link "norsk.video=https://norsk.video/" --out docs/generated/bundle docs/handover.md docs/operating.md docs/known-limitations.md`,
307
308
  "docs:handover": "bash scripts/docs-handover.sh",
308
309
  "docs:handover:check": "bash scripts/docs-handover.sh --check",
309
310
  // The docs SITE (website/) — a standalone package, so its install is its
@@ -1896,8 +1897,8 @@ function exampleInputJson(_ctx: ShapeContext): string {
1896
1897
 
1897
1898
  // ---- The canonical doc set (fleet review 02 s9, backend-only turnkey) ------
1898
1899
  // README (developer), docs/README (the index), docs/handover (the customer's
1899
- // deployment document: prose around a block norsk-ctl generates), docs/known-
1900
- // limitations (operator). Prose only: a turnkey's bundle is built from these
1900
+ // deployment document: prose around a block norsk-ctl generates), docs/operating
1901
+ // and docs/known-limitations (operator). Prose only: a turnkey's bundle is built from these
1901
1902
  // files by docs:manual, which the image build runs, so the image serves them.
1902
1903
  // Bundle-internal file names are written without backticks on purpose: the
1903
1904
  // path-existence lint would look for them in the repo.
@@ -1961,7 +1962,8 @@ norsk-ctl and the pinned Norsk SDK:
1961
1962
  Two channels, deliberately:
1962
1963
 
1963
1964
  - **The bundle the image serves.** \`bun run docs:manual\` builds it from
1964
- \`docs/handover.md\` and \`docs/known-limitations.md\`; the image build runs
1965
+ \`docs/handover.md\`, \`docs/operating.md\` and \`docs/known-limitations.md\`;
1966
+ the image build runs
1965
1967
  it, so every image serves them at /docs at the version that is running
1966
1968
  (linked from the product's page in norsk-ctl). \`bun run docs:handover\`
1967
1969
  regenerates the handover's generated block from a daemon that has this
@@ -1985,7 +1987,8 @@ served at /docs at the version that is running.
1985
1987
 
1986
1988
  | Document | Audience | What it is |
1987
1989
  | ---------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
1988
- | \`docs/handover.md\` | the platform team deploying this product | Deploying from the exported bundle without norsk-ctl: every mount, environment and injected setting |
1990
+ | \`docs/handover.md\` | whoever gets this product running | How norsk-ctl arrives, the one command that adds this product, and the bundle route for platform teams |
1991
+ | \`docs/operating.md\` | operators | What a running deployment looks like from the operator's chair: the controls, the checks, the signs |
1989
1992
  | \`docs/known-limitations.md\` | operators | What the product does not do yet, with the consequence and the workaround |
1990
1993
 
1991
1994
  The **docs site** is a separate channel and lives in \`website/\`: the public
@@ -1995,18 +1998,84 @@ manual, on the shared Norsk theme, not built from these files. See
1995
1998
  \`bun run docs:manual\` builds the bundle from these files. The tables in the
1996
1999
  handover are generated by norsk-ctl (\`bun run docs:handover\`) — regenerate
1997
2000
  them, never edit them.
2001
+
2002
+ \`docs/personas.md\` is for writers, not readers, and is not in the bundle: it
2003
+ maps this product's audiences onto the four shared personas and carries the
2004
+ journey table \`bun run docs:check\` verifies.
2005
+ `;
2006
+ }
2007
+
2008
+ // The per-product persona mapping and the journey table the journeys lint
2009
+ // reads (conventions/personas.md). The routes name the pages the skeleton
2010
+ // ships; a product rewrites them as its pages arrive, and docs:check keeps
2011
+ // every named page real.
2012
+ function personasMd(ctx: ShapeContext): string {
2013
+ return `# Who ${ctx.name}'s documentation is for
2014
+
2015
+ The four personas are shared across every Norsk product and described in
2016
+ \`node_modules/@norskvideo/ctl-dev-kit/conventions/personas.md\`. This page maps
2017
+ ${ctx.name}'s own audiences onto them and says where each reader goes.
2018
+
2019
+ ## The mapping
2020
+
2021
+ <!-- ctl-docs-template: fill me -->
2022
+ _Name this product's audiences (the people who set it up, run it, and are
2023
+ invited into it) and say which of the four personas each one is. Runtime
2024
+ actors are not personas: an operator at the console is the Builder wearing
2025
+ the Administrator's hat._
2026
+
2027
+ ## The journey table
2028
+
2029
+ | Persona | Starts at | Then, in order | Hands off to |
2030
+ | ------------- | ------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
2031
+ | Evaluator | \`/docs/\` | \`/docs/guides/\` | [norsk-ctl's manual](https://ctl.norsk.video/), to run it for real |
2032
+ | Builder | \`/docs/guides/\` | \`/docs/how-to/\` | \`/docs/reference/\`, for the settings they just used |
2033
+ | Integrator | \`/docs/reference/\` | \`/docs/concepts/\` | [norsk-ctl's CLI and API reference](https://ctl.norsk.video/) |
2034
+ | Administrator | \`handover.md\` | \`operating.md\`, \`known-limitations.md\` | [norsk-ctl's manual](https://ctl.norsk.video/), for everything about the machine |
2035
+
2036
+ Site paths resolve under website/src/content/docs; a bare name resolves under
2037
+ docs/. Each route is at most five pages and ends in a hand-off, which is the
2038
+ one place "see norsk-ctl for details" lives.
1998
2039
  `;
1999
2040
  }
2000
2041
 
2001
2042
  function handoverMd(ctx: ShapeContext): string {
2002
- return `# Deploying ${ctx.name} without norsk-ctl
2043
+ return `# Deploying ${ctx.name}
2044
+
2045
+ How ${ctx.name} reaches you and how to get it running. Nearly every deployment
2046
+ goes through norsk-ctl, and that is the first two sections. The rest of this
2047
+ page is for a platform team running the exported bundle in its own
2048
+ orchestration instead.
2049
+
2050
+ ## How norsk-ctl arrives
2051
+
2052
+ \`\`\`sh
2053
+ curl -fsSL https://norsk.video/install/ctl | bash
2054
+ \`\`\`
2055
+
2056
+ When it finishes, norsk-ctl is running on the machine and its web page is where
2057
+ products are added, launched and watched. Everything about the machine itself
2058
+ is in [norsk-ctl's manual](https://ctl.norsk.video/): ports, TLS, cloud hosts,
2059
+ a box behind another proxy.
2060
+
2061
+ ## Add this product
2062
+
2063
+ \`\`\`sh
2064
+ norsk-ctl product add --image <the image you were given> --license-file <file>
2065
+ \`\`\`
2066
+
2067
+ One command. ${ctx.name} then appears on norsk-ctl's page, ready to launch;
2068
+ operating.md says what to do with it once it is running.
2069
+
2070
+ ## Deploying without norsk-ctl
2003
2071
 
2004
- What the platform team receives, and how to run it. The tables in the
2005
- generated block below come from norsk-ctl's own rendering of this product's
2006
- default template (\`bun run docs:handover\`), so they are the launch truth of
2007
- the version this document ships with. Regenerate them; never edit them.
2072
+ Some platform teams run the product from a bundle norsk-ctl exports, inside
2073
+ their own orchestration. The tables in the generated block below come from
2074
+ norsk-ctl's own rendering of this product's default template
2075
+ (\`bun run docs:handover\`), so they are the launch truth of the version this
2076
+ document ships with. Regenerate them; never edit them.
2008
2077
 
2009
- ## What you receive
2078
+ ### What you receive
2010
2079
 
2011
2080
  A portable bundle exported by norsk-ctl: compose.yml (the flat compose, every
2012
2081
  path relative), HANDOVER.md (the same tables as below), the working-directory
@@ -2017,13 +2086,13 @@ placeholder to replace with a production licence before the first start.
2017
2086
  _Not generated yet. Run \`bun run docs:handover\` against a daemon that has this product added._
2018
2087
  <!-- END generated -->
2019
2088
 
2020
- ## Verifying a deployment
2089
+ ### Verifying a deployment
2021
2090
 
2022
2091
  - The control plane answers on its port: GET /healthz returns ok.
2023
2092
  - The media engine is licensed: its log names the licence's product entry, not a placeholder.
2024
2093
  - The default template's outputs are reachable from where the audience is.
2025
2094
 
2026
- ## Not in this artefact
2095
+ ### Not in this artefact
2027
2096
 
2028
2097
  Anything norsk-ctl-specific: the daemon, product registration, the instance
2029
2098
  proxy and its authentication, templates-as-storage. The bundle is the launch
@@ -2031,6 +2100,42 @@ truth of one product template and needs none of them.
2031
2100
  `;
2032
2101
  }
2033
2102
 
2103
+ // The operator's page, in the shape of the plan's exemplar (reuters'
2104
+ // operating.md). Each section opens with a marker so a half-filled page is
2105
+ // grep-able, and the fill-me lines say what the exemplar put there.
2106
+ function operatingMd(ctx: ShapeContext): string {
2107
+ return `# Operating ${ctx.name}
2108
+
2109
+ What an operator does with a running ${ctx.name}, and where. For getting one
2110
+ running, see handover.md; this page assumes it is up.
2111
+
2112
+ ## What it is
2113
+
2114
+ <!-- ctl-docs-template: fill me -->
2115
+ _One paragraph: what the deployment takes in, what it puts out, and the one
2116
+ thing an operator adjusts while it runs._
2117
+
2118
+ ## Where the controls are
2119
+
2120
+ <!-- ctl-docs-template: fill me -->
2121
+ _Which screen the operator works in, and what the product's own page at /docs
2122
+ and its health check at /healthz are for._
2123
+
2124
+ ## Checking it is doing its job
2125
+
2126
+ <!-- ctl-docs-template: fill me -->
2127
+ 1. _The health check answers._
2128
+ 2. _Each input is arriving. A listening input with nothing connected is idle, not failed; the sender is the thing to check._
2129
+ 3. _Each output is connected to its destination._
2130
+
2131
+ ## When something looks wrong
2132
+
2133
+ <!-- ctl-docs-template: fill me -->
2134
+ - _The picture is absent: which input or output to look at first._
2135
+ - _The picture is wrong rather than absent: what is a runtime control and what needs a rebuilt template._
2136
+ `;
2137
+ }
2138
+
2034
2139
  const KNOWN_LIMITATIONS_MD = `# Known limitations
2035
2140
 
2036
2141
  What this product does not do yet, or does with a caveat an operator must know
@@ -2105,7 +2210,9 @@ export const turnkey: ShapeModule = {
2105
2210
  ...docsSiteFiles(ctx),
2106
2211
  { path: "docs/README.md", content: docsReadmeMd(ctx) },
2107
2212
  { path: "docs/handover.md", content: handoverMd(ctx) },
2213
+ { path: "docs/operating.md", content: operatingMd(ctx) },
2108
2214
  { path: "docs/known-limitations.md", content: KNOWN_LIMITATIONS_MD },
2215
+ { path: "docs/personas.md", content: personasMd(ctx) },
2109
2216
  { path: ".github/workflows/build-docs.yml", content: ctx.canon.buildDocs },
2110
2217
  { path: "scripts/doc-guide/regen-manual.sh", content: REGEN_MANUAL_SH, executable: true },
2111
2218
  { path: "scripts/with-display.sh", content: ctx.canon.withDisplay, executable: true },
@@ -24,6 +24,21 @@ export interface UiFixture {
24
24
  * whose cwd is the repo root (the engine tier). Deferred, not module-level, so a
25
25
  * config that sets the env var at eval time is honoured. */
26
26
  export declare function docsRoot(): string;
27
+ /** The spoken ceiling for one step. The video spikes measured a step narrating
28
+ * at about 2.5 words/s: 35 words is 14 s on one still, 50 is 20 s, and the
29
+ * renderer's own lint warns at 35. This is the hard stop. */
30
+ export declare const DEFAULT_CAPTION_WORDS = 50;
31
+ /** Why a step's text cannot be narrated as written, or null when it can. Pure,
32
+ * so the wording is pinned without a browser. Checks the budget first: an
33
+ * over-long caption gets rewritten anyway, and the rewrite is where the
34
+ * phrasing gets fixed. */
35
+ export declare function captionFault(text: string, budget: number): string | null;
36
+ /** Device scale factor for a capture: 2 in a DOCS_GENERATE run, 1 otherwise.
37
+ * The video spike found the manual's component crops 383 to 778 px wide; in
38
+ * a 1080p frame they are soft, and no renderer recovers detail that was never
39
+ * captured. A plain run writes no captures, so it keeps 1x and is unchanged.
40
+ * One place for both tiers: guidesConfig and launchLiveBrowser read it. */
41
+ export declare function captureScaleFactor(): number;
27
42
  /** Per-step screenshot treatment. `crop` shoots one component at its own bounds;
28
43
  * `spotlight` keeps the whole screen for context but dims everything except the
29
44
  * named region and outlines it. Omit both for a plain full-page capture.
@@ -40,6 +55,9 @@ export interface CaptureTreatment {
40
55
  /** How long `livePicture` may take to arrive. Default 60s: a WebTransport
41
56
  * session plus a catalog plus a keyframe, on a cold encoder. */
42
57
  livePictureTimeoutMs?: number;
58
+ /** The rest of the live-picture gate: the product's own slate markers, the
59
+ * motion thresholds. `livePictureTimeoutMs` is the shorthand for its timeout. */
60
+ livePictureGate?: LivePictureGate;
43
61
  }
44
62
  /** The attributes Studio's shared MoQ player (norsk-studio-built-ins
45
63
  * `lib/shared/moq-player`) stamps on the container it mounts into. `live`
@@ -49,19 +67,83 @@ export interface CaptureTreatment {
49
67
  export declare const MOQ_PHASE_ATTR = "data-moq-phase";
50
68
  export declare const MOQ_ERROR_ATTR = "data-moq-error";
51
69
  export type MoqPhase = "connecting" | "live" | "error";
70
+ /** Slates and modals a tile must not be showing. Products add their own via
71
+ * `occluderSelectors`; the text pattern catches a slate rendered as DOM text. */
72
+ export declare const DEFAULT_OCCLUDER_SELECTORS: readonly string[];
73
+ export declare const DEFAULT_OCCLUDER_TEXT: RegExp;
74
+ export interface LivePictureGate extends LivePictureMotion {
75
+ /** Default 60s: a WebTransport session plus a catalog plus a keyframe, on a cold encoder. */
76
+ timeoutMs?: number;
77
+ /** Selectors that mark a slate or modal when visible inside the tile. Default DEFAULT_OCCLUDER_SELECTORS. */
78
+ occluderSelectors?: readonly string[];
79
+ /** Visible text inside the tile that marks a slate; null disables. Default DEFAULT_OCCLUDER_TEXT. */
80
+ occluderText?: RegExp | null;
81
+ /** Gap between the two frame samples the motion check compares. Default 500ms. */
82
+ frameGapMs?: number;
83
+ }
84
+ /** What the browser reports about a tile, for `judgeLivePicture` to rule on.
85
+ * Gathering is a browser matter; the ruling is pure, so each check and its
86
+ * wording are pinned without a launch. */
87
+ export interface LivePictureEvidence {
88
+ /** `data-moq-phase` on the tile; null when nothing stamped it. */
89
+ phase: string | null;
90
+ /** `data-moq-error`, when the player reported one. */
91
+ error: string | null;
92
+ /** What `elementFromPoint` found at the tile's centre when it was neither
93
+ * the tile nor inside it (as `tag#id.class`); null when the tile is on top. */
94
+ coveredBy: string | null;
95
+ /** Slate or modal matches visible inside the tile: the selector that hit,
96
+ * or `text "..."` for the text pattern. Empty when none. */
97
+ occluders: string[];
98
+ /** Two luma samples of the tile's drawable (video or canvas), downsampled to
99
+ * FRAME_SAMPLE_W x FRAME_SAMPLE_H and taken `frameGapMs` apart; null when
100
+ * the tile has nothing to draw. */
101
+ frames: [number[], number[]] | null;
102
+ frameGapMs: number;
103
+ }
104
+ export type LivePictureCheck = "phase" | "covered" | "occluded" | "frozen" | "undrawable";
105
+ /** What counts as motion between the two samples. A slate is still; a live
106
+ * picture moves somewhere. Per-pixel on 0..255 luma, after the downsample
107
+ * has averaged out decoder noise. */
108
+ export interface LivePictureMotion {
109
+ /** A sampled pixel has moved when its luma changed by more than this. Default 6. */
110
+ pixelDelta?: number;
111
+ /** The fraction of sampled pixels that must have moved. Default 0.02. */
112
+ minChangedFraction?: number;
113
+ }
114
+ export declare const FRAME_SAMPLE_W = 32;
115
+ export declare const FRAME_SAMPLE_H = 18;
116
+ export type LivePictureVerdict = {
117
+ live: true;
118
+ } | {
119
+ live: false;
120
+ check: LivePictureCheck;
121
+ reason: string;
122
+ };
123
+ /** Rule on whether the tile is showing a live picture, naming the first
124
+ * check that says it is not. */
125
+ export declare function judgeLivePicture(evidence: LivePictureEvidence, motion?: LivePictureMotion): LivePictureVerdict;
52
126
  /** The failure a guide sees when a tile never shows a picture: the tile, the
53
127
  * phase it was stuck in and, for an error, the player's own reason — so a
54
128
  * broken preview reads as what it is rather than as a timeout. Pure, so the
55
129
  * wording is pinned without a browser. */
56
130
  export declare function livePictureFailure(selector: string, phase: string | null, error: string | null, timeoutMs: number): string;
57
- /** Block until the tile at `selector` reports a decoded frame, or fail saying
58
- * why it did not. This is the wait a guide puts between "the source connected"
59
- * and the capture: the player mounts at that moment, and a screenshot taken
60
- * then is of a spinner. A wall-clock sleep would hide a broken preview; this
61
- * turns it into a named failure. */
62
- export declare function awaitLivePicture(page: Page, selector: string, opts?: {
63
- timeoutMs?: number;
64
- }): Promise<void>;
131
+ /** The failure for whichever check the tile never passed. The phase check
132
+ * keeps its original wording; the composite checks name themselves. */
133
+ export declare function livePictureGateFailure(selector: string, verdict: Exclude<LivePictureVerdict, {
134
+ live: true;
135
+ }>, timeoutMs: number): string;
136
+ /** Block until the tile at `selector` shows a live picture, or fail saying
137
+ * which check it never passed. This is the wait a guide puts between "the
138
+ * source connected" and the capture: the player mounts at that moment, and a
139
+ * screenshot taken then is of a spinner. A wall-clock sleep would hide a
140
+ * broken preview; this turns it into a named failure.
141
+ *
142
+ * Live means: the player stamped `live`, nothing else sits at the tile's
143
+ * centre, no slate or modal is visible inside it, and its picture moved
144
+ * between two samples. The last two are what let a standby slate and a
145
+ * modal over the hero through the phase check alone (2026-10-07). */
146
+ export declare function awaitLivePicture(page: Page, selector: string, opts?: LivePictureGate): Promise<void>;
65
147
  /** How long a capture waits for web fonts before shooting anyway. */
66
148
  export declare const FONTS_READY_TIMEOUT_MS = 10000;
67
149
  /** `document.fonts.ready`, bounded. True when the fonts settled in time.
@@ -82,15 +164,28 @@ export declare function screenshotFor(page: Page, treat?: CaptureTreatment): Pro
82
164
  /** Pure markdown assembly — exposed so a render step can re-emit markdown from a
83
165
  * stored entries.json without re-running the browser. */
84
166
  export declare function renderUiMarkdown(title: string, entries: UiEntry[]): string;
167
+ export interface DocGuideOptions {
168
+ /** Spoken-word budget for one step (heading + description) or text() block.
169
+ * Default: NORSK_DOCS_CAPTION_WORDS (which guidesConfig exports), else 50. */
170
+ captionWords?: number;
171
+ }
172
+ /** The budget a guide is held to: an explicit option, else the env var the
173
+ * config factory exports, else the default. Read at call time, like docsRoot. */
174
+ export declare function captionBudget(option?: number): number;
85
175
  export declare class DocGuide {
86
176
  private slug;
87
177
  private title;
178
+ private options;
88
179
  private entries;
89
180
  private captureIndex;
90
- constructor(slug: string, title: string);
181
+ constructor(slug: string, title: string, options?: DocGuideOptions);
91
182
  section(heading: string): void;
92
183
  step(heading: string, description?: string): void;
93
184
  text(prose: string): void;
185
+ /** Only the DOCS_GENERATE run is published and narrated, so only it is held
186
+ * to the spoken budget; a plain run of the guide asserts the UI and says
187
+ * nothing about the prose. */
188
+ private lintCaption;
94
189
  /** Screenshot the page and write it immediately under the slug's docs dir. Pass
95
190
  * a treatment to crop to one component or spotlight a region in context. */
96
191
  capture(page: Page, caption: string, treat?: CaptureTreatment): Promise<void>;