@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.
- package/conventions/bot-prs.yml +57 -5
- package/conventions/check-drift.ts +56 -1
- package/conventions/docs-site.md +96 -17
- package/conventions/docs.md +52 -5
- package/conventions/personas.md +25 -0
- package/conventions/publish-docs-site.yml +247 -0
- package/conventions/sync-drift.ts +53 -2
- package/create-product/docs-site.ts +9 -2
- package/create-product/turnkey.ts +121 -14
- package/doc-guide/doc-guide.d.ts +104 -9
- package/doc-guide/doc-guide.js +210 -25
- package/doc-guide/guides-config.d.ts +6 -0
- package/doc-guide/guides-config.js +4 -0
- package/doc-guide/live-browser.d.ts +2 -0
- package/doc-guide/live-browser.js +5 -1
- package/docs/guide-stubs.ts +304 -0
- package/docs/journeys.ts +217 -0
- package/docs/light-pages.ts +205 -0
- package/docs-theme/components/InstallProduct.astro +51 -0
- package/package.json +2 -1
package/doc-guide/doc-guide.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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>;
|
package/doc-guide/doc-guide.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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, {
|
|
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: {
|
|
@@ -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({
|
|
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
|
}
|