@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,232 @@
1
+ import { posix, sep } from "node:path";
2
+ import { unzipSync } from "fflate";
3
+ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
4
+ const ZIP_LIMITS = {
5
+ maxEntries: 500,
6
+ maxTotalBytes: 200 * 1024 * 1024,
7
+ maxEntryBytes: 64 * 1024 * 1024
8
+ };
9
+ const MARKDOWN_EXTS = [".md", ".markdown", ".txt"];
10
+ class UploadError extends Error {
11
+ hint;
12
+ status;
13
+ constructor(message, hint, status = 400) {
14
+ super(message);
15
+ this.name = "UploadError";
16
+ this.hint = hint;
17
+ this.status = status;
18
+ }
19
+ }
20
+ const GRACE = 4;
21
+ function readBody(req, limit = MAX_UPLOAD_BYTES) {
22
+ return new Promise((resolve, reject) => {
23
+ const chunks = [];
24
+ let size = 0;
25
+ let refused = false;
26
+ req.on("data", (chunk) => {
27
+ size += chunk.length;
28
+ if (refused) {
29
+ if (size > limit * GRACE) req.destroy();
30
+ return;
31
+ }
32
+ if (size > limit) {
33
+ refused = true;
34
+ chunks.length = 0;
35
+ reject(
36
+ new UploadError(
37
+ `Upload is larger than ${Math.round(limit / 1024 / 1024)} MB.`,
38
+ "Send the markdown on its own, or a zip holding only the document and the figures it cites.",
39
+ 413
40
+ )
41
+ );
42
+ return;
43
+ }
44
+ chunks.push(chunk);
45
+ });
46
+ req.on("end", () => {
47
+ if (!refused) resolve(Buffer.concat(chunks));
48
+ });
49
+ req.on("error", (err) => {
50
+ if (!refused) reject(err);
51
+ });
52
+ });
53
+ }
54
+ async function parseMultipart(body, contentType) {
55
+ if (!/^multipart\/form-data\s*;/i.test(contentType)) {
56
+ throw new UploadError(
57
+ "This endpoint takes a multipart/form-data upload.",
58
+ 'Post a form with a "file" part holding the document, e.g. `curl -F file=@paper.md`.',
59
+ 415
60
+ );
61
+ }
62
+ let form;
63
+ try {
64
+ const view = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
65
+ form = await new Response(view, { headers: { "content-type": contentType } }).formData();
66
+ } catch {
67
+ throw new UploadError(
68
+ "The multipart body could not be parsed.",
69
+ "Let your HTTP client set the Content-Type and boundary rather than writing them by hand."
70
+ );
71
+ }
72
+ const fields = {};
73
+ let file;
74
+ form.forEach((value, key) => {
75
+ if (typeof value === "string") {
76
+ fields[key] = value;
77
+ return;
78
+ }
79
+ if (key === "file") file = value;
80
+ });
81
+ if (!file) {
82
+ throw new UploadError(
83
+ 'The upload has no "file" part.',
84
+ 'Name the document part "file" \u2014 .md, .markdown, .txt, or a .zip containing one.'
85
+ );
86
+ }
87
+ if (file.size === 0) {
88
+ throw new UploadError(
89
+ `"${file.name || "the uploaded file"}" is empty.`,
90
+ "Check the path you gave your client; an empty part usually means the file was not found."
91
+ );
92
+ }
93
+ return {
94
+ filename: file.name || "upload",
95
+ bytes: new Uint8Array(await file.arrayBuffer()),
96
+ fields
97
+ };
98
+ }
99
+ function looksLikeZip(bytes) {
100
+ return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75;
101
+ }
102
+ function safeEntryPath(name) {
103
+ if (name.includes("\0")) return null;
104
+ const segments = name.replace(/\\/g, "/").split("/");
105
+ if (/^[a-zA-Z]:/.test(name) || name.startsWith("/") || name.startsWith("\\")) return null;
106
+ const kept = [];
107
+ for (const segment of segments) {
108
+ if (segment === "" || segment === ".") continue;
109
+ if (segment === "..") return null;
110
+ kept.push(segment);
111
+ }
112
+ if (kept.length === 0) return null;
113
+ const path = kept.join("/");
114
+ if (path.length > 512 || kept.some((s) => s.length > 200)) return null;
115
+ return path;
116
+ }
117
+ function insideRoot(root, joined) {
118
+ return joined === root || joined.startsWith(root.endsWith(sep) ? root : root + sep);
119
+ }
120
+ function readZip(bytes, limits = ZIP_LIMITS) {
121
+ const seen = [];
122
+ try {
123
+ unzipSync(bytes, {
124
+ filter: (f) => {
125
+ seen.push({ name: f.name, originalSize: f.originalSize });
126
+ return false;
127
+ }
128
+ });
129
+ } catch {
130
+ throw new UploadError(
131
+ "That file starts like a zip but could not be read as one.",
132
+ "Re-create the archive \u2014 a truncated download and a renamed .rar both look like this."
133
+ );
134
+ }
135
+ const warnings = [];
136
+ const approved = /* @__PURE__ */ new Map();
137
+ let total = 0;
138
+ for (const entry of seen) {
139
+ if (entry.name.endsWith("/")) continue;
140
+ if (isNoise(entry.name)) continue;
141
+ const safe = safeEntryPath(entry.name);
142
+ if (safe === null) {
143
+ throw new UploadError(
144
+ `The archive contains an entry that tries to escape its directory: "${entry.name}".`,
145
+ "Re-zip from inside the folder so every path is relative and none of them start with / or contain .."
146
+ );
147
+ }
148
+ if (entry.originalSize > limits.maxEntryBytes) {
149
+ throw new UploadError(
150
+ `"${entry.name}" unpacks to ${mb(entry.originalSize)}, over the ${mb(limits.maxEntryBytes)} per-file limit.`,
151
+ "Leave the large file out; a deck reads the document and its figures, not the dataset."
152
+ );
153
+ }
154
+ total += entry.originalSize;
155
+ if (total > limits.maxTotalBytes) {
156
+ throw new UploadError(
157
+ `The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
158
+ "Send only the document and the figures it cites."
159
+ );
160
+ }
161
+ if (approved.size >= limits.maxEntries) {
162
+ throw new UploadError(
163
+ `The archive holds more than ${limits.maxEntries} files.`,
164
+ "Send only the document and the figures it cites."
165
+ );
166
+ }
167
+ approved.set(entry.name, safe);
168
+ }
169
+ if (approved.size === 0) {
170
+ throw new UploadError(
171
+ "The archive is empty.",
172
+ "Zip the folder that holds your markdown, not an empty one."
173
+ );
174
+ }
175
+ const raw = unzipSync(bytes, { filter: (f) => approved.has(f.name) });
176
+ const files = {};
177
+ let actual = 0;
178
+ for (const [name, safe] of approved) {
179
+ const data = raw[name];
180
+ if (!data) continue;
181
+ actual += data.length;
182
+ if (actual > limits.maxTotalBytes) {
183
+ throw new UploadError(
184
+ `The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
185
+ "Send only the document and the figures it cites."
186
+ );
187
+ }
188
+ if (files[safe]) warnings.push(`two entries both unpack to ${safe}; kept the last`);
189
+ files[safe] = data;
190
+ }
191
+ return { files, warnings };
192
+ }
193
+ function pickMarkdown(files) {
194
+ const candidates = Object.keys(files).filter(
195
+ (p) => MARKDOWN_EXTS.includes(posix.extname(p).toLowerCase())
196
+ );
197
+ if (candidates.length === 0) {
198
+ throw new UploadError(
199
+ `The archive has no ${MARKDOWN_EXTS.join(", ")} file in it.`,
200
+ "DeckSmith reads a markdown document. Add the .md next to your figures and zip the folder again."
201
+ );
202
+ }
203
+ const rank = (p) => {
204
+ const base = posix.basename(p).toLowerCase();
205
+ const named = /^(readme|index|main|paper|analysis)\./.test(base) ? 0 : 1;
206
+ return [p.split("/").length, named, p];
207
+ };
208
+ return candidates.sort((a, b) => {
209
+ const [da, na, pa] = rank(a);
210
+ const [db, nb, pb] = rank(b);
211
+ return da - db || na - nb || pa.localeCompare(pb);
212
+ })[0];
213
+ }
214
+ function isNoise(name) {
215
+ return name.startsWith("__MACOSX/") || name.includes("/__MACOSX/") || posix.basename(name) === ".DS_Store" || posix.basename(name) === "Thumbs.db";
216
+ }
217
+ function mb(bytes) {
218
+ return `${Math.round(bytes / 1024 / 1024)} MB`;
219
+ }
220
+ export {
221
+ MARKDOWN_EXTS,
222
+ MAX_UPLOAD_BYTES,
223
+ UploadError,
224
+ ZIP_LIMITS,
225
+ insideRoot,
226
+ looksLikeZip,
227
+ parseMultipart,
228
+ pickMarkdown,
229
+ readBody,
230
+ readZip,
231
+ safeEntryPath
232
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ /** One entry of the slideshow island's `slides` array. */
2
+ export interface SlideSpec {
3
+ sceneId: string;
4
+ /** Absolute seconds. Present when the island lives outside the composition. */
5
+ startTime?: number;
6
+ endTime?: number;
7
+ /** Absolute positions on the deck timeline, inside this slide's window. */
8
+ fragments?: number[];
9
+ notes?: string;
10
+ }
11
+ export interface Stop {
12
+ /** Absolute position on the deck timeline, in seconds. */
13
+ t: number;
14
+ /** 0-based, over the slides that could be placed. */
15
+ slide: number;
16
+ /** 0 is the slide itself; 1..n are its fragments in time order. */
17
+ fragment: number;
18
+ notes: string;
19
+ /**
20
+ * The scene this stop belongs to. Carried through because it is the only id
21
+ * shared with the narration island — `slide` is a position over the slides we
22
+ * could place, which shifts the moment one of them is unplaceable.
23
+ */
24
+ sceneId: string;
25
+ }
26
+ export interface Pos {
27
+ slide: number;
28
+ fragment: number;
29
+ }
30
+ /**
31
+ * Flatten the island into the ordered list of positions a presenter steps
32
+ * through: each slide's start, then each of its fragments.
33
+ *
34
+ * Placement comes from the island alone. This code only ever runs in the wrapper
35
+ * page, where the scene divs are inside the player's iframe and unreachable — so
36
+ * `emitIsland` always writes `startTime`/`endTime`, and there is nothing to
37
+ * scrape from the DOM.
38
+ */
39
+ export declare function buildStops(slides: readonly SlideSpec[]): Stop[];
40
+ export interface TransitionPlan {
41
+ animate: boolean;
42
+ /** Wall-clock milliseconds to spend. 0 whenever `animate` is false. */
43
+ durationMs: number;
44
+ }
45
+ /**
46
+ * Decide whether a step plays or cuts. Pure, so the policy is testable without
47
+ * a DOM; the rAF loop that obeys it is not.
48
+ */
49
+ export declare function planTransition(fromT: number, toT: number, opts?: {
50
+ reducedMotion?: boolean;
51
+ }): TransitionPlan;
52
+ /** `#3` is slide 3; `#3.2` is slide 3, second fragment. Both 1-based. */
53
+ export declare function formatHash(pos: Pos): string;
54
+ export declare function parseHash(hash: string): Pos | null;
55
+ /**
56
+ * Index of `pos` in the stop list. A deep link that names a fragment we no
57
+ * longer emit falls back to its slide rather than to nothing.
58
+ */
59
+ export declare function findStop(stops: readonly Stop[], pos: Pos): number;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The narration island, and the two questions the presented deck asks of it.
3
+ *
4
+ * A narrated deck carries a second JSON island beside the slideshow one: same
5
+ * page, same shape of thing, different reader. It is separate rather than folded
6
+ * into the slideshow manifest because that manifest is HyperFrames' format, not
7
+ * ours — adding a key to it is a bet on a schema someone else owns.
8
+ *
9
+ * Everything here is pure, and deliberately so. Playback is untestable without a
10
+ * browser; "which cue is on screen at t" and "which segment belongs to this
11
+ * stop" are the parts that can actually be wrong, so they live apart from the
12
+ * audio element and are tested directly.
13
+ *
14
+ * Read defensively throughout. A deck built before narration existed has no
15
+ * island at all, and a deck built by a newer emitter may carry fields this
16
+ * reader has never heard of; neither may do anything worse than fall silent.
17
+ */
18
+ /** Selector for the island. Not `+json` alone — the slideshow one is that too. */
19
+ export declare const NARRATION_ISLAND = "script[type=\"application/decksmith-narration+json\"]";
20
+ /** One subtitle line, in seconds from the start of its own segment's audio. */
21
+ export interface Cue {
22
+ start: number;
23
+ end: number;
24
+ text: string;
25
+ }
26
+ /**
27
+ * What is spoken at one stop.
28
+ *
29
+ * `stop` matches `Stop.fragment`: 0 is the slide's landing, 1..n its reveals.
30
+ * The contract's `seconds` is not read here — the audio element's own clock is
31
+ * the authority, and a measured number we then ignore is a number that can drift
32
+ * out of agreement with the file without anything noticing.
33
+ */
34
+ export interface Segment {
35
+ stop: number;
36
+ /** Resolved against `Narration.dir`; see `audioSrc`. */
37
+ audio: string;
38
+ cues: Cue[];
39
+ }
40
+ export interface Narration {
41
+ voice: string;
42
+ /** Directory the segment paths hang off, relative to the deck page. May be "". */
43
+ dir: string;
44
+ /** Keyed by scene id — `s1`, `s2` — the same ids the slideshow island carries. */
45
+ scenes: Record<string, Segment[]>;
46
+ }
47
+ /**
48
+ * The longest cue we will put on screen at once: two lines of about 42
49
+ * characters, which is broadcast practice and roughly what the band's width
50
+ * gives at its clamped font size.
51
+ *
52
+ * edge-tts emits one cue per SENTENCE, and a long sentence produces a long cue —
53
+ * 121 characters on a single wrapped block was measured on the demo deck. That
54
+ * is three lines, and three lines is a band tall enough to cover the bottom of
55
+ * the slide however much room the composition reserves. Capping here rather than
56
+ * at synthesis time means a deck built before this existed is fixed by reloading
57
+ * it, without re-narrating anything.
58
+ */
59
+ export declare const CUE_MAX_CHARS = 84;
60
+ /**
61
+ * One cue, wrapped to at most `max` characters a piece, with the original
62
+ * duration divided between the pieces in proportion to their length.
63
+ *
64
+ * Proportional rather than equal because the reading time a chunk needs tracks
65
+ * its length, and because the speech under it does too — the split lands close
66
+ * to where the voice actually is. A word longer than `max` becomes its own
67
+ * piece rather than being broken mid-word.
68
+ *
69
+ * EVEN, not greedy. Packing straight to `max` fills the first piece and dumps
70
+ * whatever is left into the last one, and the duration split is proportional,
71
+ * so a 90-character cue came out as an 84-character caption followed by a
72
+ * SINGLE WORD on screen for 0.3 seconds. Six of the demo deck's forty-nine cues
73
+ * flashed a one-word caption that way — under every broadcast minimum, and
74
+ * clearly wrong the moment anyone watched the video. So the piece COUNT is
75
+ * decided first, from `max`, and then the narrowest width that still fits in
76
+ * that many pieces does the packing: same number of captions, no orphan.
77
+ */
78
+ export declare function splitCue(cue: Cue, max?: number): Cue[];
79
+ /**
80
+ * Parse the island's text. Anything malformed reads as "no narration", which is
81
+ * the deck we shipped yesterday and is always a safe answer.
82
+ */
83
+ export declare function parseNarration(text: string | null | undefined): Narration | null;
84
+ /**
85
+ * The cue on screen at `t`, or null in a gap.
86
+ *
87
+ * Half-open: `[start, end)`. A cue ending exactly where the next begins must
88
+ * hand over cleanly, and closing both ends would put two lines on screen for one
89
+ * frame at every sentence boundary — which is every boundary edge-tts emits.
90
+ */
91
+ export declare function activeCue(cues: readonly Cue[], t: number): Cue | null;
92
+ /** The segment spoken at one stop, or null where the deck is silent. */
93
+ export declare function segmentFor(narration: Narration | null, sceneId: string, stop: number): Segment | null;
94
+ /**
95
+ * Where the segment's audio actually lives, relative to the deck page.
96
+ *
97
+ * `dir` is a convenience so the emitter can write bare filenames; a segment path
98
+ * that is already absolute or already a URL is left exactly as it is, because
99
+ * prefixing one would break a deck whose audio is hosted rather than baked.
100
+ */
101
+ export declare function audioSrc(narration: Narration, segment: Segment): string;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The source's own figure, with the explanation drawn onto it.
3
+ *
4
+ * `claim-figure` parks a screenshot beside a sentence and leaves the viewer to
5
+ * find the part being talked about. This archetype removes that search: each
6
+ * note is a dot on the exact pixel, a leader out to the nearest margin, and a
7
+ * label sitting on a coloured rule — revealed one at a time, so the figure is
8
+ * read in the order the argument needs rather than all at once.
9
+ *
10
+ * `notes[].x/y` are fractions of the figure, never pixels, so the overlay is
11
+ * solved in the figure's own space and stays correct at any layout size. That is
12
+ * only true if the displayed image box is known exactly, which is why the `<img>`
13
+ * gets explicit pixel dimensions derived here rather than `max-width:100%`: a box
14
+ * the browser chooses is a box this file cannot annotate.
15
+ *
16
+ * Margins either side of the figure is a landscape idea. In portrait two of them
17
+ * cost two thirds of an 860px stage, and the demo's figure came out 232px wide —
18
+ * a postage stamp in a tall black frame, which is the one thing a slide whose
19
+ * whole subject is a figure may not be. So portrait puts the figure across the
20
+ * full width and stacks the labels underneath it in two columns instead. The
21
+ * device is unchanged either way: a dot on the pixel, a leader out to a rule, and
22
+ * the label sitting on it. Only which way "out" points has moved.
23
+ */
24
+ import type { Format } from "../../types.js";
25
+ import type { Emitter } from "../kit.js";
26
+ import type { Box, Pt } from "../svg.js";
27
+ export interface FigureNote {
28
+ x: number;
29
+ y: number;
30
+ text: string;
31
+ }
32
+ export interface NoteBox {
33
+ /** Which margin this label was given. */
34
+ side: "l" | "r";
35
+ lines: string[];
36
+ /**
37
+ * Stage-space x of the rule's inner end — the one the leader arrives at, with
38
+ * the label running outward from it.
39
+ *
40
+ * Solved here rather than in the emitter because it is the one number the two
41
+ * arrangements disagree about: beside the plate in landscape, either side of
42
+ * the centre channel under the figure in portrait.
43
+ */
44
+ inner: number;
45
+ /** Widest line — also the length of the rule the label sits on. */
46
+ w: number;
47
+ h: number;
48
+ /** Stage-space top of the text block. */
49
+ top: number;
50
+ /** Stage-space y of the rule, where the leader arrives from the dot. */
51
+ ruleY: number;
52
+ /** The annotated point, in stage space. */
53
+ at: Pt;
54
+ clipped: boolean;
55
+ }
56
+ export interface FigureLayout {
57
+ /** The image box in stage space. The plate is this grown by `PLATE` a side. */
58
+ img: Box;
59
+ /** Width available to a label in either margin. */
60
+ col: number;
61
+ /**
62
+ * What the stage actually needs, which is at most the budget it was solved
63
+ * against. A 4:1 strip uses 230px of a 726px budget, and a stage left at the
64
+ * budget puts a 250px dead band above and below the only thing on the slide.
65
+ */
66
+ height: number;
67
+ boxes: NoteBox[];
68
+ /** False when a label had to be clipped or a stack had to overlap. */
69
+ ok: boolean;
70
+ }
71
+ /**
72
+ * Size the figure and place its labels.
73
+ *
74
+ * Exported because this is the one thing here that can be wrong without looking
75
+ * wrong in a diff: a label half a pixel past the stage, or two overlapping in a
76
+ * stack, is a property worth asserting directly rather than through rendered SVG.
77
+ */
78
+ export declare function planFigure(stageW: number, notes: FigureNote[], fig: {
79
+ width: number;
80
+ height: number;
81
+ }, stageH: number, tall?: boolean): FigureLayout;
82
+ /**
83
+ * Height the stage may spend, once the chrome and the caption have taken theirs.
84
+ * A budget, not a size — `planFigure` gives back only what it used.
85
+ *
86
+ * TAKES THE HEADLINE, because the chrome's height is a fact about the headline.
87
+ * This charged a flat `HEAD_H` — one line — however many the headline wrapped to,
88
+ * so a three-line headline went 174px unaccounted and pushed the stage down
89
+ * until the caption rendered 81px BELOW the canvas and a note's second line 41px
90
+ * below. `chromeHeight` is the same measurement every other archetype makes.
91
+ *
92
+ * AND IT NO LONGER FLOORS AT 360, which was the same fix half-made. Charging the
93
+ * headline correctly stopped the caption falling off the canvas and left it
94
+ * being CRUSHED instead: under a three-line headline the honest remainder is
95
+ * 299px, the floor handed back 360, the stage took 345 of it, and `.af-cap` —
96
+ * a flex child that may shrink — was squeezed to a 1.7px box with
97
+ * `-webkit-line-clamp` quietly eating the text. `clipped_text` and
98
+ * `text_box_overflow`, on a slide whose provenance line is invisible. A floor
99
+ * that reports more room than exists does not create room; it only moves which
100
+ * gate notices.
101
+ */
102
+ export declare function stageBudget(format: Format, eyebrow: string | undefined, headline: string, caption: string): number;
103
+ export declare const annotatedFigure: Emitter<"annotated-figure">;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Magnitudes, drawn.
3
+ *
4
+ * A table of figures makes the viewer do arithmetic: "1.592 against 0.930" is two
5
+ * numbers and a subtraction. The same pair as two bars is one glance. So nothing
6
+ * here is decoration — the bar lengths carry the entire claim, which is why the
7
+ * scale is derived from the data and anchored at zero. A bar chart whose baseline
8
+ * is not zero is a chart that asserts a ratio the numbers do not support, and it
9
+ * would pass every gate we have.
10
+ *
11
+ * Three decisions worth knowing before changing anything:
12
+ *
13
+ * 1. Every bar sits in a full-width rail. The rail is what makes a dwarfed bar
14
+ * legible (see MIN_LEN) — the eye reads "almost none of the extent" rather
15
+ * than "nothing was drawn".
16
+ * 2. Values count up rather than fade in, and they count on the same decimal grid
17
+ * their printed string is on, so the number GSAP lands on is byte-identical to
18
+ * the one in the static SVG.
19
+ * 3. The bars are one reveal, not N. Holding on bar 3 of 8 asks the viewer to
20
+ * compare it against bars that have not been drawn yet.
21
+ *
22
+ * A label gutter beside the rails is a landscape idea. At 9:16 the demo's five
23
+ * names took 340px of an 860px box, so 40% of the width was spent naming bars
24
+ * that then had 60% left to differ in. Portrait puts the label on its own line
25
+ * above the rail instead and hands the whole width to the comparison — which is
26
+ * also where the height it needs comes from, since portrait has height to spare
27
+ * and width it does not.
28
+ */
29
+ import type { Emitter } from "../kit.js";
30
+ export declare const barCompare: Emitter<"bar-compare">;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Labelled panels side by side — the archetype for the things a paper does not
3
+ * put in a figure: a contradiction between two tables, a caveat, a limit on what
4
+ * was actually tested. Panels appear one at a time so each can be spoken to.
5
+ */
6
+ import type { Emitter } from "../kit.js";
7
+ export declare const callout: Emitter<"callout">;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A claim from the source, shown next to the figure that backs it.
3
+ *
4
+ * The layout is chosen from the figure's own aspect ratio rather than being a
5
+ * parameter: EXPERIMENT-002 full-bled a 1.98-aspect figure and pushed its
6
+ * caption 200px off-canvas. Only a genuine strip earns the full width.
7
+ */
8
+ import type { Emitter } from "../kit.js";
9
+ export declare const claimFigure: Emitter<"claim-figure">;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A table from the source, revealed row by row, then read for the viewer: the
3
+ * highlighted rows light up in the order the argument needs them.
4
+ *
5
+ * Nothing here invents numbers, and nothing here drops them either: every row of
6
+ * the named table is drawn, at 40px or above, or the beat is refused. The type
7
+ * is solved from the WIDTH — upwards from the 40px floor, capped — and the height
8
+ * rule then asks whether those rows, drawn at that size, fit on the canvas. The
9
+ * one lever the height rule owns is the row padding, and it is already shut to
10
+ * `PAD_Y_MIN` whenever this refuses, so "will not fit" is a measurement of this
11
+ * slide rather than a preference about it.
12
+ *
13
+ * WHAT IT DELIBERATELY DOES NOT DO: buy rows by taking the type back down toward
14
+ * 40. That would be legal — invariant 5 is a floor, not a fixed size, and a table
15
+ * declined here at 52px would often fit at 40 — but it makes `cell` solve two
16
+ * constraints at once, and the width solve is the one that has been measured. So
17
+ * the refusal names the size it refused AT, and the choice stays visible to
18
+ * whoever reads the error instead of being buried in this file.
19
+ */
20
+ import type { Emitter } from "../kit.js";
21
+ export declare const dataTable: Emitter<"data-table">;
@@ -0,0 +1,2 @@
1
+ import type { Emitter } from "../kit.js";
2
+ export declare const equationWalk: Emitter<"equation-walk">;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A field, and the windows that move over it.
3
+ *
4
+ * Attention spans, receptive fields, patches, pooling kernels, token sequences —
5
+ * every paper draws these as a rectangle over a lattice, and every slide deck
6
+ * then writes "8x8 shifted windows" in a bullet and calls it explained. The
7
+ * content of the idea is *which cells* and *how many of them*, and a sentence
8
+ * cannot show that; a lattice with the rectangle lit on it shows nothing else.
9
+ *
10
+ * Everything here is solved from the box that is actually free after the chrome
11
+ * and the note, so a 24x16 feature map and a 2x1 pair of patches are the same
12
+ * code. A constant cell size would be right for one grid and wrong for the other
13
+ * twenty-three, and nobody would ever find out which.
14
+ */
15
+ import type { Emitter } from "../kit.js";
16
+ export declare const grid: Emitter<"grid">;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The explanatory vocabulary, indexed by archetype.
3
+ *
4
+ * The mapped type is the point: adding an archetype to `Beat` without an emitter
5
+ * beside it fails to compile, which is the only guarantee that keeps the union in
6
+ * `types.ts` honest.
7
+ */
8
+ import type { Archetype, Beat } from "../../types.js";
9
+ import type { EmitContext, Emitter, Scene } from "../kit.js";
10
+ export declare const emitters: {
11
+ [A in Archetype]: Emitter<A>;
12
+ };
13
+ /**
14
+ * Dispatch a beat to its emitter. The cast is the one place the pairing is taken
15
+ * on trust: `emitters[beat.archetype]` is a union of twelve emitters and TypeScript
16
+ * will not narrow the key and the beat together. The table above already proves
17
+ * every archetype has exactly one emitter of the right shape.
18
+ */
19
+ export declare function emitScene(beat: Beat, ctx: EmitContext): Scene;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A chart the source never drew.
3
+ *
4
+ * Papers report sweeps as tables and leave the shape of the curve to the reader.
5
+ * Drawing it is the whole point of this archetype, so everything here is derived
6
+ * from the data — the scale especially. A hardcoded axis is how a chart ends up
7
+ * asserting something the numbers do not.
8
+ */
9
+ import type { Emitter } from "../kit.js";
10
+ export interface Scale {
11
+ min: number;
12
+ max: number;
13
+ step: number;
14
+ decimals: number;
15
+ }
16
+ /**
17
+ * A round-numbered scale that provably contains every value. Exported because
18
+ * "the axis spans the data" is the one property a chart must never get wrong,
19
+ * and it is worth asserting directly rather than through rendered SVG.
20
+ */
21
+ export declare function chartScale(values: number[]): Scale;
22
+ export declare const lineChart: Emitter<"line-chart">;