@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.
@@ -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>;
@@ -10,6 +10,37 @@ export function docsRoot() {
10
10
  return isAbsolute(override) ? override : resolve(process.cwd(), override);
11
11
  return resolve(process.cwd(), "docs/generated");
12
12
  }
13
+ /** The spoken ceiling for one step. The video spikes measured a step narrating
14
+ * at about 2.5 words/s: 35 words is 14 s on one still, 50 is 20 s, and the
15
+ * renderer's own lint warns at 35. This is the hard stop. */
16
+ export const DEFAULT_CAPTION_WORDS = 50;
17
+ const SPOKEN_WORD = /[\p{L}\p{N}]/u;
18
+ const RAW_URL = /https?:\/\/\S+/;
19
+ const SLASH_BETWEEN_WORDS = /\S+\/\S+/;
20
+ /** Why a step's text cannot be narrated as written, or null when it can. Pure,
21
+ * so the wording is pinned without a browser. Checks the budget first: an
22
+ * over-long caption gets rewritten anyway, and the rewrite is where the
23
+ * phrasing gets fixed. */
24
+ export function captionFault(text, budget) {
25
+ const count = text.split(/\s+/).filter((token) => SPOKEN_WORD.test(token)).length;
26
+ if (count > budget)
27
+ return `${count} words; the spoken budget is ${budget}`;
28
+ const url = RAW_URL.exec(text);
29
+ if (url)
30
+ return `contains a raw URL ("${url[0]}"); say "the address" and let the page carry the link`;
31
+ const slash = SLASH_BETWEEN_WORDS.exec(text);
32
+ if (slash)
33
+ return `contains a slash between words ("${slash[0]}"); write "or" or "and", or hyphenate`;
34
+ return null;
35
+ }
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 function captureScaleFactor() {
42
+ return process.env.DOCS_GENERATE === "1" ? 2 : 1;
43
+ }
13
44
  /** The attributes Studio's shared MoQ player (norsk-studio-built-ins
14
45
  * `lib/shared/moq-player`) stamps on the container it mounts into. `live`
15
46
  * means the first video frame has decoded — not that the transport is up or
@@ -18,35 +49,161 @@ export function docsRoot() {
18
49
  export const MOQ_PHASE_ATTR = "data-moq-phase";
19
50
  export const MOQ_ERROR_ATTR = "data-moq-error";
20
51
  const LIVE_PICTURE_TIMEOUT_MS = 60_000;
52
+ const LIVE_PICTURE_POLL_MS = 250;
53
+ const LIVE_PICTURE_FRAME_GAP_MS = 500;
54
+ /** Slates and modals a tile must not be showing. Products add their own via
55
+ * `occluderSelectors`; the text pattern catches a slate rendered as DOM text. */
56
+ export const DEFAULT_OCCLUDER_SELECTORS = ["[data-slate]", '[role="dialog"]', ".modal"];
57
+ export const DEFAULT_OCCLUDER_TEXT = /temporarily unavailable|standby/i;
58
+ export const FRAME_SAMPLE_W = 32;
59
+ export const FRAME_SAMPLE_H = 18;
60
+ const DEFAULT_PIXEL_DELTA = 6;
61
+ const DEFAULT_MIN_CHANGED_FRACTION = 0.02;
62
+ function changedFraction(a, b, pixelDelta) {
63
+ const n = Math.min(a.length, b.length);
64
+ if (n === 0)
65
+ return 0;
66
+ let changed = 0;
67
+ for (let i = 0; i < n; i++)
68
+ if (Math.abs(a[i] - b[i]) > pixelDelta)
69
+ changed++;
70
+ return changed / n;
71
+ }
72
+ function phaseReason(phase, error) {
73
+ if (phase === null)
74
+ return "not stamped — is a player mounted there?";
75
+ return phase === "error" && error ? `error: ${error}` : phase;
76
+ }
77
+ /** Rule on whether the tile is showing a live picture, naming the first
78
+ * check that says it is not. */
79
+ export function judgeLivePicture(evidence, motion = {}) {
80
+ if (evidence.phase !== "live")
81
+ return { live: false, check: "phase", reason: phaseReason(evidence.phase, evidence.error) };
82
+ if (evidence.coveredBy !== null)
83
+ return { live: false, check: "covered", reason: `the tile's centre is under "${evidence.coveredBy}"` };
84
+ if (evidence.occluders.length > 0)
85
+ return {
86
+ live: false,
87
+ check: "occluded",
88
+ reason: `a slate or modal is visible inside the tile: ${evidence.occluders.join(", ")}`,
89
+ };
90
+ if (evidence.frames === null)
91
+ return { live: false, check: "undrawable", reason: "no video or canvas inside the tile to sample" };
92
+ const need = motion.minChangedFraction ?? DEFAULT_MIN_CHANGED_FRACTION;
93
+ const moved = changedFraction(evidence.frames[0], evidence.frames[1], motion.pixelDelta ?? DEFAULT_PIXEL_DELTA);
94
+ if (moved < need)
95
+ return {
96
+ live: false,
97
+ check: "frozen",
98
+ reason: `the picture did not change across ${evidence.frameGapMs}ms (${(moved * 100).toFixed(1)}% of sampled pixels moved; need ${need * 100}%)`,
99
+ };
100
+ return { live: true };
101
+ }
21
102
  /** The failure a guide sees when a tile never shows a picture: the tile, the
22
103
  * phase it was stuck in and, for an error, the player's own reason — so a
23
104
  * broken preview reads as what it is rather than as a timeout. Pure, so the
24
105
  * wording is pinned without a browser. */
25
106
  export function livePictureFailure(selector, phase, error, timeoutMs) {
26
- const state = phase === null
27
- ? "not stamped — is a player mounted there?"
28
- : phase === "error" && error
29
- ? `error: ${error}`
30
- : phase;
31
- return `the MoQ preview at "${selector}" never decoded a frame in ${timeoutMs}ms (phase: ${state})`;
32
- }
33
- /** Block until the tile at `selector` reports a decoded frame, or fail saying
34
- * why it did not. This is the wait a guide puts between "the source connected"
35
- * and the capture: the player mounts at that moment, and a screenshot taken
36
- * then is of a spinner. A wall-clock sleep would hide a broken preview; this
37
- * turns it into a named failure. */
38
- export async function awaitLivePicture(page, selector, opts) {
39
- const timeoutMs = opts?.timeoutMs ?? LIVE_PICTURE_TIMEOUT_MS;
40
- try {
41
- await page.waitForFunction(({ selector, attr }) => document.querySelector(selector)?.getAttribute(attr) === "live", { selector, attr: MOQ_PHASE_ATTR }, { timeout: timeoutMs });
107
+ return `the MoQ preview at "${selector}" never decoded a frame in ${timeoutMs}ms (phase: ${phaseReason(phase, error)})`;
108
+ }
109
+ /** The failure for whichever check the tile never passed. The phase check
110
+ * keeps its original wording; the composite checks name themselves. */
111
+ export function livePictureGateFailure(selector, verdict, timeoutMs) {
112
+ if (verdict.check === "phase") {
113
+ return `the MoQ preview at "${selector}" never decoded a frame in ${timeoutMs}ms (phase: ${verdict.reason})`;
42
114
  }
43
- catch {
44
- const [phase, error] = await page.evaluate(({ selector, phaseAttr, errorAttr }) => {
45
- const el = document.querySelector(selector);
46
- return [el?.getAttribute(phaseAttr) ?? null, el?.getAttribute(errorAttr) ?? null];
47
- }, { selector, phaseAttr: MOQ_PHASE_ATTR, errorAttr: MOQ_ERROR_ATTR });
48
- throw new Error(livePictureFailure(selector, phase, error, timeoutMs));
115
+ return `the MoQ preview at "${selector}" never showed a live picture in ${timeoutMs}ms (${verdict.check}: ${verdict.reason})`;
116
+ }
117
+ /** Gather, in the page, everything `judgeLivePicture` rules on. Stops at the
118
+ * phase when the tile is not live: the frame samples cost `frameGapMs`. */
119
+ async function collectLivePictureEvidence(page, selector, gate) {
120
+ return page.evaluate(async ({ selector, phaseAttr, errorAttr, occluderSelectors, occluderText, frameGapMs, w, h }) => {
121
+ const el = document.querySelector(selector);
122
+ const phase = el?.getAttribute(phaseAttr) ?? null;
123
+ const error = el?.getAttribute(errorAttr) ?? null;
124
+ const evidence = { phase, error, coveredBy: null, occluders: [], frames: null, frameGapMs };
125
+ if (!el || phase !== "live")
126
+ return evidence;
127
+ const describe = (n) => `${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ""}${Array.from(n.classList)
128
+ .map((c) => `.${c}`)
129
+ .join("")}`;
130
+ let r = el.getBoundingClientRect();
131
+ if (r.bottom < 0 || r.right < 0 || r.top > innerHeight || r.left > innerWidth) {
132
+ el.scrollIntoView({ block: "nearest", inline: "nearest" });
133
+ r = el.getBoundingClientRect();
134
+ }
135
+ const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
136
+ if (hit === null)
137
+ evidence.coveredBy = "nothing (is the tile off-screen?)";
138
+ else if (!el.contains(hit))
139
+ evidence.coveredBy = describe(hit);
140
+ const visible = (n) => n.getClientRects().length > 0 && getComputedStyle(n).visibility !== "hidden";
141
+ for (const sel of occluderSelectors) {
142
+ if (Array.from(el.querySelectorAll(sel)).some(visible))
143
+ evidence.occluders.push(sel);
144
+ }
145
+ if (occluderText) {
146
+ const match = new RegExp(occluderText.source, occluderText.flags).exec(el.innerText ?? "");
147
+ if (match)
148
+ evidence.occluders.push(`text "${match[0]}"`);
149
+ }
150
+ const drawable = el.matches("video, canvas") ? el : el.querySelector("video, canvas");
151
+ if (!drawable)
152
+ return evidence;
153
+ const scratch = document.createElement("canvas");
154
+ scratch.width = w;
155
+ scratch.height = h;
156
+ const ctx = scratch.getContext("2d");
157
+ if (!ctx)
158
+ return evidence;
159
+ const sample = () => {
160
+ ctx.drawImage(drawable, 0, 0, w, h);
161
+ const d = ctx.getImageData(0, 0, w, h).data;
162
+ const luma = [];
163
+ for (let i = 0; i < d.length; i += 4)
164
+ luma.push(Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]));
165
+ return luma;
166
+ };
167
+ const first = sample();
168
+ await new Promise((resolve) => setTimeout(resolve, frameGapMs));
169
+ evidence.frames = [first, sample()];
170
+ return evidence;
171
+ }, {
172
+ selector,
173
+ phaseAttr: MOQ_PHASE_ATTR,
174
+ errorAttr: MOQ_ERROR_ATTR,
175
+ occluderSelectors: [...gate.occluderSelectors],
176
+ occluderText: gate.occluderText ? { source: gate.occluderText.source, flags: gate.occluderText.flags } : null,
177
+ frameGapMs: gate.frameGapMs,
178
+ w: FRAME_SAMPLE_W,
179
+ h: FRAME_SAMPLE_H,
180
+ });
181
+ }
182
+ /** Block until the tile at `selector` shows a live picture, or fail saying
183
+ * which check it never passed. This is the wait a guide puts between "the
184
+ * source connected" and the capture: the player mounts at that moment, and a
185
+ * screenshot taken then is of a spinner. A wall-clock sleep would hide a
186
+ * broken preview; this turns it into a named failure.
187
+ *
188
+ * Live means: the player stamped `live`, nothing else sits at the tile's
189
+ * centre, no slate or modal is visible inside it, and its picture moved
190
+ * between two samples. The last two are what let a standby slate and a
191
+ * modal over the hero through the phase check alone (2026-10-07). */
192
+ export async function awaitLivePicture(page, selector, opts = {}) {
193
+ const timeoutMs = opts.timeoutMs ?? LIVE_PICTURE_TIMEOUT_MS;
194
+ const gate = {
195
+ occluderSelectors: opts.occluderSelectors ?? DEFAULT_OCCLUDER_SELECTORS,
196
+ occluderText: opts.occluderText === undefined ? DEFAULT_OCCLUDER_TEXT : opts.occluderText,
197
+ frameGapMs: opts.frameGapMs ?? LIVE_PICTURE_FRAME_GAP_MS,
198
+ };
199
+ const deadline = Date.now() + timeoutMs;
200
+ let verdict = judgeLivePicture(await collectLivePictureEvidence(page, selector, gate), opts);
201
+ while (!verdict.live && Date.now() < deadline) {
202
+ await page.waitForTimeout(LIVE_PICTURE_POLL_MS);
203
+ verdict = judgeLivePicture(await collectLivePictureEvidence(page, selector, gate), opts);
49
204
  }
205
+ if (!verdict.live)
206
+ throw new Error(livePictureGateFailure(selector, verdict, timeoutMs));
50
207
  }
51
208
  const SPOTLIGHT_ID = "docguide-spotlight";
52
209
  /** Dim the page except the target region, drawn as one fixed box whose oversized
@@ -113,8 +270,12 @@ export async function awaitFontsReady(page, timeoutMs = FONTS_READY_TIMEOUT_MS)
113
270
  * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
114
271
  * so the treatment geometry is testable on its own. */
115
272
  export async function screenshotFor(page, treat) {
116
- if (treat?.livePicture)
117
- await awaitLivePicture(page, treat.livePicture, { timeoutMs: treat.livePictureTimeoutMs });
273
+ if (treat?.livePicture) {
274
+ await awaitLivePicture(page, treat.livePicture, {
275
+ timeoutMs: treat.livePictureTimeoutMs,
276
+ ...treat.livePictureGate,
277
+ });
278
+ }
118
279
  // Wait for web fonts, else the snapshot catches a fallback font mid-swap --
119
280
  // but not forever (see awaitFontsReady).
120
281
  if (!(await awaitFontsReady(page))) {
@@ -157,24 +318,48 @@ export function renderUiMarkdown(title, entries) {
157
318
  }
158
319
  return lines.join("\n");
159
320
  }
321
+ /** The budget a guide is held to: an explicit option, else the env var the
322
+ * config factory exports, else the default. Read at call time, like docsRoot. */
323
+ export function captionBudget(option) {
324
+ if (option !== undefined)
325
+ return option;
326
+ const fromEnv = Number(process.env.NORSK_DOCS_CAPTION_WORDS);
327
+ return Number.isInteger(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CAPTION_WORDS;
328
+ }
329
+ const TEXT_LABEL_WORDS = 6;
160
330
  export class DocGuide {
161
331
  slug;
162
332
  title;
333
+ options;
163
334
  entries = [];
164
335
  captureIndex = 0;
165
- constructor(slug, title) {
336
+ constructor(slug, title, options = {}) {
166
337
  this.slug = slug;
167
338
  this.title = title;
339
+ this.options = options;
168
340
  }
169
341
  section(heading) {
170
342
  this.entries.push({ kind: "section", heading });
171
343
  }
172
344
  step(heading, description) {
345
+ this.lintCaption(`step "${heading}"`, description ? `${heading} ${description}` : heading);
173
346
  this.entries.push({ kind: "step", heading, description });
174
347
  }
175
348
  text(prose) {
349
+ const label = prose.split(/\s+/).slice(0, TEXT_LABEL_WORDS).join(" ");
350
+ this.lintCaption(`text "${label} ..."`, prose);
176
351
  this.entries.push({ kind: "text", prose });
177
352
  }
353
+ /** Only the DOCS_GENERATE run is published and narrated, so only it is held
354
+ * to the spoken budget; a plain run of the guide asserts the UI and says
355
+ * nothing about the prose. */
356
+ lintCaption(what, narrated) {
357
+ if (process.env.DOCS_GENERATE !== "1")
358
+ return;
359
+ const fault = captionFault(narrated, captionBudget(this.options.captionWords));
360
+ if (fault)
361
+ throw new Error(`doc-guide "${this.slug}": ${what} cannot be narrated: ${fault}`);
362
+ }
178
363
  /** Screenshot the page and write it immediately under the slug's docs dir. Pass
179
364
  * a treatment to crop to one component or spotlight a region in context. */
180
365
  async capture(page, caption, treat) {
@@ -9,11 +9,17 @@ export interface GuidesConfigOptions {
9
9
  width: number;
10
10
  height: number;
11
11
  };
12
+ /** Pixel density of the captures. Default 2 in a DOCS_GENERATE run, 1
13
+ * otherwise (see captureScaleFactor): crops shot at 1x are soft in a video frame. */
14
+ deviceScaleFactor?: number;
12
15
  /** webServer command. Default `bunx vite --port <port> --strictPort --host 127.0.0.1`. */
13
16
  command?: string;
14
17
  /** Playwright testDir. Default "./tests/guides". */
15
18
  testDir?: string;
16
19
  /** Per-test timeout in ms. Default 60_000. */
17
20
  timeout?: number;
21
+ /** Spoken-word budget for one step's text in a DOCS_GENERATE run (see
22
+ * DocGuide). Exported as NORSK_DOCS_CAPTION_WORDS. Default: the dev-kit's 50. */
23
+ captionWords?: number;
18
24
  }
19
25
  export declare function guidesConfig(opts: GuidesConfigOptions): PlaywrightTestConfig;
@@ -1,8 +1,11 @@
1
1
  import { defineConfig } from "@playwright/test";
2
+ import { captureScaleFactor } from "./doc-guide.js";
2
3
  export function guidesConfig(opts) {
3
4
  // Exported before the test files import the dev-kit DocGuide, so its deferred
4
5
  // docsRoot() resolves to the repo-level dir rather than <package>/docs/generated.
5
6
  process.env.NORSK_DOCS_ROOT = opts.docsRoot;
7
+ if (opts.captionWords !== undefined)
8
+ process.env.NORSK_DOCS_CAPTION_WORDS = String(opts.captionWords);
6
9
  const baseURL = `http://127.0.0.1:${opts.port}`;
7
10
  const command = opts.command ?? `bunx vite --port ${opts.port} --strictPort --host 127.0.0.1`;
8
11
  return defineConfig({
@@ -14,6 +17,7 @@ export function guidesConfig(opts) {
14
17
  browserName: "chromium",
15
18
  headless: true,
16
19
  viewport: opts.viewport ?? { width: 1440, height: 960 },
20
+ deviceScaleFactor: opts.deviceScaleFactor ?? captureScaleFactor(),
17
21
  ...(process.env.BROWSER_FOR_TESTING && { launchOptions: { executablePath: process.env.BROWSER_FOR_TESTING } }),
18
22
  },
19
23
  webServer: {
@@ -12,4 +12,6 @@ export declare function launchLiveBrowser(opts?: {
12
12
  width: number;
13
13
  height: number;
14
14
  };
15
+ /** Default: captureScaleFactor() -- 2 in a DOCS_GENERATE run, 1 otherwise. */
16
+ deviceScaleFactor?: number;
15
17
  }): Promise<LiveBrowser>;
@@ -5,6 +5,7 @@
5
5
  // chromium the fixture tier uses, via BROWSER_FOR_TESTING) rather than go through
6
6
  // the test-runner's webServer model.
7
7
  import { chromium } from "@playwright/test";
8
+ import { captureScaleFactor } from "./doc-guide.js";
8
9
  /** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
9
10
  * chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
10
11
  * --no-sandbox because the nix chromium has no setuid sandbox helper. */
@@ -15,7 +16,10 @@ export async function launchLiveBrowser(opts) {
15
16
  }
16
17
  const browser = await chromium.launch({ executablePath, args: ["--no-sandbox"] });
17
18
  const viewport = opts?.viewport ?? { width: 1440, height: 960 };
18
- const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
19
+ const context = await browser.newContext({
20
+ viewport,
21
+ deviceScaleFactor: opts?.deviceScaleFactor ?? captureScaleFactor(),
22
+ });
19
23
  const page = await context.newPage();
20
24
  return { browser, context, page };
21
25
  }