@jokerized/decksmith 0.3.2 → 0.4.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.
@@ -1,3 +1,4 @@
1
+ import { type Narration } from "./subtitles.js";
1
2
  /** One entry of the slideshow island's `slides` array. */
2
3
  export interface SlideSpec {
3
4
  sceneId: string;
@@ -49,6 +50,32 @@ export interface TransitionPlan {
49
50
  export declare function planTransition(fromT: number, toT: number, opts?: {
50
51
  reducedMotion?: boolean;
51
52
  }): TransitionPlan;
53
+ /**
54
+ * How long autoplay waits on the stop it is on, or `null` for "do not set a
55
+ * timer". Pure, for the same reason `planTransition` and `refused` are: the
56
+ * decision has two callers and lives inside `start`, which no test in this
57
+ * project can reach.
58
+ *
59
+ * `heard` is the whole point of the split. A narrated stop is timed by its own
60
+ * audio — speech drives the deck, which is this project's entire timing model —
61
+ * so it waits for `ended` and wants no timer. But `voice.at` answers
62
+ * synchronously about whether a segment EXISTS, while `play()` rejects a beat
63
+ * later, so at arrival a stop whose file is missing is indistinguishable from a
64
+ * working one. Ask again when `play()` settles and the answer can flip either
65
+ * way: to `false`, and the stop needs the clock after all or autoplay waits
66
+ * forever for an `ended` that a source which never loaded cannot fire; or back
67
+ * to `true`, when a gesture retried the segment and it is speaking now, and the
68
+ * timer armed while it was silent would cut the sentence it just started.
69
+ *
70
+ * `gapMs` is the gap the author left before the next stop, the same number the
71
+ * linear render used, clamped so neither a back-to-back pair nor a long hold
72
+ * turns into a bad wait.
73
+ */
74
+ export declare function dwellMs(opts: {
75
+ playing: boolean;
76
+ heard: boolean;
77
+ gapMs: number;
78
+ }): number | null;
52
79
  /** `#3` is slide 3; `#3.2` is slide 3, second fragment. Both 1-based. */
53
80
  export declare function formatHash(pos: Pos): string;
54
81
  export declare function parseHash(hash: string): Pos | null;
@@ -78,6 +105,106 @@ interface Frame {
78
105
  * cannot drive it. `present()` says so out loud rather than rendering blank.
79
106
  */
80
107
  export declare function frameOf(player: Player): Frame | null;
108
+ export interface Voice {
109
+ /**
110
+ * Arrive at a stop: cut whatever was speaking, start this stop from zero.
111
+ * Returns whether this stop has anything to say — autoplay needs to know,
112
+ * because a silent stop has no `ended` event to wait for and would otherwise
113
+ * be where playback quietly stops forever.
114
+ */
115
+ at: (stop: Stop) => boolean;
116
+ /**
117
+ * Stop talking without arriving anywhere. The deck has ONE audio track and
118
+ * `claim-figure` already spends it on narration — see its `muted` note — so
119
+ * anything else that wants to make a sound has to take it first.
120
+ */
121
+ hush: () => void;
122
+ toggleMute: () => void;
123
+ toggleSubtitles: () => void;
124
+ /** Called when the segment for the CURRENT stop finishes of its own accord. */
125
+ onEnded: (fn: () => void) => void;
126
+ /**
127
+ * Called when the CURRENT stop's `play()` settles, with whether there is going
128
+ * to be a sound. Autoplay needs it because `at` answers one beat too early:
129
+ * `at` says a segment EXISTS, and `play()` says whether it can be heard.
130
+ *
131
+ * BOTH answers matter. False means no `ended` will ever fire, so a deck
132
+ * playing itself must fall back to its own clock or sit on that slide forever
133
+ * with the button lit. True is how it gives that clock back — a segment
134
+ * retried after a dropped connection is speaking now, and a timer armed while
135
+ * it was silent would cut the sentence it just started.
136
+ */
137
+ onSettled: (fn: (heard: boolean) => void) => void;
138
+ /**
139
+ * A real gesture: retry the segment for the stop we are on. Covers the
140
+ * autoplay policy, which is what it was written for, and a file that failed to
141
+ * load — see `refused` for why the second one is never retried unasked.
142
+ */
143
+ unlock: () => void;
144
+ }
145
+ /**
146
+ * Was that `play()` rejection the autoplay policy, or an audio file we will
147
+ * never be able to play? Pure, so the classification is testable without a
148
+ * browser; the element that produces the rejection is not.
149
+ *
150
+ * The two need telling apart because they are not the same failure and must not
151
+ * be reported as one. The policy is recoverable by construction: the first
152
+ * gesture retries the segment and it speaks, so "press any key for sound" is
153
+ * simply true. A file that did not load MIGHT be — a two-second wifi drop and a
154
+ * one-off 503 look exactly like this from here — but it is just as likely a deck
155
+ * whose audio directory never got copied, and telling THAT viewer to press a key
156
+ * names the wrong culprit and goes on being wrong every time they try.
157
+ *
158
+ * So this split decides what the strip says, and whether anything retries
159
+ * unasked. It does NOT decide whether a person may retry: a gesture re-arms
160
+ * either failure (see `unlock`), because someone asking is not the same as us
161
+ * guessing. And no gate here can see any of it — nothing in the suite opens
162
+ * deck.html or plays a sound.
163
+ *
164
+ * `NotSupportedError` is the unplayable one — observed rather than taken from
165
+ * the spec: a source that cannot be fetched at all rejects `play()` with
166
+ * `NotSupportedError` and leaves `audio.error.code` at 4
167
+ * (`MEDIA_ERR_SRC_NOT_SUPPORTED`), and so does one that is fetched but is not
168
+ * media.
169
+ *
170
+ * Be honest about what that is worth. It is ONE run, on this machine, in a
171
+ * headless browser whose engine and version nobody wrote down, and that run
172
+ * could not show the other side of the split at all: the build played an
173
+ * ungestured sound even under
174
+ * `--autoplay-policy=document-user-activation-required`, so no `NotAllowedError`
175
+ * ever arrived to compare against. Nothing here has watched the two failures
176
+ * come out of the same browser. Treat "a missing segment reads as missing on the
177
+ * first attempt" as what that one run did, not as a cross-engine guarantee.
178
+ *
179
+ * Everything else FAILS SAFE to the policy, `NotAllowedError` included, and that
180
+ * is exactly what makes such thin evidence affordable: rejection names are
181
+ * per-engine, and an unknown one degrading to what ships today is the smaller
182
+ * mistake — "press any key" over a file we could have played still recovers on
183
+ * the first gesture, whereas the other way round leaves a recoverable deck
184
+ * permanently silent.
185
+ */
186
+ export declare function refused(err: unknown): boolean;
187
+ /**
188
+ * Speech and subtitles for one presented deck.
189
+ *
190
+ * Two rules earn their own object. First, leaving a stop must silence it
191
+ * immediately by every route — arrow, click, Home/End, hashchange — so all of
192
+ * them funnel through `at`, which stops before it starts; two sentences talking
193
+ * over each other is worse than no narration at all.
194
+ *
195
+ * Second, cues are timed against `audio.currentTime` and nothing else. A timer
196
+ * started alongside `play()` agrees with the audio right up until the first
197
+ * stall, and then never again — and a stall is exactly when a viewer is looking
198
+ * at the subtitle to find out what they missed.
199
+ *
200
+ * Exported, with `Voice`, only so a test can drive its failure paths: everything
201
+ * that matters below happens after `play()` rejects, and nothing else in this
202
+ * module can hand it that promise. `start` is the only caller.
203
+ */
204
+ export declare function mountVoice(doc: Document, narration: Narration, ui: {
205
+ subs: HTMLElement;
206
+ flags: HTMLElement;
207
+ }): Voice;
81
208
  /** One player-page video, keyed in the island by the scene that draws its still. */
82
209
  export interface ClipSpec {
83
210
  /** Already in embeddable form — `embedUrl` in src/pack/media.ts did that at build time. */
@@ -13,12 +13,20 @@
13
13
  *
14
14
  * Three things here are load-bearing and each of them cost a render to learn:
15
15
  *
16
- * 1. THE MOVE IS TWO `fromTo`s AND NOTHING ELSE. `seek()` passes
17
- * `suppressEvents`, so a GSAP `onUpdate` never fires under capture. The first
18
- * camera in experiment 008 drove `viewBox` from an `onUpdate`, played
19
- * perfectly in a browser, and rendered 900 frames of a frozen `0 0 1920 1080`
20
- * with every gate green (invariant 11). State must be applied by the thing
21
- * being seeked.
16
+ * 1. THE MOVE IS TWO `fromTo`s AND NOTHING ELSE. The first camera in experiment
17
+ * 008 drove `viewBox` from an `onUpdate`, played perfectly in a browser, and
18
+ * rendered 900 frames of a frozen `0 0 1920 1080` with every gate green.
19
+ *
20
+ * THAT RECORD STANDS; THE MECHANISM IT WAS ATTRIBUTED TO DOES NOT. `seek()`
21
+ * passing `suppressEvents` is why `decksmith frames` sees nothing, but the
22
+ * render does not seek — capture runs off Chrome's `beginFrame`, and both
23
+ * constructions were re-rendered on 2026-09-04 and both ramped smoothly. What
24
+ * a callback actually costs is reproducibility: 11 differing frames of 3,120
25
+ * on the demo as built, 260 with one `onUpdate` tween added. Whatever froze
26
+ * experiment 008's camera, it was not measured, and this file should not keep
27
+ * asserting it. The rule is unchanged and so is the reason for it — state must
28
+ * be applied by the thing being seeked. See invariant 11 in AGENTS.md and
29
+ * `.planning/2026-09-04-invariant-11-under-beginframe.md`.
22
30
  *
23
31
  * 2. THE EASES ARE CLOSED FORM AND THEY ARE NOT DECORATION. `zoomEase` travels
24
32
  * scale in log space — a linear scale tween reads as a lurch, and every
@@ -67,6 +67,19 @@ export interface DeckOptions {
67
67
  * failure shape.
68
68
  */
69
69
  onBeatError?: (beatId: string, err: Error) => void;
70
+ /**
71
+ * What to do when a beat IS drawn but not as it was authored.
72
+ *
73
+ * The other half of `onBeatError`, and the reason it is a second hook rather
74
+ * than a second call to that one: this beat is IN the deck. An emitter that
75
+ * drops an ornament to fit its beat — see `Scene.warnings` — says so here, and
76
+ * absent this hook it says it to nobody, which is the silence the whole
77
+ * mechanism exists to avoid.
78
+ *
79
+ * Called once per built scene, from `layout`. `planCut` emits every beat as
80
+ * well and stays quiet: its scenes are thrown away.
81
+ */
82
+ onBeatWarning?: (beatId: string, warning: string) => void;
70
83
  /**
71
84
  * The subsetted bundle's `@font-face` CSS, INLINED rather than linked.
72
85
  *
@@ -311,20 +311,45 @@ export interface Scene {
311
311
  * off, against 7 frames of antialiasing once deferred. Both failures are
312
312
  * invisible to every gate. `cameraMeasure` carries the numbers and the controls.
313
313
  *
314
- * INVARIANT 11 IS THE TRAP HERE. This is not a callback on a tween. `seek()`
315
- * passes `suppressEvents`, so measuring from an `onUpdate` — the obvious way to
316
- * "measure late" renders a frozen video with every gate green. Measurement
317
- * happens before the timeline exists, and what it produces is ordinary tween
318
- * values.
314
+ * INVARIANT 11 IS THE TRAP HERE. This is not a callback on a tween. Measuring
315
+ * from an `onUpdate` — the obvious way to "measure late" — is what the
316
+ * invariant forbids, and the reason is reproducibility rather than the frozen
317
+ * video an older version of this comment claimed: measured 2026-09-04, one
318
+ * `onUpdate` tween took the demo from 11 non-reproducible frames of 3,120 to
319
+ * 260. See AGENTS.md invariant 11. Measurement here happens before the
320
+ * timeline exists, and what it produces is ordinary tween values.
319
321
  */
320
322
  measure?: string[];
321
323
  /**
322
324
  * Vendored runtimes this scene's tweens need registered before its script
323
- * runs — `"dsMorph"` for the equation morph. The shell loads a runtime only
324
- * when some scene names it, so a deck that names none is byte-for-byte what
325
- * it was.
325
+ * runs — `"dsMorph"` for the equation morph, `"morphSVG"` for a reshape. The
326
+ * shell loads a runtime only when some scene names it, so a deck that names
327
+ * none is byte-for-byte what it was.
328
+ *
329
+ * `PLUGINS` in `composition.ts` is the ONE place a name resolves, and it is
330
+ * the list to read before returning this field: the type is
331
+ * `readonly string[]`, so a name that table does not know is a typo no
332
+ * compiler can see, and the shell refuses it there rather than emitting a
333
+ * scene whose plugin tween animates nothing.
326
334
  */
327
335
  plugins?: readonly string[];
336
+ /**
337
+ * WHAT THE ARCHETYPE HAD TO GIVE UP TO DRAW THIS BEAT, one sentence each.
338
+ *
339
+ * The middle answer between drawing the beat as asked and refusing it. An
340
+ * emitter that cannot fit everything it was given has three moves: draw it
341
+ * anyway and let it collide or truncate, which is the failure this project
342
+ * keeps finding by eye; throw, which reaches `onBeatError` and costs the whole
343
+ * SLIDE; or draw less and say so, which is this. Reach for it when the thing
344
+ * dropped is an ORNAMENT — line-chart's reshape against a baseline, say —
345
+ * and for the throw when what is left would misrepresent the source.
346
+ *
347
+ * Surfaced by `layout` through `DeckOptions.onBeatWarning`, once per built
348
+ * scene. `planCut`'s measuring pass emits every beat too and deliberately does
349
+ * not report these: it throws its scenes away, and the same sentence twice per
350
+ * beat trains people to stop reading it.
351
+ */
352
+ warnings?: readonly string[];
328
353
  /**
329
354
  * Hold points in seconds from the scene's start — where a presenter should
330
355
  * pause. The shell converts these to absolute island fragment times.
@@ -69,6 +69,33 @@ export declare function nv(v: number): number;
69
69
  */
70
70
  export declare const DRAW_FROM: Vars;
71
71
  export declare const DRAW_TO: Vars;
72
+ /**
73
+ * ONE SHAPE BECOMES ANOTHER — a baseline curve lifting off its own position and
74
+ * settling into the result, leaving whatever was drawn under it behind.
75
+ *
76
+ * The from-vars are SELF-REFERENTIAL: `{ morphSVG: { shape: target } }` means
77
+ * "start from the `d` you were authored with". That is the construction
78
+ * `experiments/013-vocabulary/gaps/spike/index.html:147` measured, and it is the
79
+ * only one that keeps invariant 2 checkable by `tsc` — MorphSVG's natural
80
+ * spelling is a bare `to`, which is a `from()` wearing different clothes.
81
+ * `_parseShape` reads `data-original` when the shape names the target itself
82
+ * (MorphSVGPlugin.js), so the start is the authored path however many times the
83
+ * tween is re-initialised under a seek.
84
+ *
85
+ * `to` NAMES A SELECTOR, so the end shape lives in the document as a hidden
86
+ * sibling rather than as a path string in the timeline. Two copies of the same
87
+ * geometry — one in the `d` attribute, one in the tween — is the duplication
88
+ * `drawFrom` was retired for.
89
+ *
90
+ * Safe under capture for the reason DrawSVG is: a plugin's `render()` is part of
91
+ * being seeked where a callback is not. Measured for THIS plugin in a deck, not
92
+ * inherited from that one — `.planning/2026-09-08-reshape-seam.md`.
93
+ *
94
+ * ONE CALL PER (ELEMENT, MORPH). `first` is false for any reshape that is not
95
+ * the element's first on `morphSVG`; a second immediate render on one property
96
+ * would establish its start state at build time and undo the first.
97
+ */
98
+ export declare function reshape(target: string, to: string, at: number, seconds: number, first: boolean): Tween;
72
99
  /**
73
100
  * Something travels a polyline — a pulse along an arrow, a marker ring along a
74
101
  * curve, a highlight down a divider: one `x`/`y` `fromTo` per leg, each leg's
@@ -166,6 +166,19 @@ export type { AssetRequest, Fetcher, Media, MediaPlan } from "./pack/media.js";
166
166
  */
167
167
  export { CONFIG_FILE, loadPrefs } from "./prefs.js";
168
168
  export type { Prefs, PrefsPatch } from "./prefs.js";
169
+ /**
170
+ * The `TMPDIR` guard, exported because `src/server/main.ts` has no other way to
171
+ * reach it: `build:server` transpiles without bundling, so a deep import there
172
+ * would survive into `dist/server/` and resolve to nothing. It fails the house
173
+ * rule for this file — nobody outside asked for it — and it is here anyway,
174
+ * because the alternative is a second copy of the guard living under `server/`.
175
+ *
176
+ * A CONSUMER SHOULD NEVER NEED TO CALL IT. It is a no-op in a published install
177
+ * (see src/tmpdir.ts for why), and importing this module does not run it: an
178
+ * absent `TMPDIR` is inherited by every child process, which is not something a
179
+ * library import may decide for its host.
180
+ */
181
+ export { guardTmpdir } from "./tmpdir.js";
169
182
  /**
170
183
  * Every schema and type. Unusually broad for this file, and justified: `types.ts`
171
184
  * is nothing but the wire format — what `source.json`, `storyboard.json`,
@@ -197,6 +210,12 @@ export interface BuildDeckOptions {
197
210
  * Absent, the emitter's error propagates — see `DeckOptions.onBeatError`.
198
211
  */
199
212
  onBeatError?: (beatId: string, err: Error) => void;
213
+ /**
214
+ * Called when a beat is drawn but not as it was authored — an emitter dropped
215
+ * an ornament to fit the beat's length. The beat is IN the deck; nothing is
216
+ * missing but the ornament. See `DeckOptions.onBeatWarning`.
217
+ */
218
+ onBeatWarning?: (beatId: string, warning: string) => void;
200
219
  /** Any name in `THEME_NAMES`. Overrides `storyboard.theme`. */
201
220
  theme?: string;
202
221
  /** Multiplies every duration and hold. 1 leaves the bytes untouched. */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The directory holding our own `package.json`, found by RESOLVING it rather
3
+ * than by counting `..` segments off `import.meta.url`.
4
+ *
5
+ * The positional form gives the same answer everywhere this module can land
6
+ * today — `src/` under vitest, and the depth-one bundles `dist/cli.js`,
7
+ * `dist/mcp.js` and `dist/index.js`, which is the convention `src/version.ts`
8
+ * relies on and explains at length. The problem is how it FAILS: at any other
9
+ * depth the root silently becomes `dist/`, nothing is ever inside it, and the
10
+ * guard turns into a no-op that prints nothing and throws nothing. Nobody would
11
+ * notice until the directories came back. `require.resolve` throws
12
+ * MODULE_NOT_FOUND there instead, which is the whole reason for the detour.
13
+ *
14
+ * One consequence, stated rather than left to be discovered: in a published
15
+ * install this resolves to `node_modules/@jokerized/decksmith`, and nobody's
16
+ * TMPDIR is inside that, so the guard is a deliberate no-op for consumers. It
17
+ * protects this checkout — the mess is ours. Do NOT "fix" the asymmetry by
18
+ * testing `process.cwd()`: a user who legitimately works inside their temp
19
+ * directory would have their `TMPDIR` deleted for it.
20
+ *
21
+ * Exported for the test that asserts the depth is still right.
22
+ */
23
+ export declare function packageRoot(): string;
24
+ /** True when `dir` is the package root or sits inside it, symlinks resolved. */
25
+ export declare function insideRoot(dir: string): boolean;
26
+ /**
27
+ * Unset a `TMPDIR` that resolves inside the package root, and say so.
28
+ *
29
+ * Idempotent by construction rather than by a flag: once the variable is gone
30
+ * `os.tmpdir()` answers from the platform default, so a second call finds
31
+ * nothing to do and stays silent.
32
+ */
33
+ export declare function guardTmpdir(): void;
@@ -205,6 +205,13 @@ export declare const lineChartParamsSchema: z.ZodObject<{
205
205
  }, z.core.$strip>>;
206
206
  deltas: z.ZodOptional<z.ZodArray<z.ZodString>>;
207
207
  readout: z.ZodOptional<z.ZodString>;
208
+ compare: z.ZodOptional<z.ZodObject<{
209
+ label: z.ZodString;
210
+ points: z.ZodArray<z.ZodObject<{
211
+ x: z.ZodString;
212
+ y: z.ZodNumber;
213
+ }, z.core.$strip>>;
214
+ }, z.core.$strip>>;
208
215
  }, z.core.$strip>;
209
216
  export declare const calloutParamsSchema: z.ZodObject<{
210
217
  eyebrow: z.ZodOptional<z.ZodString>;
@@ -609,6 +616,13 @@ export declare const beatSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
609
616
  }, z.core.$strip>>;
610
617
  deltas: z.ZodOptional<z.ZodArray<z.ZodString>>;
611
618
  readout: z.ZodOptional<z.ZodString>;
619
+ compare: z.ZodOptional<z.ZodObject<{
620
+ label: z.ZodString;
621
+ points: z.ZodArray<z.ZodObject<{
622
+ x: z.ZodString;
623
+ y: z.ZodNumber;
624
+ }, z.core.$strip>>;
625
+ }, z.core.$strip>>;
612
626
  }, z.core.$strip>;
613
627
  id: z.ZodString;
614
628
  intent: z.ZodString;
@@ -1208,6 +1222,13 @@ export declare const storyboardSchema: z.ZodObject<{
1208
1222
  }, z.core.$strip>>;
1209
1223
  deltas: z.ZodOptional<z.ZodArray<z.ZodString>>;
1210
1224
  readout: z.ZodOptional<z.ZodString>;
1225
+ compare: z.ZodOptional<z.ZodObject<{
1226
+ label: z.ZodString;
1227
+ points: z.ZodArray<z.ZodObject<{
1228
+ x: z.ZodString;
1229
+ y: z.ZodNumber;
1230
+ }, z.core.$strip>>;
1231
+ }, z.core.$strip>>;
1211
1232
  }, z.core.$strip>;
1212
1233
  id: z.ZodString;
1213
1234
  intent: z.ZodString;
@@ -1981,6 +2002,13 @@ export declare const packSchema: z.ZodObject<{
1981
2002
  }, z.core.$strip>>;
1982
2003
  deltas: z.ZodOptional<z.ZodArray<z.ZodString>>;
1983
2004
  readout: z.ZodOptional<z.ZodString>;
2005
+ compare: z.ZodOptional<z.ZodObject<{
2006
+ label: z.ZodString;
2007
+ points: z.ZodArray<z.ZodObject<{
2008
+ x: z.ZodString;
2009
+ y: z.ZodNumber;
2010
+ }, z.core.$strip>>;
2011
+ }, z.core.$strip>>;
1984
2012
  }, z.core.$strip>;
1985
2013
  id: z.ZodString;
1986
2014
  intent: z.ZodString;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jokerized/decksmith",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Turn a source document into an animated explanation deck.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,7 +63,7 @@
63
63
  "commander": "^14.0.1",
64
64
  "fflate": "^0.8.3",
65
65
  "gsap": "^3.14.2",
66
- "hyperframes": "0.8.27",
66
+ "hyperframes": "0.8.33",
67
67
  "katex": "^0.16.11",
68
68
  "puppeteer-core": "^25.3.0",
69
69
  "remark-gfm": "^4.0.1",