@jokerized/decksmith 0.1.0

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.
Files changed (78) hide show
  1. package/README.md +794 -0
  2. package/dist/cli.js +8906 -0
  3. package/dist/deck-runtime.js +55 -0
  4. package/dist/index.js +8590 -0
  5. package/dist/mcp.js +7809 -0
  6. package/dist/server/errors.js +73 -0
  7. package/dist/server/http.js +504 -0
  8. package/dist/server/main.js +91 -0
  9. package/dist/server/options.js +198 -0
  10. package/dist/server/pipeline.js +356 -0
  11. package/dist/server/queue.js +195 -0
  12. package/dist/server/ui.js +1614 -0
  13. package/dist/server/upload.js +232 -0
  14. package/dist/types/cli.d.ts +1 -0
  15. package/dist/types/deck/runtime.d.ts +59 -0
  16. package/dist/types/deck/subtitles.d.ts +101 -0
  17. package/dist/types/emit/archetypes/annotated-figure.d.ts +103 -0
  18. package/dist/types/emit/archetypes/bar-compare.d.ts +30 -0
  19. package/dist/types/emit/archetypes/callout.d.ts +7 -0
  20. package/dist/types/emit/archetypes/claim-figure.d.ts +9 -0
  21. package/dist/types/emit/archetypes/data-table.d.ts +21 -0
  22. package/dist/types/emit/archetypes/equation-walk.d.ts +2 -0
  23. package/dist/types/emit/archetypes/grid.d.ts +16 -0
  24. package/dist/types/emit/archetypes/index.d.ts +19 -0
  25. package/dist/types/emit/archetypes/line-chart.d.ts +22 -0
  26. package/dist/types/emit/archetypes/pipeline.d.ts +81 -0
  27. package/dist/types/emit/archetypes/split-compare.d.ts +2 -0
  28. package/dist/types/emit/archetypes/stack.d.ts +93 -0
  29. package/dist/types/emit/archetypes/title.d.ts +188 -0
  30. package/dist/types/emit/camera.d.ts +397 -0
  31. package/dist/types/emit/composition.d.ts +191 -0
  32. package/dist/types/emit/island.d.ts +19 -0
  33. package/dist/types/emit/kit.d.ts +256 -0
  34. package/dist/types/emit/svg.d.ts +177 -0
  35. package/dist/types/emit/theme.d.ts +65 -0
  36. package/dist/types/emit/themes/index.d.ts +40 -0
  37. package/dist/types/emit/themes/ink.d.ts +12 -0
  38. package/dist/types/emit/themes/mono.d.ts +20 -0
  39. package/dist/types/emit/themes/paper.d.ts +18 -0
  40. package/dist/types/index.d.ts +200 -0
  41. package/dist/types/mcp/main.d.ts +2 -0
  42. package/dist/types/mcp/prereqs.d.ts +22 -0
  43. package/dist/types/mcp/tools.d.ts +212 -0
  44. package/dist/types/narrate/narrate.d.ts +87 -0
  45. package/dist/types/narrate/tts.d.ts +134 -0
  46. package/dist/types/narrate/voices.d.ts +29 -0
  47. package/dist/types/pack/media.d.ts +61 -0
  48. package/dist/types/pack/pack.d.ts +16 -0
  49. package/dist/types/plan/codex.d.ts +24 -0
  50. package/dist/types/plan/duration.d.ts +394 -0
  51. package/dist/types/plan/prompt.d.ts +47 -0
  52. package/dist/types/plan/refs.d.ts +22 -0
  53. package/dist/types/plan/select.d.ts +116 -0
  54. package/dist/types/prefs.d.ts +43 -0
  55. package/dist/types/render/captions.d.ts +108 -0
  56. package/dist/types/render/ffmpeg.d.ts +129 -0
  57. package/dist/types/render/render.d.ts +123 -0
  58. package/dist/types/render/timing.d.ts +290 -0
  59. package/dist/types/server/errors.d.ts +11 -0
  60. package/dist/types/server/http.d.ts +57 -0
  61. package/dist/types/server/main.d.ts +1 -0
  62. package/dist/types/server/options.d.ts +90 -0
  63. package/dist/types/server/pipeline.d.ts +21 -0
  64. package/dist/types/server/queue.d.ts +105 -0
  65. package/dist/types/server/ui.d.ts +9 -0
  66. package/dist/types/server/upload.d.ts +107 -0
  67. package/dist/types/source/assets.d.ts +11 -0
  68. package/dist/types/source/fonts.d.ts +15 -0
  69. package/dist/types/source/markdown.d.ts +8 -0
  70. package/dist/types/types.d.ts +1992 -0
  71. package/dist/types/verify/budget.d.ts +41 -0
  72. package/dist/types/verify/check.d.ts +78 -0
  73. package/dist/types/verify/drift.d.ts +158 -0
  74. package/dist/types/verify/fidelity.d.ts +247 -0
  75. package/dist/types/verify/index.d.ts +207 -0
  76. package/dist/types/verify/overprint.d.ts +133 -0
  77. package/dist/types/verify/typefloor.d.ts +50 -0
  78. package/package.json +84 -0
@@ -0,0 +1,41 @@
1
+ import { type Beat, type Finding, type Format, type Storyboard } from "../types.js";
2
+ /** What the composition root records about itself. */
3
+ export interface Canvas {
4
+ width: number;
5
+ height: number;
6
+ /** `data-duration` on the root: the whole deck, narration included. */
7
+ seconds: number;
8
+ }
9
+ /**
10
+ * Read the root element's own attributes.
11
+ *
12
+ * Only the root carries `data-width`/`data-height` (`emitComposition`); scenes
13
+ * carry an id, a start and a duration and nothing else, which is exactly the
14
+ * attribute set EXPERIMENT-003 pinned. So "the div with all three" is an
15
+ * unambiguous handle, and a sub-composition file — should one ever exist — reads
16
+ * as `undefined` rather than as a deck of its own.
17
+ */
18
+ export declare function readCanvas(html: string): Canvas | undefined;
19
+ /**
20
+ * Which profiles could have produced this canvas.
21
+ *
22
+ * The composition records pixels, never a profile id, and `deck-16x9` and
23
+ * `video-16x9` share 1920x1080 — they differ only in navigability. Rather than
24
+ * guess between them, hold the deck to the most permissive budget any profile
25
+ * with this canvas allows: a gate that fails a deck for a limit that might not
26
+ * be its own is a gate people learn to ignore. Today the two 16:9 profiles have
27
+ * the same (absent) budget, so the reading is exact as well as safe.
28
+ */
29
+ export declare function profilesFor(width: number, height: number): Format[];
30
+ /**
31
+ * The budget finding for one built composition, if there is one.
32
+ *
33
+ * `storyboard` is optional because `decksmith verify <dir>` has only the
34
+ * directory. With it, the message can name the beats to drop; without it, the
35
+ * overrun alone still says the useful half. `kept` is the list `build` actually
36
+ * emitted — scenes are beats in order, so a scene's window is the narrated
37
+ * length of the beat at the same index, and getting that pairing wrong would
38
+ * attribute one beat's seconds to another. Absent, it is re-derived with the
39
+ * flat threshold, which is the same list only while the budget cut nothing.
40
+ */
41
+ export declare function scanBudget(html: string, storyboard?: Storyboard, kept?: readonly Beat[]): Finding[];
@@ -0,0 +1,78 @@
1
+ import type { Verdict } from "../types.js";
2
+ /**
3
+ * No `strict` option on purpose. Upstream's `--strict` fails on warnings, and
4
+ * every real deck emits `composition_file_too_large` — its limit is 300
5
+ * structural lines, which a seven-beat deck already passes. Its remedy is to
6
+ * split into sub-compositions, which is exactly the structure that makes a deck
7
+ * non-navigable (EXPERIMENT-003). So the flag could never pass, and a switch
8
+ * that cannot succeed is worse than no switch.
9
+ */
10
+ export interface CheckOptions {
11
+ /** Also write the five contrast-pass PNGs to `<dir>/snapshots`. */
12
+ snapshots?: boolean;
13
+ /**
14
+ * Extra moments to sample, in absolute deck seconds — the deck's own stops.
15
+ *
16
+ * WHY THE DEFAULT GRID IS NOT ENOUGH. Upstream samples nine midpoints across
17
+ * the whole duration, and a stop is a POINT: a twelve-beat deck is ~92s, so
18
+ * the grid lands on 5.1, 15.4, 25.6 … and the odds that any of them coincides
19
+ * with a hold are nil. Measured on a deliberately reverted `claim-figure` fix:
20
+ * the default grid reported 0 findings, and the same deck sampled at its own
21
+ * stops reported `canvas_overflow` on `#s11-c` and `#s11-e` at t=91.4. The
22
+ * overflow was there the whole time; nothing ever looked at the frame the
23
+ * audience actually sees.
24
+ *
25
+ * `regrade` still excuses anything inside a camera transit window, so widening
26
+ * the sampling cannot resurrect the mid-flight false positives that exemption
27
+ * exists for.
28
+ */
29
+ at?: readonly number[];
30
+ timeoutMs?: number;
31
+ }
32
+ /** Run the HyperFrames gates over a built project directory. */
33
+ export declare function check(dir: string, opts?: CheckOptions): Promise<Verdict>;
34
+ /**
35
+ * The times to hand `--at`: the caller's stops UNION the grid they displace.
36
+ *
37
+ * THE UNION IS THE WHOLE POINT. `--at` is a replacement, not an addition
38
+ * (`buildLayoutSampleTimes` returns early on a non-empty `at`), so handing over
39
+ * a list of stops silently deletes nine samples that were doing real work —
40
+ * `data-table` at 7 rows is caught by a midpoint and by no stop of its own. A
41
+ * gate change that trades one class of defect for another is not a
42
+ * strengthening, and this is the line that stops it being one.
43
+ *
44
+ * Times past the end are dropped by upstream anyway; dropping them here keeps
45
+ * the argument honest about what was asked for. Rounded to 3dp because that is
46
+ * upstream's own `roundTime`, so a stop and its midpoint neighbour dedupe
47
+ * instead of running the same frame twice.
48
+ */
49
+ export declare function sampleTimes(stops: readonly number[], duration: number): number[];
50
+ /**
51
+ * Turn one `hyperframes check --json` run into a verdict.
52
+ *
53
+ * Findings carry no `beatId`: the gates report DOM selectors, and mapping a
54
+ * selector back to a beat needs the scene→beat table that `emit` owns. The scene
55
+ * id survives inside the message (`[#s3 .term t=12.5s]`), which is enough to aim
56
+ * a repair round by hand until T2 makes it mechanical.
57
+ *
58
+ * `transit` defaults to none, which is the STRICT grading — a caller who does
59
+ * not supply the deck's camera windows gets every off-canvas finding as an
60
+ * error, exactly as before the camera existed.
61
+ */
62
+ export declare function parseCheckReport(stdout: string, stderr?: string, transit?: Window[]): Verdict;
63
+ /** One camera transit, `[t0, t1]` in absolute deck seconds. */
64
+ /**
65
+ * A camera's transit window, and WHOSE it is.
66
+ *
67
+ * The sid matters because the guarantee is not symmetric. `assertStopsOutsideMove`
68
+ * promises that no stop of the DIPPING scene lands inside its own move; it
69
+ * promises nothing about the INCOMING scene, whose first stop can fall inside the
70
+ * tail (a review built exactly that deck to check). So an exemption granted on
71
+ * time alone excuses a finding about a scene the emitter never vouched for.
72
+ */
73
+ type Window = {
74
+ sid: string;
75
+ t0: number;
76
+ t1: number;
77
+ };
78
+ export {};
@@ -0,0 +1,158 @@
1
+ import type { Verdict } from "../types.js";
2
+ /** Below this, two renders of the same deck differ in a way a viewer would see. */
3
+ export declare const FLOOR_DB = 40;
4
+ /**
5
+ * The archetypes a byte-identical fixture may use.
6
+ *
7
+ * Everything left out tweens `scale` on something that carries text, or draws a
8
+ * raster: `equation-walk` scales the term it is explaining, `grid` scales whole
9
+ * labelled cells, `line-chart` pops its dots from zero, and the two figure
10
+ * archetypes place a decoded bitmap. Scaling an outline hands Skia a glyph-cache
11
+ * decision that comes out differently between runs (EXPERIMENT-006), which is
12
+ * exactly the noise `identical` mode must not contain — so a fixture that grew
13
+ * one of these would start failing for a reason that is not a defect.
14
+ *
15
+ * Enforced by `test/drift.test.ts` against the committed fixture, because the
16
+ * pressure on a fixture is always to make it more interesting.
17
+ */
18
+ export declare const FIXTURE_SAFE_ARCHETYPES: ReadonlySet<string>;
19
+ export type DriftMode = "identical" | "psnr";
20
+ export interface DriftOptions {
21
+ /** Default `psnr`. Use `identical` only on a deck that can actually hold it. */
22
+ mode?: DriftMode;
23
+ /** Per-frame PSNR floor in dB. Ignored in `identical` mode. */
24
+ floorDb?: number;
25
+ /**
26
+ * Passed to `hyperframes render -w`. Worker sharding was tested and refuted as
27
+ * a source of drift (EXPERIMENT-006, hypothesis 2), so this defaults to `auto`
28
+ * and exists to re-test that, not to work around it.
29
+ */
30
+ workers?: number | "auto";
31
+ /** Where the two frame directories go. A fresh temp directory by default. */
32
+ workDir?: string;
33
+ /** Keep the frames even when the gate passes. They are kept on failure regardless. */
34
+ keep?: boolean;
35
+ /** Per render, not for the pair. */
36
+ timeoutMs?: number;
37
+ }
38
+ export interface DriftReport extends Verdict {
39
+ mode: DriftMode;
40
+ frames: number;
41
+ /** Frames whose two PNGs are byte for byte the same. */
42
+ identical: number;
43
+ /** The lowest per-frame PSNR and the 1-based frame it fell on. Absent when nothing differed. */
44
+ worst?: {
45
+ frame: number;
46
+ db: number;
47
+ };
48
+ /** The two frame directories, when they were kept. */
49
+ kept?: {
50
+ a: string;
51
+ b: string;
52
+ };
53
+ /** What moved inside the first render. */
54
+ motion: Motion;
55
+ }
56
+ /** One scene's window as the composition declares it, in seconds. */
57
+ export interface SceneWindow {
58
+ id: string;
59
+ start: number;
60
+ duration: number;
61
+ }
62
+ /** What changed inside a single render. */
63
+ export interface Motion {
64
+ /** Scenes that had frames of their own to compare, and did change. */
65
+ live: readonly string[];
66
+ /** Scenes that had frames of their own to compare, and did not. */
67
+ frozen: readonly string[];
68
+ /**
69
+ * Scenes with fewer than two frames the scene does not share with a
70
+ * neighbour. Reported rather than counted as a pass: "no rule applied" and
71
+ * "the rule passed" have to stay distinguishable.
72
+ */
73
+ unmeasured: readonly string[];
74
+ /** Distinct frames across the whole render — the only measure when no scene could be read. */
75
+ distinct: number;
76
+ }
77
+ /**
78
+ * `<div data-composition-id="s3" data-start="…" data-duration="…">` for every
79
+ * scene, in document order.
80
+ *
81
+ * The attribute triple is EXPERIMENT-003's, and `emitComposition` puts it on
82
+ * scenes and nothing else — `scanBudget` reads the same three. Anchored on the
83
+ * opening tag rather than swept with one greedy pattern because a `[\s\S]{0,200}`
84
+ * bridge between two attributes reads across the boundary into the next element
85
+ * on a scene whose attribute order ever changes.
86
+ */
87
+ export declare function readScenes(html: string): SceneWindow[];
88
+ /**
89
+ * Did anything move, and where not?
90
+ *
91
+ * PER SCENE, not per deck, because the deck-level question has no threshold that
92
+ * survives contact. MEASURED on the frozen fixture: 210 frames, 3 distinct
93
+ * images, 2 frame-to-frame changes — one per scene switch, which HyperFrames
94
+ * performs itself and which happens whether or not the deck's own timeline ever
95
+ * ran. A twelve-scene frozen deck would show about twelve. So "more than N
96
+ * distinct frames" is a bar that rises with the scene count and can be cleared by
97
+ * a deck in which nothing the emitter wrote has any effect at all. Per scene the
98
+ * bar is fixed and needs no calibration: a scene either changed during its own
99
+ * window or it did not.
100
+ *
101
+ * A RATIO does not work either, and the two decks say why: the fixture's live
102
+ * render is 138 distinct frames in 210 (65.7%) and the shipped demo's is 1198 in
103
+ * 7395 (16.2%), because narration stretches every beat and a hold is one image
104
+ * for as long as it lasts. A threshold set from the fixture fails the demo; one
105
+ * set from the demo passes a deck where ten of twelve scenes are dead.
106
+ *
107
+ * ITS OWN WINDOW means the frames it does not share with a neighbour. Scenes
108
+ * overlap by a crossfade — the fixture's s1 is [0, 3.4) and s2 is [3, 7) — and
109
+ * those shared frames carry the switch, which is exactly the change that is not
110
+ * evidence of the scene animating. Dropping them takes the frozen fixture's
111
+ * per-scene count from 1 change to 0, against 55 and 92 in the live render, so
112
+ * the rule is "at least one change", with no constant to argue about.
113
+ *
114
+ * That leaves the partial freeze visible too: one archetype whose reveal is
115
+ * driven by a callback fails its own scene while the other eleven pass.
116
+ */
117
+ export declare function measureMotion(hashes: readonly string[], scenes: readonly SceneWindow[], seconds: number): Motion;
118
+ /**
119
+ * Render `dir` twice and compare.
120
+ *
121
+ * `dir` is a built deck directory — the thing `decksmith build -o` writes, with
122
+ * `index.html` and `hyperframes.json` in it.
123
+ */
124
+ export declare function drift(dir: string, opts?: DriftOptions): Promise<DriftReport>;
125
+ /**
126
+ * Turn the measurement into a verdict. Pure, and separated from the plumbing
127
+ * because this is the part with an opinion in it — everything above just shells
128
+ * out and hashes.
129
+ */
130
+ export declare function judge(m: {
131
+ mode: DriftMode;
132
+ floorDb: number;
133
+ frames: number;
134
+ /** 1-based indices of the frames whose two PNGs differ. */
135
+ differing: readonly number[];
136
+ /** Required whenever `differing` is non-empty. */
137
+ worst?: {
138
+ frame: number;
139
+ db: number;
140
+ };
141
+ motion: Motion;
142
+ }): DriftReport;
143
+ /**
144
+ * `frame_000001.png` → `frame_%06d.png`, for ffmpeg's image2 demuxer.
145
+ *
146
+ * Derived from what the renderer actually wrote rather than hardcoded, because
147
+ * the naming is HyperFrames' business: a version that renames its frames should
148
+ * make this say so, not silently compare nothing.
149
+ */
150
+ export declare function framePattern(name: string): {
151
+ pattern: string;
152
+ start: number;
153
+ } | undefined;
154
+ /** The lowest `psnr_avg` in an ffmpeg stats stream, and the frame it fell on. */
155
+ export declare function worstFrame(stats: string): {
156
+ frame: number;
157
+ db: number;
158
+ } | undefined;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * THE FIDELITY GATE — at each stop, did the thing this slide is about appear?
3
+ *
4
+ * WHY THIS EXISTS. Four of the twenty compositional decks in
5
+ * `experiments/015-decision/` are GATE-CLEAN and draw their main diagram as
6
+ * nothing at all: eight to fifteen drawables per deck, sized legally, placed
7
+ * legally, and multiplied to zero opacity by a group they were nested in.
8
+ * `out/f-vocab18.png` is a headline over three grey arrowheads. `lint`,
9
+ * `runtime`, `layout`, `motion`, `contrast`, the 40px floor and `drift` all pass
10
+ * on it — `drift` twice over, because both renders are identically empty. Every
11
+ * gate in this repo reads what the DOM CONTAINS. This one reads what the frame
12
+ * SHOWS, which is the only thing the audience gets.
13
+ *
14
+ * WHERE IT MEASURES, AND WHY NOT THE CHEAPER PLACE. Two prototypes exist.
15
+ * `invisible.mjs` does arithmetic over the plan — effective opacity as the
16
+ * product down the parent chain — and found 4 of 20. `ink.mjs` renders the deck
17
+ * and counts non-background pixels at every hold, and found 5 of 20. The
18
+ * arithmetic is ~1000x cheaper and it is NOT the gate, for a reason that is
19
+ * structural rather than aesthetic: the shipped path has no plan to do
20
+ * arithmetic over. A DeckSmith storyboard never mentions opacity — revealing
21
+ * what a beat draws is the archetype's job — so on the artifact this project
22
+ * actually produces there is nothing for `invisible.mjs` to read. It cannot be
23
+ * a pre-filter either, for the same reason. Pixels are the only instrument that
24
+ * works on both authoring paths, so pixels are the gate.
25
+ *
26
+ * WHICH PIXELS. Not `hyperframes snapshot`, which is the obvious cheap frame
27
+ * source and is unsound here: it calls `player.renderSeek(t)` with no options
28
+ * (`cli.js`, `seekCompositionTimeline`), so `suppressEvents` is falsy and a GSAP
29
+ * `onUpdate` FIRES. Invariant 11 says the capture path suppresses events, so
30
+ * callback-driven motion plays under snapshot and renders frozen — snapshot is
31
+ * permissive in exactly the direction that hides the failure this gate is for.
32
+ * So the gate drives the capture path's own two calls: inject the pinned
33
+ * hyperframes runtime, `renderSeek(t, { suppressEvents: true })`, then
34
+ * `Page.captureScreenshot` with the renderer's own clip parameters. Measured
35
+ * against `experiments/015-decision/out/vocab-18.mp4` — a real render — at all
36
+ * twelve of its holds, this agrees to a worst case of 0.11 and a mean of 0.03
37
+ * percentage points, the residual being H.264 quantisation (the video's
38
+ * background reads 11,13,15 where the screenshot reads 11,13,16).
39
+ *
40
+ * WHAT COUNTS AS INK. Pixels that differ from the frame's own modal colour by
41
+ * more than `INK_DELTA` on any channel. `ink.mjs` used absolute luma > 26, which
42
+ * is half of the `ink` theme's background and therefore counts 100% of a `mono`
43
+ * deck's white frame as ink; a shipped gate cannot be theme-specific. The modal
44
+ * colour IS the background — it holds 80–98% of every frame measured — and one
45
+ * line of 40px type comes out at 0.684% of the band on `ink`, 0.669% on `mono`
46
+ * and 0.668% on `paper`, so the measure is theme-independent to within 2.4%.
47
+ * This is also where `ink.mjs` and `invisible.mjs` disagreed: `ink.mjs` called
48
+ * `vocab-10` empty at 0.376%, under its 0.4% threshold, because luma > 26 misses
49
+ * a panel fill that is drawn but dim. Background-relative, `vocab-10`'s emptiest
50
+ * hold is 3.705% — nine times its own reported value and nowhere near any floor.
51
+ * The disagreement was the instrument, not the deck: the true count is 4 of 20.
52
+ *
53
+ * WHERE IT LOOKS. Below the slide's caption. Whole-frame ink does not
54
+ * discriminate — measured, MENU's median hold is 16.5% of the frame and VOCAB's
55
+ * is 3.4%, but the hand-written control is 3.6%, sitting among the failures
56
+ * rather than above them, because the headline alone is 1.5–2% and every deck
57
+ * has one. `ink.mjs` cut a fixed band at 0.22H. That band is wrong for this
58
+ * emitter: a two-line DeckSmith `.headline` reaches 0.309H at 16:9 and 0.382H on
59
+ * the twelve-beat demo's last scene, so a fixed 0.22H leaves most of the caption
60
+ * inside the measured region, where its ink would mask an empty body. So the
61
+ * band's top comes from the artifact — the bottom of the active scene's own
62
+ * caption — and 0.22H is only the fallback for a composition that names none.
63
+ * The control that had to be run: blank the archetype on four of the demo's
64
+ * scenes and measure what is left below the caption. It is 0.0000% on all four,
65
+ * so a DeckSmith slide whose beat drew nothing reads exactly zero. The gate is
66
+ * not being propped up by chrome.
67
+ *
68
+ * THE FLOOR IS NOT FITTED. 0.15% of the frame is the measured ink of ONE SHORT
69
+ * LABEL at the 40px audience floor of invariant 5 (0.213% of a 0.73H band =
70
+ * 0.155% of the frame, measured in Chrome, three themes). Below that the body
71
+ * holds less ink than the smallest legible thing this project permits. The
72
+ * corpus then CHECKS it rather than setting it: over 466 stops the worst
73
+ * positive reads 0.039% of the frame and the best negative 0.663%, so the floor
74
+ * derived from the type gate lands 3.8x above one and 4.4x below the other. Had
75
+ * it landed outside that gap, the floor would have been wrong and the gap would
76
+ * have said so. Fitting it would have looked exactly like this and meant
77
+ * nothing, which is why the number came from somewhere else first.
78
+ *
79
+ * ACCEPTANCE, from `fidelity()` itself and not a lookalike. 41 decks, 466 stops:
80
+ * TP 4, FP 0, TN 37, FN 0. It flags `vocab-11`, `-13`, `-16` and `-18` and names
81
+ * the scene (`s01-flow` in all four). It flags none of arm MENU's 20, none of the
82
+ * twelve other VOCAB decks, not the hand-written control, and not the twelve-beat
83
+ * demo in any of its four formats — whose tightest margin is 7.6x the floor
84
+ * (9:16, 1.138%; 16:9 and video 1.732%; 1x1 1.704%) — nor `demo/fixtures/`
85
+ * (plain 4.435%, camera 7.181%). Re-run three times, every number identical to
86
+ * four decimals: this gate does not flake, which matters more than its sensitivity
87
+ * because a false positive on a good slide is how `composition_file_too_large`
88
+ * became something everyone ignores.
89
+ *
90
+ * The number that did NOT move is the one worth the most. Arm MENU is 0 of 20
91
+ * under `invisible.mjs`, 0 of 20 under `ink.mjs` and 0 of 20 here — three
92
+ * different measures, three different thresholds, one answer — which is what
93
+ * makes 4 of 20 on the other arm a property of composition rather than of
94
+ * whoever wrote the checker.
95
+ *
96
+ * THE CONTROL THAT COULD HAVE REFUTED IT. Case thirteen is not expressible on the
97
+ * shipped path, so it was forced: a copy of the twelve-beat demo with one CSS
98
+ * rule holding every scene's content at opacity 0 while the captions reveal
99
+ * normally. Every other gate passes it with ZERO errors and ZERO warnings — it
100
+ * even loses `connector_detached`, because an invisible connector detaches from
101
+ * nothing — and this gate fails it with 12 errors, 37 of 37 stops at 0.0000%. The
102
+ * same deck unbroken reads 1.732% and passes. That is the fourteenth case, caught
103
+ * before it shipped rather than after.
104
+ *
105
+ * WHAT WAS TRIED AND REFUTED. "Ink INCREASED across the reveal" is the more
106
+ * appealing signal — it promises to excuse a sparse slide — and the corpus kills
107
+ * it. Per scene, last hold minus first: the hand-written control's scene 2 gains
108
+ * 0.71 points while every broken scene gains more (vocab-11 0.80, vocab-13 1.04,
109
+ * vocab-18 1.28, vocab-16 1.80), because a scene that starts at zero and ends
110
+ * faint still increases. Per-scene MAXIMUM ink fails too, and by a hair that is
111
+ * worse than a wide miss: vocab-16's broken third scene peaks at 2.51% against
112
+ * the control's honest 2.44%. Presence at a stop is the signal; nothing else in
113
+ * the corpus separates.
114
+ *
115
+ * WHAT IT STILL CANNOT SEE, written down here so it is not rediscovered as a
116
+ * surprise. It answers "something was drawn", never "something was legible": a
117
+ * panel wash 13 levels off the background clears the floor while being nearly
118
+ * invisible, and nothing in the stack measures contrast for a SHAPE — `contrast`
119
+ * only grades runs of text. It measures only at declared stops, so a scene with
120
+ * no holds is not looked at, on the grounds that the deck never claims the
121
+ * audience is stopped there. And it is per-scene, not per-element, which costs a
122
+ * real case: `vocab-16`'s third scene is a bar chart whose FIVE BARS are all
123
+ * invisible while its value labels, axis and category labels are not — opened, it
124
+ * is five numbers floating over an axis — and it reads 0.520% at its emptiest
125
+ * stop, 3.5x the floor, so this gate passes it. The deck is still caught, on its
126
+ * first scene, so the confusion matrix does not show the miss; the scene-level
127
+ * miss is real all the same. No threshold fixes it, because the labels genuinely
128
+ * appeared. What would fix it is knowing that a bar chart must have bars — the
129
+ * beat's intent, which is precisely what arm MENU's archetypes encode and a
130
+ * composed plan does not.
131
+ *
132
+ * COST, and this is why it is not behind a flag. Alone it is 3.8–4.7s for the
133
+ * twelve-beat demo's 37 stops — one browser, one page, one screenshot each. In
134
+ * `verify` it is FREE: it runs concurrently with `check`, which is a child
135
+ * process on its own Chrome, and finishes first. Measured, three runs each:
136
+ * `verify --no-fidelity` 5.66 / 5.77 / 5.75s, `verify` 5.61 / 5.64 / 5.66s. So
137
+ * the marginal wall cost is zero and the difference is noise, which was NOT the
138
+ * expected answer — the estimate before measuring was +7%. `npm run score` on the
139
+ * demo moves 5.81s to 6.32s, and that is a cold cache. A gate too slow to run is
140
+ * a gate nobody runs; this one costs nothing, so it runs by default.
141
+ */
142
+ import type { Finding } from "../types.js";
143
+ /**
144
+ * Ink at a stop, as a fraction of the WHOLE frame — not of the measured band.
145
+ *
146
+ * The band's top moves with the caption, so a band-relative fraction would move
147
+ * the threshold every time a headline wrapped to another line. Normalising by
148
+ * the frame instead makes the number an amount of ink, and tightening the band
149
+ * can then only lower it. That is the safe direction: a stricter band cannot
150
+ * make a blank slide pass.
151
+ */
152
+ export declare const INK_FLOOR = 0.0015;
153
+ /** One declared stop: which scene, and where on the deck timeline. */
154
+ export interface Stop {
155
+ /** Composition id — `s1`, `s2`. */
156
+ sid: string;
157
+ /** Absolute seconds on the composition timeline. */
158
+ t: number;
159
+ }
160
+ export interface Measured extends Stop {
161
+ /** Non-background pixels below the caption, over the whole frame's pixels. */
162
+ ink: number;
163
+ /** Where the band began, as a fraction of frame height. Reported for triage. */
164
+ bandTop: number;
165
+ }
166
+ export interface FidelityOptions {
167
+ floor?: number;
168
+ /**
169
+ * Stops to measure. Supplied by the 015 calibration harness, whose decks are
170
+ * not DeckSmith builds and carry neither `timing.json` nor an island; the
171
+ * shipped path reads them off the artifact.
172
+ */
173
+ stops?: readonly Stop[];
174
+ timeoutMs?: number;
175
+ }
176
+ export interface FidelityReport {
177
+ stops: Measured[];
178
+ /**
179
+ * Both gates' findings — this one's `blank_at_stop` and `overprint`'s
180
+ * `svg_text_overprint`.
181
+ *
182
+ * They share a report because they share a browser, a page and a seek: the
183
+ * collision rule is one `page.evaluate` inside the loop below. Keeping it in
184
+ * its own module and folding the findings in here is the split that costs
185
+ * nothing — see `verify/overprint.ts` for why the rule has to exist at all.
186
+ */
187
+ findings: Finding[];
188
+ elapsedMs: number;
189
+ }
190
+ export interface Frame {
191
+ width: number;
192
+ height: number;
193
+ /** 3 for RGB, 4 for RGBA. */
194
+ channels: number;
195
+ pixels: Uint8Array;
196
+ }
197
+ /**
198
+ * Chrome's screenshot PNG, unpacked.
199
+ *
200
+ * Written out rather than taken from a dependency because `fflate` already ships
201
+ * for the pack format and supplies the only hard part; what is left is one
202
+ * chunk walk and one unfilter. `ffmpeg` would also do it, but `verify` does not
203
+ * otherwise need ffmpeg and a gate that needs a second binary is a gate that
204
+ * gets skipped on the machine that lacks it.
205
+ */
206
+ export declare function decodePng(png: Uint8Array): Promise<Frame>;
207
+ /**
208
+ * Non-background pixels from `bandTopPx` down, over the whole frame's pixels.
209
+ *
210
+ * Pure and exported so the measurement can be tested without a browser — and so
211
+ * that "what this counts" is a thing someone can read in twenty lines rather
212
+ * than infer from a gate's verdict. It counts PIXELS. It does not read a
213
+ * bounding box, and it does not ask the DOM whether an element is visible; both
214
+ * of those have been wrong in this project inside the last week.
215
+ */
216
+ export declare function inkBelow(frame: Frame, bandTopPx: number): number;
217
+ /**
218
+ * The stops a built deck declares, from `timing.json` if it is there and the
219
+ * slideshow island if it is not.
220
+ *
221
+ * `timing.json` is preferred because it exists for every format — `short-9x16`
222
+ * emits no navigable page and so no island — and because it names the scene each
223
+ * stop belongs to, which is what makes a finding aimable. Its `holds` are
224
+ * SCENE-RELATIVE and its `scenes[].start` absolute; the island's `fragments` are
225
+ * already absolute. (`TimedScene.holds`' doc comment in `src/render/timing.ts`
226
+ * says "absolute" and is stale — `framePlan` adds `scene.start` at line 265, and
227
+ * line 370 says so outright.)
228
+ */
229
+ export declare function readStops(timing: string | null, deckPage: string | null): Stop[];
230
+ /**
231
+ * Findings for the stops whose body drew nothing.
232
+ *
233
+ * One finding per SCENE rather than per stop. The failure is a scene whose
234
+ * content never arrives, and vocab-16 has five such stops in one scene — five
235
+ * lines saying the same thing is how a report becomes wallpaper. The worst stop
236
+ * is named because that is the frame to open.
237
+ */
238
+ export declare function gradeFidelity(rows: readonly Measured[], floor?: number): Finding[];
239
+ /**
240
+ * Seek every declared stop and count the ink below its caption.
241
+ *
242
+ * Never throws for an environmental reason: a machine that cannot open a browser
243
+ * gets a WARNING saying the gate did not run, because "the instrument is
244
+ * missing" and "the deck is blank" are different claims and only the second is
245
+ * the deck's fault. A stop that *was* measured and came up empty is an error.
246
+ */
247
+ export declare function fidelity(dir: string, opts?: FidelityOptions): Promise<FidelityReport>;