@jokerized/decksmith 0.3.1 → 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.
package/README.md CHANGED
@@ -51,7 +51,9 @@ decksmith unpack talk.deck -o reopened/
51
51
  ```
52
52
 
53
53
  - **ingest** — document to `Source`: sections, figures, equations and tables, each with a
54
- stable id so a later stage can point back at it.
54
+ stable id so a later stage can point back at it. Give it an http(s) URL instead of a
55
+ path and it reads the page in a browser first — see "Ingesting a web page" below, which
56
+ also says what happens to a video.
55
57
  - **plan** — `Source` to `Storyboard`: an ordered list of beats. Each beat carries an
56
58
  `intent` (what the viewer should understand), an optional `claim` it is accountable to,
57
59
  `evidence` refs into the Source, a `weight`, and an archetype with its parameters.
@@ -108,6 +110,77 @@ decksmith unpack talk.deck -o reopened/
108
110
  - **pack** / **unpack** — the whole deck as one `.deck` file, and back again. See "The
109
111
  `.deck` container" below.
110
112
 
113
+ ### Ingesting a web page
114
+
115
+ `ingest` takes a URL wherever it takes a file:
116
+
117
+ ```sh
118
+ decksmith ingest https://example.com/the-paper -o source.json
119
+ ```
120
+
121
+ It opens a headless Chrome, puts the page's own bytes into it, and **aborts every request
122
+ the page then makes**. A browser pointed at a stranger's URL is otherwise an open proxy —
123
+ the page says `<img src="http://169.254.169.254/latest/meta-data/">` and the browser
124
+ fetches it from inside your network. So the HTML comes through the same address guard
125
+ everything else here uses, and each figure is fetched afterwards, one at a time, through
126
+ that guard again. That has a cost, and it is stated rather than discovered: a page that assembles its body
127
+ from an external bundle harvests to almost nothing, because that bundle was one of the
128
+ aborted requests. Save the page and ingest the file.
129
+
130
+ Which part of the page becomes the document is scored the way Readability scores it: strip
131
+ what the tag, the ARIA role or the class name says is chrome, score every paragraph by its
132
+ length and its commas, propagate that up to five levels of ancestor, discount each
133
+ candidate by its link density, then merge the winner with the siblings that score near it.
134
+ That is what keeps a consent banner and a 142-comment thread out of a deck about the
135
+ article. When it is not sure it declines, says so, and leaves the page to a blunter rule —
136
+ believe `<main>`, believe `<article>`, else take the densest container. Either way the
137
+ line after it is the one to read:
138
+
139
+ ```
140
+ ingest: harvested "Sparse attention at scale" — 6 images, 1 clips
141
+ ingest: 9 sections, 6 figures, 0 equations
142
+ ```
143
+
144
+ Nine sections for a nine-section article is the extraction working. Ninety is the page.
145
+
146
+ **A video has three cases, and they are three different decks.**
147
+
148
+ - **A file the page serves** — `<video src="…mp4">`, or a link ending in one — is
149
+ downloaded, measured off its own container, and re-encoded to a VP9 webm no more than
150
+ 1280px on its longest edge, with the audio dropped and the length capped at 60 seconds.
151
+ It **plays in the presented deck and in the rendered mp4**: the composition holds a real
152
+ `<video>` seeked on the deck's own clock. The encode is why the cap exists — `render`
153
+ pre-decodes a clip to one still per output frame before capture begins, at the source's
154
+ own resolution, so a 4K original writes 4K stills into an 860px plate. **If ffmpeg is
155
+ not installed the clip is still used**, exactly as the page served it, and the harvest
156
+ says so in a warning naming what to install. `--no-transcode` asks for that on purpose;
157
+ `--max-clip-seconds` moves the cap.
158
+ - **A player-page link** — YouTube, Vimeo, Dailymotion, Loom — keeps its still and where
159
+ to watch it. The **mp4 gets the poster frame**, because that is all a captured document
160
+ can honestly hold. The **presented deck gets the real player**: `deck.html` carries the
161
+ embeddable form of that URL, and a `▶ Video` button (or `v`) opens it over the slide,
162
+ click to play, torn down when you leave the slide. Nothing is fetched until you press
163
+ it, and `index.html` never gains an iframe — a third-party frame in the captured
164
+ document would play at wall-clock speed while the deck is being seeked, and refetch
165
+ itself from the network on every render.
166
+
167
+ **The bytes are deliberately not downloaded.** They sit behind a manifest, DRM or terms,
168
+ and pulling down what a YouTube link stands for is usually against that site's terms.
169
+ It is the same `embed` policy the container uses — see "bake, link, embed" below — and
170
+ it is a property of the URL rather than a choice you can make. A host we have no
171
+ verified embed rule for keeps the poster and the link, and no frame: a URL we invented
172
+ would 404 inside the frame as a black rectangle with nothing to click.
173
+ - **No video at all** is the ordinary case, and nothing changes. A `<video>` with a poster
174
+ and no source becomes an ordinary figure, warned; one with neither becomes a line of
175
+ prose carrying the link, also warned.
176
+
177
+ The URL-only budgets, each named after the option it sets so that a refusal naming
178
+ `maxAssets` names something findable in `--help`: `--max-assets` (40), `--max-clips` (4),
179
+ `--max-bytes` (96 MB in total), `--max-seconds` (180, wall clock for the whole harvest),
180
+ `--max-clip-seconds` (60, per clip), `--no-transcode`. Every figure, clip or whole video
181
+ left out is printed verbatim before the plan is paid for, which is the only moment anyone
182
+ can act on it.
183
+
111
184
  ### What `build` writes
112
185
 
113
186
  ```
@@ -116,6 +189,11 @@ out/
116
189
  deck.html open this: the navigable deck
117
190
  hyperframes-player.global.js copied from the hyperframes package, so nothing needs a CDN
118
191
  hyperframes.json
192
+ vendor/ the scripts the head loads, and only those: GSAP and
193
+ DrawSVG on every deck, MorphSVG (21,195 B) only on one
194
+ that reshapes, ds-morph.js only on one that morphs an
195
+ equation
196
+ katex/ katex.min.css rewritten to its woff2 faces, and those fonts
119
197
  assets/ every figure a beat cites — generated ones included, under
120
198
  the same name source.json records
121
199
  audio/ only when the deck is narrated
@@ -126,9 +204,9 @@ not `file://`: the wrapper drives the composition through its iframe, and a file
126
204
  iframe cannot be reached. The deck says so in the console rather than rendering blank.
127
205
 
128
206
  Arrow keys, Space and PageUp/PageDown step; clicking the left or right third does too;
129
- `Home`/`End` jump; `n` toggles presenter notes; `f` is fullscreen; `m` mutes the voice and
130
- `s` hides the subtitles. Every step is deep-linkable (`#3` is slide 3, `#3.2` its second
131
- reveal).
207
+ `Home`/`End` jump; `p` plays the deck by itself and pauses it again; `n` toggles presenter
208
+ notes; `f` is fullscreen; `m` mutes the voice and `s` hides the subtitles. Every step is
209
+ deep-linkable (`#3` is slide 3, `#3.2` its second reveal).
132
210
 
133
211
  Stepping forward *plays* the reveal rather than cutting to it — the step layer sweeps the
134
212
  composition's timelines across frames instead of seeking once. Backward steps, `Home`/`End`
@@ -279,6 +357,8 @@ for pictures still finishes, on the tool's own SVG if it has to.
279
357
  | `GET /api/jobs/:id/events` | the same payload as SSE on every change |
280
358
  | `GET /api/formats` | the presets, themes, tones, densities and canvas bounds the picker draws from |
281
359
  | `GET /d/:id/...` | the built deck, served statically; `/d/:id/deck.html` is the player |
360
+ | `GET /player.js` | the `<decksmith-player>` element, as an ES module — see below |
361
+ | `GET /examples/embed.html` | a page that embeds two decks with it, and the file you copy |
282
362
 
283
363
  Options on `POST`, all optional, all defaulted server-side: `format`, `width`+`height`,
284
364
  `theme`, `slides`, `lang`, `tone`, `density`, `speed`, `narrate`, `voice`, `images`,
@@ -488,6 +568,8 @@ const { out, files, navigable } = await buildDeck(storyboard, source, "./deck",
488
568
  format: FORMATS["deck-16x9"],
489
569
  theme: "ink",
490
570
  assetsFrom: ".", // the directory whose assets/ holds the figures
571
+ onBeatError: (id, err) => {}, // the beat is not in the deck
572
+ onBeatWarning: (id, why) => {}, // the beat IS, minus an ornament it could not fit
491
573
  onStep: console.log, // silent otherwise: a library that prints is one you
492
574
  }); // cannot run inside a request handler
493
575
 
@@ -499,6 +581,11 @@ demo deck built this way is byte-identical to `decksmith build`'s. It does **not
499
581
  gates, because `verify` wants a browser and a caller may have neither one nor the patience
500
582
  for it; call `verify` yourself when you want it.
501
583
 
584
+ `onBeatWarning` is the only signal that a slide is finished-looking but not what was
585
+ planned — a `line-chart` drawn without its comparison, say. `build` prints it and the
586
+ server puts it in the job's warnings; a library caller that ignores it ships the same
587
+ silence this project keeps finding by eye.
588
+
502
589
  ### The surface
503
590
 
504
591
  | Stage | Exports |
@@ -511,6 +598,7 @@ for it; call `verify` yourself when you want it.
511
598
  | verify | `verify`, `check`, `parseCheckReport` |
512
599
  | pack | `writePack`, `readPack`, `openPack`, `planMedia`, `mediaSummary` |
513
600
  | prefs | `loadPrefs`, `CONFIG_FILE` |
601
+ | process | `guardTmpdir` |
514
602
  | contract | everything in `src/types.ts` — every schema, `Source`, `Storyboard`, `Beat`, `Format`, `FORMATS`, `Verdict` |
515
603
 
516
604
  Two of those are worth pointing at. `Runner` is exported so you can drive planning through
@@ -522,6 +610,13 @@ body rather than a filesystem: it is pure, taking strings in and returning strin
522
610
  so a server's tests draw with a fake instead of spawning Codex, and a deployment can add a
523
611
  backend this package does not ship.
524
612
 
613
+ `guardTmpdir` is the odd one out: it is not part of building a deck, it is what an
614
+ executable that is about to `mkdtemp` calls first. It deletes a `TMPDIR` that resolves
615
+ inside the package root and says so on stderr, and it is a no-op in an installed package.
616
+ Importing this module does not run it, deliberately — an absent `TMPDIR` is inherited by
617
+ every child process a host spawns, which is not a decision a library import gets to make
618
+ for its host. Our own three executables call it; nothing else does.
619
+
525
620
  Anything not listed is deliberately absent, and adding to the list is a promise we cannot
526
621
  quietly take back. `prefsFromFlags` is the clearest example: it translates commander's flag
527
622
  object, which is the CLI's problem and nobody else's.
@@ -642,7 +737,19 @@ right up until the first stall, and a stall is exactly when a viewer looks at th
642
737
  to find out what they missed. `m` mutes (captions keep tracking, and a muted element is
643
738
  exempt from the autoplay policy, so it doubles as the escape hatch); `s` hides subtitles.
644
739
  If the browser refuses to autoplay, the deck says `press any key for sound` once and
645
- navigation carries on regardless.
740
+ navigation carries on regardless. A segment that will not *play* — a missing file, a
741
+ dropped connection — reads `narration unavailable` instead, because telling that viewer to
742
+ press a key names the wrong culprit and goes on naming it every time they try. A gesture
743
+ retries either failure; nothing retries the unplayable one unasked.
744
+
745
+ `p` plays the deck by itself, and that is a mode rather than a change to what stepping
746
+ means — the ordinary case is a presenter talking over it. A narrated stop is timed by its
747
+ own audio and waits for it to finish. A stop that is not being *heard* — no segment, or
748
+ one that would not play — gets the clock instead, at the gap the author left before the
749
+ next stop, floored at 1.5s and capped at 8s. Whether a stop is heard is asked twice, on
750
+ arrival and again when `play()` settles, because until it settles a missing file and a
751
+ working one are indistinguishable; without the second ask, autoplay waits forever for an
752
+ `ended` that a source which never loaded cannot fire.
646
753
 
647
754
  Audio is content-addressed on the text, voice, rate and pitch, so re-narrating an edited
648
755
  deck re-speaks only the sentences that moved — and two beats saying the same sentence
@@ -799,7 +906,7 @@ The explanatory vocabulary. These came out of hand-building a real deck
799
906
  | `equation-walk` | an equation explained symbol by symbol | `equationId`, `terms` (1–4) |
800
907
  | `equation-morph` | one equation becoming the next, the shared terms carried across | `fromId`, `toId`, `terms` (1–4) |
801
908
  | `data-table` | a results table with rows revealed in argument order | `tableId`, `highlight` |
802
- | `line-chart` | a trend, with per-step deltas | `points`, `deltas?`, `readout?` |
909
+ | `line-chart` | a trend, with per-step deltas, optionally against a baseline | `points`, `deltas?`, `readout?`, `compare?` |
803
910
  | `callout` | 1–3 panels of prose: definitions, contrasts, takeaways | `panels`, `note?` |
804
911
  | `pipeline` | stages in a flow, arrowed, with an optional feedback loop | `stages` (2–6), `loop?` |
805
912
  | `annotated-figure` | a figure cropped to the panel under discussion, with leader lines | `figureId`, `crop?`, `notes` |
@@ -812,6 +919,33 @@ The last six draw the mechanism rather than describe it, and `verify` warns when
812
919
  leans on the others: a deck of headlines and bullet panels is what every other slide
813
920
  generator already makes.
814
921
 
922
+ `line-chart`'s `compare` is the one parameter that changes what its archetype *is*. Given
923
+ `{ label, points }` the chart draws the baseline first, holds it, then reshapes the curve
924
+ into `points` and leaves the baseline behind as a labelled ghost — so the slide asserts a
925
+ change in the curve's *shape*, not two numbers. Reach for it on that tell: one quantity
926
+ measured under two conditions.
927
+
928
+ The two series must be over the same x values in the same order, or `storyboardSchema`
929
+ refuses the beat before any of it is drawn. Series over different categories would reshape
930
+ point *i* of one condition onto point *i* of another, which is a smooth and convincing lie
931
+ that nothing downstream can catch: both curves fit the plot, both clear the type floor, and
932
+ `drift` renders the same wrong thing twice.
933
+
934
+ It costs time — the baseline draws, is held, then reshapes — so give a compare beat
935
+ `seconds` of 7, or 8 with a `readout`. Given fewer than it needs, the emitter draws the
936
+ chart *without* the comparison rather than stopping on a half-drawn one: the same beat, one
937
+ series, byte-for-byte the plain chart, down to MorphSVG's 21,195 bytes staying off the deck.
938
+ It says so on the way past, as a `build: kept <beat> — …` line, and that line is the only
939
+ place anyone learns the comparison is gone. Refusing instead would reach `onBeatError` and
940
+ drop the whole slide, which is the worse trade.
941
+
942
+ Keep `label` short. It is set at the end of the baseline and refused outright if it is
943
+ wider than the plot, and *that* refusal does cost the slide. Where it fits, it is placed by
944
+ trying four positions and rejecting any that would print through the axis names, the tick
945
+ labels, the category names, a value, a drawn delta or either curve; if all four are
946
+ rejected the label is dropped rather than overprinted, and dropped silently, because an
947
+ unnamed ghost is still legibly the fainter, earlier curve.
948
+
815
949
  Each maps to exactly one emitter. Adding a domain means adding archetypes — the core never
816
950
  learns what a camera frustum or an orderbook is.
817
951
 
@@ -824,7 +958,13 @@ learns what a camera frustum or an orderbook is.
824
958
  inner HTML, GSAP statements, hold points, and its own CSS. It owns one scene's insides
825
959
  and nothing else. If it draws labelled parts a camera could fly into, fill `Scene.parts`
826
960
  in the same loop that gives them their ids — `inside.element` is an index, and that map
827
- is the only thing that can tell the plan's third stage from the picture's.
961
+ is the only thing that can tell the plan's third stage from the picture's. If its tweens
962
+ need a vendored GSAP plugin, name it in `Scene.plugins`: `PLUGINS` in
963
+ `src/emit/composition.ts` is the one place a name resolves, and a name that table does
964
+ not know is refused at emit time rather than emitted as a tween that animates nothing.
965
+ And if it has to drop an ornament to fit the beat's length, draw the rest and say so in
966
+ `Scene.warnings` rather than throwing — a throw reaches `onBeatError`, which costs the
967
+ whole slide.
828
968
  3. Register it in `src/emit/archetypes/index.ts`.
829
969
 
830
970
  Nothing else changes. The document shell, the deck runtime, the format profiles and the
@@ -850,6 +990,42 @@ The corollary is worth internalising before you trust a green gate: `check` has
850
990
  twice on artifacts that were broken. The gates verify the mechanics of what the structure
851
991
  exposes, and a structurally wrong deck exposes nothing to check.
852
992
 
993
+ ### Embedding a deck in your own page
994
+
995
+ The same step layer is reachable from outside as a custom element. A consumer learns one
996
+ thing — where the deck is:
997
+
998
+ ```html
999
+ <script type="module" src="/player.js"></script>
1000
+ <decksmith-player deck="/d/<id>/"></decksmith-player>
1001
+ ```
1002
+
1003
+ `next()`, `prev()`, `go(i)` and `play(on)` are methods; `ds-ready`, `ds-stop` and
1004
+ `ds-error` are events. `ds-ready` carries every stop the deck can land on, which is what
1005
+ a jump list is built from. Setting `deck` again swaps the deck in place.
1006
+
1007
+ **The iframe stays, and is the module boundary.** It is tempting to mount the deck
1008
+ inline instead, and three facts in this tree rule it out. `frameOf` reads
1009
+ `contentDocument` and returns null cross-origin while the runtime only warns, so an
1010
+ inlined deck served from a CDN would navigate perfectly and paint nothing, silently.
1011
+ `customElements.define` is one registry per document, so two decks would be two vendored
1012
+ hyperframes bundles and the second `define` throws. And the deck's own chrome is written
1013
+ against `100vh` being the box, which is true inside a frame and quietly wrong outside it.
1014
+ Keeping the frame leaves the same-origin pair as `deck.html`↔`index.html`, one directory,
1015
+ always true — and makes the host link `postMessage`, which does not care about origin.
1016
+
1017
+ **Silence is a supported state.** A deck is a static artifact that outlives the tool that
1018
+ built it, and every deck built before this change has no bridge in it. The element waits,
1019
+ gives up, emits `ds-error` with reason `no-bridge`, and leaves the deck exactly as usable
1020
+ as it was — still a deck in a frame, its own keyboard still working. It does not blank and
1021
+ it does not throw.
1022
+
1023
+ `examples/embed.html` is both the demo the dev server serves at `/examples/embed.html`
1024
+ and the file you copy next to a built deck. Nothing in `lint`, `check`, `verify`, `drift`
1025
+ or `render` opens a deck page, so a browser pass is the only instrument that can tell you
1026
+ any of this works. The design note is
1027
+ [`.planning/2026-09-07-player-as-a-module.md`](.planning/2026-09-07-player-as-a-module.md).
1028
+
853
1029
  ## Invariants the generator enforces
854
1030
 
855
1031
  These were all learned by breaking them, and they are why build output is generated
@@ -888,19 +1064,36 @@ mechanically rather than free-hand.
888
1064
  correctly only because one wrapper happened to already be its box's width. No gate reads
889
1065
  CSS, so this is pinned by a test instead — `archetypes.test.ts` fails when two
890
1066
  archetypes say different things about the same class name.
891
- - **Nothing is driven by a GSAP callback.** Capture seeks rather than plays, and `seek()`
892
- passes `suppressEvents` so `onUpdate`, `onStart` and `onComplete` never fire while a
893
- frame is being taken. Motion applied from a callback therefore looks right in a browser
894
- and renders a **frozen video**, with every gate green: lint, check, the type floor, and
895
- even `drift`, which passes twice over because both renders freeze identically. State
896
- must be applied by the thing being seeked tween the property. This is the most
897
- dangerous failure shape in the project, and no automated gate can see it.
1067
+ - **Nothing is driven by a GSAP callback.** Not because the video renders frozen: that was
1068
+ the stated reason for a long time, it was measured on 2026-09-04, and it did not hold.
1069
+ `hyperframes render` drives capture through Chrome's `beginFrame` rather than through a
1070
+ seek, and `suppressEvents` is a property of a seek so under the renderer a callback
1071
+ fires and its motion ramps, in both constructions that were tried. What the callback
1072
+ actually costs is **reproducibility**. Measured against a control on the same deck: the
1073
+ demo differs on 11 frames of 3,120 as built and on 260 with one `onUpdate` tween added,
1074
+ so one callback multiplies the non-reproducible frames by 24 without moving the
1075
+ worst-case PSNR. And nothing in this stack agrees about what a callback did: `decksmith
1076
+ frames` *does* seek, so it passes `suppressEvents` and shows nothing; `hyperframes
1077
+ snapshot` shows a browser's playback; the render shows a third thing. So tween the
1078
+ property, and where a value is not directly tweenable, tween a proxy object and bind the
1079
+ property to it. `AGENTS.md` invariant 11 carries the measurement and the table.
1080
+ - **A vendored runtime costs bytes only on the decks that use it.** GSAP and DrawSVG are
1081
+ unconditional, because every drawing archetype draws something on. Everything else goes
1082
+ through `PLUGINS` in `src/emit/composition.ts`: a scene names what its tweens need in
1083
+ `Scene.plugins`, the head emits a `<script src>` and a `registerPlugin` per name **in the
1084
+ table's order** — not the scene's, or two storyboards differing only in beat order would
1085
+ emit the same tags in a different order and cost someone a day in `drift` — and
1086
+ `vendorScripts` copies only the files the emitted head actually references. So MorphSVG's
1087
+ 21,195 bytes are on a deck that reshapes and on no other, and a name the table does not
1088
+ know is refused at emit time rather than shipped as a tween that silently animates
1089
+ nothing. Registration goes before the first scene script, because a scene builds its
1090
+ timeline inline and late registration is not late, it is nothing.
898
1091
 
899
1092
  ## Repo layout
900
1093
 
901
1094
  ```
902
1095
  src/index.ts the library surface — the only file consumers import
903
- src/cli.ts the ten verbs, argv and stderr
1096
+ src/cli.ts the eleven verbs, argv and stderr
904
1097
  src/types.ts the contract: Source, Storyboard, Beat, Format, Verdict
905
1098
  src/prefs.ts the three-layer preference resolver
906
1099
  src/emit/kit.ts the seam between the deck shell and the archetype emitters