import type { Page } from "@playwright/test"; export type UiEntry = { kind: "section"; heading: string; } | { kind: "step"; heading: string; description?: string; } | { kind: "text"; prose: string; } | { kind: "capture"; caption: string; filename: string; }; export interface UiFixture { type: "ui"; title: string; entries: UiEntry[]; } /** Resolve the repo-level docs/generated root. NORSK_DOCS_ROOT wins (absolute or * relative to cwd); otherwise /docs/generated, which is correct for runners * whose cwd is the repo root (the engine tier). Deferred, not module-level, so a * config that sets the env var at eval time is honoured. */ export declare function docsRoot(): string; /** The spoken ceiling for one step. The video spikes measured a step narrating * at about 2.5 words/s: 35 words is 14 s on one still, 50 is 20 s, and the * renderer's own lint warns at 35. This is the hard stop. */ export declare const DEFAULT_CAPTION_WORDS = 50; /** The part of a step's text that is narrated: everything but its fenced * blocks, with each markdown link read as its label. */ export declare function spokenText(text: string): string; /** Why a step's text cannot be narrated as written, or null when it can. Pure, * so the wording is pinned without a browser. Checks the budget first: an * over-long caption gets rewritten anyway, and the rewrite is where the * phrasing gets fixed. Only the spoken part is held to either. */ export declare function captionFault(caption: string, budget: number): string | null; /** Device scale factor for a capture: 2 in a DOCS_GENERATE run, 1 otherwise. * The video spike found the manual's component crops 383 to 778 px wide; in * a 1080p frame they are soft, and no renderer recovers detail that was never * captured. A plain run writes no captures, so it keeps 1x and is unchanged. * One place for both tiers: guidesConfig and launchLiveBrowser read it. */ export declare function captureScaleFactor(): number; /** Per-step screenshot treatment. `crop` shoots one component at its own bounds; * `spotlight` keeps the whole screen for context but dims everything except the * named region and outlines it. Omit both for a plain full-page capture. * `livePicture` names a MoQ video tile the shot must not be taken without — * see `awaitLivePicture`. */ export interface CaptureTreatment { crop?: string; spotlight?: string; /** Slack in px around a spotlight region before the dimming starts. */ pad?: number; /** A live video tile (Studio's shared MoQ player's container) that has to be * showing a picture before the capture is taken. */ livePicture?: string; /** How long `livePicture` may take to arrive. Default 60s: a WebTransport * session plus a catalog plus a keyframe, on a cold encoder. */ livePictureTimeoutMs?: number; /** The rest of the live-picture gate: the product's own slate markers, the * motion thresholds. `livePictureTimeoutMs` is the shorthand for its timeout. */ livePictureGate?: LivePictureGate; } /** The attributes Studio's shared MoQ player (norsk-studio-built-ins * `lib/shared/moq-player`) stamps on the container it mounts into. `live` * means the first video frame has decoded — not that the transport is up or * the catalog arrived, both of which can hold for seconds with nothing on * screen, which is exactly what a capture taken on a clock shows. */ export declare const MOQ_PHASE_ATTR = "data-moq-phase"; export declare const MOQ_ERROR_ATTR = "data-moq-error"; export type MoqPhase = "connecting" | "live" | "error"; /** Slates and modals a tile must not be showing. Products add their own via * `occluderSelectors`; the text pattern catches a slate rendered as DOM text. */ export declare const DEFAULT_OCCLUDER_SELECTORS: readonly string[]; export declare const DEFAULT_OCCLUDER_TEXT: RegExp; export interface LivePictureGate extends LivePictureMotion { /** Default 60s: a WebTransport session plus a catalog plus a keyframe, on a cold encoder. */ timeoutMs?: number; /** Selectors that mark a slate or modal when visible inside the tile. Default DEFAULT_OCCLUDER_SELECTORS. */ occluderSelectors?: readonly string[]; /** Visible text inside the tile that marks a slate; null disables. Default DEFAULT_OCCLUDER_TEXT. */ occluderText?: RegExp | null; /** Gap between the two frame samples the motion check compares. Default 500ms. */ frameGapMs?: number; } /** What the browser reports about a tile, for `judgeLivePicture` to rule on. * Gathering is a browser matter; the ruling is pure, so each check and its * wording are pinned without a launch. */ export interface LivePictureEvidence { /** `data-moq-phase` on the tile; null when nothing stamped it. */ phase: string | null; /** `data-moq-error`, when the player reported one. */ error: string | null; /** What `elementFromPoint` found at the tile's centre when it was neither * the tile nor inside it (as `tag#id.class`); null when the tile is on top. */ coveredBy: string | null; /** Slate or modal matches visible inside the tile: the selector that hit, * or `text "..."` for the text pattern. Empty when none. */ occluders: string[]; /** Two luma samples of the tile's drawable (video or canvas), downsampled to * FRAME_SAMPLE_W x FRAME_SAMPLE_H and taken `frameGapMs` apart; null when * the tile has nothing to draw. */ frames: [number[], number[]] | null; frameGapMs: number; } export type LivePictureCheck = "phase" | "covered" | "occluded" | "frozen" | "undrawable"; /** What counts as motion between the two samples. A slate is still; a live * picture moves somewhere. Per-pixel on 0..255 luma, after the downsample * has averaged out decoder noise. */ export interface LivePictureMotion { /** A sampled pixel has moved when its luma changed by more than this. Default 6. */ pixelDelta?: number; /** The fraction of sampled pixels that must have moved. Default 0.02. */ minChangedFraction?: number; } export declare const FRAME_SAMPLE_W = 32; export declare const FRAME_SAMPLE_H = 18; export type LivePictureVerdict = { live: true; } | { live: false; check: LivePictureCheck; reason: string; }; /** Rule on whether the tile is showing a live picture, naming the first * check that says it is not. */ export declare function judgeLivePicture(evidence: LivePictureEvidence, motion?: LivePictureMotion): LivePictureVerdict; /** The failure a guide sees when a tile never shows a picture: the tile, the * phase it was stuck in and, for an error, the player's own reason — so a * broken preview reads as what it is rather than as a timeout. Pure, so the * wording is pinned without a browser. */ export declare function livePictureFailure(selector: string, phase: string | null, error: string | null, timeoutMs: number): string; /** The failure for whichever check the tile never passed. The phase check * keeps its original wording; the composite checks name themselves. */ export declare function livePictureGateFailure(selector: string, verdict: Exclude, timeoutMs: number): string; /** Block until the tile at `selector` shows a live picture, or fail saying * which check it never passed. This is the wait a guide puts between "the * source connected" and the capture: the player mounts at that moment, and a * screenshot taken then is of a spinner. A wall-clock sleep would hide a * broken preview; this turns it into a named failure. * * Live means: the player stamped `live`, nothing else sits at the tile's * centre, no slate or modal is visible inside it, and its picture moved * between two samples. The last two are what let a standby slate and a * modal over the hero through the phase check alone (2026-10-07). */ export declare function awaitLivePicture(page: Page, selector: string, opts?: LivePictureGate): Promise; /** How long a capture waits for web fonts before shooting anyway. */ export declare const FONTS_READY_TIMEOUT_MS = 10000; /** `document.fonts.ready`, bounded. True when the fonts settled in time. * * Unbounded, this hung commentary's composition guide for its whole 360s test * budget on 2026-10-03 (CI only, after the Take, with the page about to render * the advanced compositor): `fonts.ready` resolves only once every pending font * load has finished, a request that never answers never finishes, and * `page.evaluate` has no timeout of its own. The test then timed out, its * teardown with it, and the instance ran on for hours. A capture in a fallback * font is a far smaller failure than that, so bound the wait inside the page * AND around the call -- a wedged renderer never answers the call at all. */ export declare function awaitFontsReady(page: Pick, timeoutMs?: number): Promise; /** Produce the screenshot bytes for a capture, applying its treatment. Split out * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file) * so the treatment geometry is testable on its own. */ export declare function screenshotFor(page: Page, treat?: CaptureTreatment): Promise; /** Pure markdown assembly — exposed so a render step can re-emit markdown from a * stored entries.json without re-running the browser. */ export declare function renderUiMarkdown(title: string, entries: UiEntry[]): string; export interface DocGuideOptions { /** Spoken-word budget for one step (heading + description) or text() block. * Default: NORSK_DOCS_CAPTION_WORDS (which guidesConfig exports), else 50. */ captionWords?: number; } /** The budget a guide is held to: an explicit option, else the env var the * config factory exports, else the default. Read at call time, like docsRoot. */ export declare function captionBudget(option?: number): number; export declare class DocGuide { private slug; private title; private options; private entries; private captureIndex; constructor(slug: string, title: string, options?: DocGuideOptions); section(heading: string): void; step(heading: string, description?: string): void; text(prose: string): void; /** Only the DOCS_GENERATE run is published and narrated, so only it is held * to the spoken budget; a plain run of the guide asserts the UI and says * nothing about the prose. */ private lintCaption; /** Screenshot the page and write it immediately under the slug's docs dir. Pass * a treatment to crop to one component or spotlight a region in context. */ capture(page: Page, caption: string, treat?: CaptureTreatment): Promise; /** Write entries.json + the rendered index.md under docs/generated//. */ flush(): Promise; }