@jokerized/decksmith 0.8.0 → 0.9.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 (40) hide show
  1. package/README.md +121 -4
  2. package/dist/cli.js +4101 -651
  3. package/dist/deck-runtime.js +70 -2
  4. package/dist/index.js +4134 -651
  5. package/dist/mcp.js +3642 -515
  6. package/dist/types/build/files.d.ts +13 -1
  7. package/dist/types/deck/motion.d.ts +64 -0
  8. package/dist/types/deck/playback.d.ts +206 -0
  9. package/dist/types/deck/repack.d.ts +10 -0
  10. package/dist/types/deck/runtime.d.ts +96 -1
  11. package/dist/types/deck/subtitles.d.ts +29 -1
  12. package/dist/types/emit/archetypes/annotated-figure.d.ts +1 -1
  13. package/dist/types/emit/archetypes/bar-compare.d.ts +0 -28
  14. package/dist/types/emit/archetypes/callout.d.ts +0 -5
  15. package/dist/types/emit/archetypes/claim-figure.d.ts +15 -0
  16. package/dist/types/emit/archetypes/grid.d.ts +0 -14
  17. package/dist/types/emit/archetypes/line-chart.d.ts +0 -8
  18. package/dist/types/emit/archetypes/pipeline.d.ts +15 -1
  19. package/dist/types/emit/archetypes/stack.d.ts +1 -1
  20. package/dist/types/emit/composition.d.ts +47 -2
  21. package/dist/types/emit/faces.d.ts +22 -0
  22. package/dist/types/emit/fit.d.ts +127 -0
  23. package/dist/types/emit/kit.d.ts +104 -2
  24. package/dist/types/emit/look.d.ts +138 -0
  25. package/dist/types/emit/motion.d.ts +244 -0
  26. package/dist/types/emit/svg.d.ts +54 -16
  27. package/dist/types/emit/theme.d.ts +1 -1
  28. package/dist/types/emit/themes/index.d.ts +19 -1
  29. package/dist/types/emit/themes/packs.d.ts +15 -0
  30. package/dist/types/emit/themes/pick.d.ts +104 -0
  31. package/dist/types/emit/type.d.ts +111 -0
  32. package/dist/types/index.d.ts +35 -1
  33. package/dist/types/plan/direct.d.ts +66 -0
  34. package/dist/types/prefs.d.ts +15 -1
  35. package/dist/types/source/fonts.d.ts +11 -1
  36. package/dist/types/types.d.ts +38 -0
  37. package/dist/types/verify/fidelity.d.ts +34 -0
  38. package/dist/types/verify/fill.d.ts +205 -0
  39. package/dist/types/verify/motion.d.ts +33 -0
  40. package/package.json +4 -1
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The v2 motion grammar: how a scene ARRIVES, how it HANDS OFF to the next one,
3
+ * and what MOVES while the narrator is talking about it.
4
+ *
5
+ * v0.8.0 had one of each, measured over 124 HypePaper scenes: 114 opened with
6
+ * the same eyebrow-then-headline fade-up, all 115 seams were one 0.4s
7
+ * `power2.in` dissolve, two eases carried 85% of 1,591 tweens, and once a slide
8
+ * had built, nothing on it moved while the voice explained it. That is the
9
+ * founder's "the animation is always the same", and this module is the answer
10
+ * for `design: "v2"` decks only. `classic` never reaches it, so every v0.8.0
11
+ * byte and golden is unchanged.
12
+ *
13
+ * FOUR RULES EVERYTHING HERE OBEYS, each of them an AGENTS.md invariant:
14
+ *
15
+ * 1. Pure and seeded. Every choice is a function of the storyboard (`fnv1a` of
16
+ * the source id and the beat id), never of a clock or `Math.random`, so two
17
+ * builds of one storyboard are byte-identical (invariant 4).
18
+ * 2. `fromTo` only, no callbacks. Every tween is a `Tween`, which cannot be
19
+ * written without its `from` (invariant 2), and nothing here hangs state off
20
+ * `onUpdate` (invariant 11). Emphasis and seam tweens carry
21
+ * `immediateRender: false` so they never paint at build time over an
22
+ * element's own entrance.
23
+ * 3. Holds, `open` and scene lengths never move. A restyled entrance keeps every
24
+ * tween's position and duration, so `openSeconds`, `beatSeconds` and the
25
+ * timing manifest (`holdsFor` in src/render/timing.ts re-emits scenes WITHOUT
26
+ * this module) all still agree. Emphasis lives strictly inside a hold window
27
+ * and is back at rest before the window closes, so every frame a gate
28
+ * captures at a hold is the frame v0.8.0 would have captured there.
29
+ * 4. Up, never down. Emphasis grows a part (`LIFT`) and never shrinks one: the
30
+ * type floor reads declared sizes, so a part scaled below 1 at a hold would
31
+ * pass at a size it is not drawn at.
32
+ *
33
+ * INTERFACE, for the other v2 tracks (player, fit, style, layout):
34
+ *
35
+ * - `planMotion(seed, beats)` → which entrance verb each scene uses, which seam
36
+ * joins each pair, and the order emphasis kinds are tried in. A style pack
37
+ * that wants to bias the grammar should filter `ENTRANCES`/`SEAMS` before
38
+ * calling it rather than post-edit its answer, so the no-repeat rules still
39
+ * hold.
40
+ * - `restyleEntrance(scene, sid, verb)` rewrites an emitted scene's own
41
+ * entrance tweens. It only rewrites tweens it recognises (`isEntrance`), so an
42
+ * archetype added later is left alone rather than broken.
43
+ * - `seamOut`/`seamIn` are the handoff tweens for the outgoing and incoming
44
+ * scene roots; `composition.ts` appends them where it used to append the one
45
+ * dissolve.
46
+ * - `emphasize(...)` adds the during-narration motion and returns the
47
+ * `HoldWindow`s the deck player needs (`deck.html`'s
48
+ * `application/decksmith-motion+json` island, read by src/deck/motion.ts).
49
+ */
50
+ import type { Archetype, BeatRole } from "../types.js";
51
+ import { type Scene, type Tween } from "./kit.js";
52
+ /**
53
+ * How a scene's parts arrive. Six, so the no-repeat rule always has at least
54
+ * four to choose from after excluding the previous scene's verb and a capped
55
+ * `rise`.
56
+ *
57
+ * | verb | what moves | ease |
58
+ * |-------|----------------------------------------------|---------------|
59
+ * | rise | fade up from below (v0.8.0's, kept, capped) | power3.out |
60
+ * | slide | in from the reading side | expo.out |
61
+ * | snap | scale up from 0.9 with a small overshoot | back.out(1.7) |
62
+ * | focus | out of a blur (HTML) / short rise (SVG) | circ.out |
63
+ * | wipe | clip-path opens left to right (HTML) | power4.inOut |
64
+ * | mask | clip-path opens bottom-up while rising (HTML)| power4.out |
65
+ *
66
+ * `focus`, `wipe` and `mask` touch `filter`/`clip-path`, which are only applied
67
+ * to HTML elements this module can SEE are HTML (an id whose tag it read in the
68
+ * scene's markup). An SVG part or a class selector gets the verb's transform
69
+ * fallback instead, because clip-path on an SVG child clips against a reference
70
+ * box nobody measured.
71
+ */
72
+ export declare const ENTRANCES: readonly ["rise", "slide", "snap", "focus", "wipe", "mask"];
73
+ export type Entrance = (typeof ENTRANCES)[number];
74
+ /**
75
+ * How one scene hands off to the next. `dive` is the existing camera move and is
76
+ * never chosen here — a beat that is `inside` the previous one keeps it.
77
+ *
78
+ * | seam | outgoing root | incoming root |
79
+ * |----------|----------------------------------|------------------------|
80
+ * | dissolve | opacity → 0, power2.in (v0.8.0) | — |
81
+ * | push | xPercent 0 → −4, opacity → 0 | xPercent 4 → 0 |
82
+ * | lift | yPercent 0 → −4, opacity → 0 | yPercent 4 → 0 |
83
+ * | wipe | clip-path closes left to right | — |
84
+ * | zoom | scale 1 → 1.04, opacity → 0 | scale 0.96 → 1 |
85
+ *
86
+ * 4% is under the scene's own padding at every format (110px of 1920 is 5.7%,
87
+ * 84px of 1080 is 7.8%), so a mid-seam frame never puts content off the canvas
88
+ * for the layout gate's grid samples to find.
89
+ */
90
+ export declare const SEAMS: readonly ["dissolve", "push", "lift", "wipe", "zoom"];
91
+ export type Seam = (typeof SEAMS)[number] | "dive";
92
+ /** What moves while the narrator talks about a part: grow, glow, underline. */
93
+ export declare const EMPHASES: readonly ["pulse", "glow", "underline"];
94
+ export type Emphasis = (typeof EMPHASES)[number];
95
+ /** Every ease this module writes. A test pins that nothing else appears. */
96
+ export declare const MOTION_EASES: readonly ["power3.out", "expo.out", "back.out(1.7)", "circ.out", "power4.inOut", "power4.out", "power2.in", "power2.inOut", "sine.out", "sine.inOut"];
97
+ /**
98
+ * FNV-1a, 32-bit. The seeded choice every v2 decision is made with: stable
99
+ * across platforms and Node versions, which a `Math.random` with a seed is not.
100
+ */
101
+ export declare function fnv1a(s: string): number;
102
+ /** What `planMotion` needs to know about a beat. */
103
+ export interface MotionBeat {
104
+ id: string;
105
+ archetype: Archetype;
106
+ role?: BeatRole | undefined;
107
+ inside?: {
108
+ beat: string;
109
+ } | undefined;
110
+ }
111
+ export interface MotionPlan {
112
+ /** One per beat, in order. */
113
+ entrances: Entrance[];
114
+ /** One per adjacent pair: `seams[i]` joins beat `i` to beat `i + 1`. */
115
+ seams: Seam[];
116
+ /** Per beat, the order emphasis kinds are tried in (the first that fits wins). */
117
+ emphases: Emphasis[][];
118
+ }
119
+ /**
120
+ * At most this share of a deck's scenes open with `rise`, the one verb that
121
+ * reads as v0.8.0's stock fade-up. The plan's target is "modal entrance ≤ 40%".
122
+ */
123
+ export declare const RISE_SHARE = 0.25;
124
+ /**
125
+ * The deck's motion, decided once for the whole storyboard.
126
+ *
127
+ * ENTRANCES: each beat hashes into the verbs still allowed to it — never the
128
+ * previous beat's, and `rise` only while under `RISE_SHARE`. Hash-then-filter
129
+ * rather than a rotation so two decks with the same archetype sequence still
130
+ * move differently, which is the cross-deck sameness the critic flagged.
131
+ *
132
+ * SEAMS are chosen by the RELATION between the two beats first, then varied:
133
+ * same family (two quantity beats) → `push`, a lateral continuation; a role
134
+ * boundary (`background` → `limitations`) → `zoom`, a chapter break; into or
135
+ * out of a title → `lift`; any other family change → `wipe`; into the closing
136
+ * beat → `dissolve`. A seam that would repeat the previous one is replaced by a
137
+ * hashed pick from the rest. Then, for a deck of 10+ beats with fewer than three
138
+ * kinds, the earliest replaceable seams are rotated through the missing kinds.
139
+ * `inside` is the camera's and stays `dive`.
140
+ */
141
+ export declare function planMotion(seed: string, beats: readonly MotionBeat[]): MotionPlan;
142
+ /**
143
+ * Rotate missing seam kinds into the earliest positions where the swap keeps
144
+ * both neighbours different and does not remove the last use of a kind.
145
+ * In place; deterministic because it walks in index order.
146
+ */
147
+ export declare function ensureSeamKinds(seams: Seam[], want: number): void;
148
+ /**
149
+ * A tween this module may restyle: an opacity 0 → 1 reveal whose `from` only
150
+ * moves opacity and position/scale, rendered immediately (the element's own
151
+ * first appearance). Dims, restores, draw-ons, pulses and anything with
152
+ * `immediateRender: false` are someone else's and stay exactly as emitted.
153
+ */
154
+ export declare function isEntrance(t: Tween): boolean;
155
+ /** The tag of the element an `#id` selector names in this markup, if it is one. */
156
+ export declare function tagOf(html: string, target: string): string | undefined;
157
+ /**
158
+ * One scene's entrances, re-voiced in `verb`. Positions and durations are
159
+ * untouched (rule 3 at the top), so this cannot move a hold or the voice.
160
+ */
161
+ export declare function restyleEntrance(scene: Scene, sid: string, verb: Entrance): Scene;
162
+ /**
163
+ * When the incoming chrome may start, in unpaced seconds: once the outgoing
164
+ * scene's handoff (`HANDOFF_SECONDS`, 0.4) has taken it below half opacity,
165
+ * which a `power2.in` fade does at 0.32.
166
+ *
167
+ * WHY. v0.8.0's eyebrow starts at 0.15 on GSAP's default `power1.out`, which is
168
+ * still under 0.5 opacity by the time the old scene fades past half. v2's eases
169
+ * are faster off the mark — `expo.out` is at 0.75 a fifth of the way in — so an
170
+ * eyebrow starting at 0.15 is legible over the outgoing headline, the collision
171
+ * `HANDOFF_SECONDS` warns about. So the chrome's START moves to here and its END
172
+ * stays where it was: `openSeconds` reads only the ends, so the voice starts at
173
+ * the same instant. `test/motion.test.ts` checks every verb against every seam.
174
+ */
175
+ export declare const SEAM_CLEAR = 0.3;
176
+ /**
177
+ * The OUTGOING half of a seam: tweens on `#sid` starting where its slide ends
178
+ * (`at`) and running `over` seconds into the next scene's window, exactly where
179
+ * the v0.8.0 dissolve ran. `dissolve` is byte-for-byte `handoffStatement`'s
180
+ * tween, so a v2 deck whose plan says dissolve hands off as v0.8.0 did.
181
+ */
182
+ export declare function seamOut(kind: Exclude<Seam, "dive">, sid: string, at: number, over: number): Tween[];
183
+ /**
184
+ * The INCOMING half, on the next scene's own timeline from its 0. Settles by
185
+ * `firstHold - 0.05`, so the frame at the first stop is the frame v0.8.0 drew
186
+ * there; a scene that holds sooner than `over` gets no incoming move at all.
187
+ */
188
+ export declare function seamIn(kind: Seam, sid: string, over: number, firstHold: number): Tween[];
189
+ /** A narrated stop's quiet stretch, scene-relative. See `emphasize`. */
190
+ export interface HoldWindow {
191
+ /** Stop index within the scene (the narration segment's `stop`). */
192
+ stop: number;
193
+ /** The hold itself: where the deck lands for this stop. */
194
+ at: number;
195
+ /** Where this stop's sentence starts on the scene's clock (may be < `at`). */
196
+ from: number;
197
+ /** The first moment after `at` that belongs to the next stop's reveal, or the scene's end. */
198
+ to: number;
199
+ }
200
+ /** What `emphasize` needs to know about a narration segment. */
201
+ export interface SpokenSegment {
202
+ stop: number;
203
+ seconds: number;
204
+ cues: readonly {
205
+ start: number;
206
+ }[];
207
+ }
208
+ export declare const EMPH_TOTAL: number;
209
+ /** `#rrggbb` → `rgba(r, g, b, a)`, or undefined for anything else. */
210
+ export declare function rgba(hex: string, a: number): string | undefined;
211
+ /**
212
+ * Motion DURING the narration hold, timed to what is being said.
213
+ *
214
+ * There is no word timing, so the clock is the narration's own cue boundaries:
215
+ * each segment speaks about its stop's subject (`subjectOf`), and the subject is
216
+ * emphasised at the first cue boundary — the sentence's start counts — at which
217
+ * it is on screen and settled, inside a stretch where nothing else is arriving.
218
+ *
219
+ * THE STRETCHES. After stop `k`'s hold the scene is quiet until the first tween
220
+ * that starts later (the next stop's reveal); after the LAST hold it is quiet to
221
+ * the scene's end, which is where most narration lands because the reveals play
222
+ * under the voice (`speechPlan`). An emphasis for stop `k` may use any stretch
223
+ * from `k` on — in the video every part is still there — and must be back at
224
+ * rest before the stretch closes, so every hold frame is untouched (rule 3).
225
+ *
226
+ * `windows` is the deck player's half. A presented deck holds still at each stop
227
+ * while its audio plays; `src/deck/motion.ts` seeks the scene through the
228
+ * stop's own stretch on the audio's clock, `from + audio.currentTime`, so the
229
+ * same emphasis lands on the same word there. Only a stop's OWN stretch is
230
+ * offered, because seeking into a later one would show the next reveal early.
231
+ */
232
+ export declare function emphasize(scene: Scene, sid: string, opts: {
233
+ segments: readonly SpokenSegment[];
234
+ /** `speechPlan(open, holds, segments).starts` — where each segment starts. */
235
+ starts: readonly number[];
236
+ /** The scene's slide length (before any handoff or camera tail). */
237
+ end: number;
238
+ /** Per-scene try order, from `planMotion`. */
239
+ kinds: readonly Emphasis[];
240
+ accent: string;
241
+ }): {
242
+ scene: Scene;
243
+ windows: HoldWindow[];
244
+ };
@@ -1,19 +1,5 @@
1
- /**
2
- * Geometry, text metrics and SVG strings. Nothing above that.
3
- *
4
- * Six diagrammatic archetypes draw vector graphics that the source document
5
- * never contained. Left to themselves each would re-derive the same four things
6
- * — how wide a label renders, where N things go across a canvas, how to draw an
7
- * arrowhead, how to escape a caption — and they would each get them slightly
8
- * wrong in a different way. `line-chart` shipped 3.6px from clipping because it
9
- * guessed its own em-factor; that guess now lives here, once.
10
- *
11
- * This module knows about characters, boxes and paths. It does not know what a
12
- * pipeline stage or a receptive field is — that knowledge belongs to the emitter
13
- * that composes these primitives, and putting it here is how a primitive layer
14
- * turns into six hand-rolled one-offs wearing a shared import.
15
- */
16
1
  import { type Tween, type Vars } from "./kit.js";
2
+ import { type LatinFace, type TypeSpec } from "./type.js";
17
3
  export interface Pt {
18
4
  x: number;
19
5
  y: number;
@@ -145,7 +131,49 @@ export declare function id(sid: string, part: string, i?: number): string;
145
131
  * why the auto-detection below still runs underneath: the parameter can only
146
132
  * widen what a run is charged, never narrow it.
147
133
  */
148
- export type Face = "latin" | "cjk" | "hangul";
134
+ export type Script = "latin" | "cjk" | "hangul";
135
+ /**
136
+ * A v2 style pack's face: the script as above, the Latin face that draws the
137
+ * run, and the pack's type spec (`./type.ts`).
138
+ *
139
+ * An OBJECT rather than a fourth string, and only ever built by `faceOf` and
140
+ * `displayFace`, because every emitter already threads a `Face` through to
141
+ * `textWidth` and `chrome*` without looking inside it. That is what lets a pack
142
+ * change both the width table and the chrome's scale with no emitter learning
143
+ * packs exist: the measurement rides on the value they already pass.
144
+ *
145
+ * `glyphs` only matters when `script` is `"latin"`. A CJK deck draws its Latin
146
+ * in the bundled Noto face whatever the pack says, and is measured as it always
147
+ * was.
148
+ */
149
+ export interface PackFace {
150
+ readonly script: Script;
151
+ readonly glyphs: LatinFace;
152
+ readonly type: TypeSpec;
153
+ /**
154
+ * A CJK deck whose pack sets this role in a serif: the bundle's Noto Serif
155
+ * draws the run, not Noto Sans. Han and kana are the same 1.0em in both;
156
+ * Hangul is 0.966em against 0.920, and the Latin inside a run is about 7%
157
+ * wider (measured 2026-10-08, Chrome 145, KR/JP/SC at 400 and 700). See
158
+ * `CJK_SERIF_HANGUL` and `CJK_SERIF_LATIN`.
159
+ */
160
+ readonly cjkSerif?: boolean;
161
+ }
162
+ /**
163
+ * A bare `Script` is the classic face, exactly as before packs: Inter's table and
164
+ * `CLASSIC_TYPE`. Every theme that is not a v2 pack still measures as one, so
165
+ * its bytes do not move.
166
+ */
167
+ export type Face = Script | PackFace;
168
+ /** The script a face sets in. */
169
+ export declare function scriptOf(face: Face): Script;
170
+ /** The chrome's type spec. A bare script is classic. */
171
+ export declare function typeOf(face: Face): TypeSpec;
172
+ /**
173
+ * The same face, measured as the pack's DISPLAY face — what the eyebrow, the
174
+ * headline and the title slide's headline are set in. Identity for classic.
175
+ */
176
+ export declare function displayFace(face: Face): Face;
149
177
  /**
150
178
  * The face a theme's stack will use, from the family `familyFor` put in front.
151
179
  *
@@ -161,6 +189,16 @@ export declare function textWidth(text: string, fontSize: number, weight?: numbe
161
189
  * one unbroken "word".
162
190
  */
163
191
  export declare function wrap(text: string, fontSize: number, maxWidth: number, weight?: number, tracking?: number, face?: Face): string[];
192
+ /**
193
+ * Whether `wrap` would have to cut a word letter by letter to set `text` at
194
+ * this size and width — "Rectified-flo" / "w". `wrap` does that as a last
195
+ * resort, which is right for a box that cannot be any wider; a layout that is
196
+ * CHOOSING a size (v2's growth, a layout variant) asks this first and takes a
197
+ * size at which no word is cut. A run with Han or kana in it breaks between
198
+ * characters by the script's own rules, so it never counts; Hangul words are
199
+ * space-separated and do.
200
+ */
201
+ export declare function cutsWord(text: string, fontSize: number, maxWidth: number, weight?: number, face?: Face): boolean;
164
202
  /** The SVG root. Width and height match the viewBox so px and user units agree. */
165
203
  export declare function svg(elementId: string, w: number, h: number, body: string): string;
166
204
  export declare function rect(b: Box, a?: Attrs): string;
@@ -1,7 +1,7 @@
1
1
  import type { Format, Storyboard } from "../types.js";
2
2
  import { type Scene } from "./kit.js";
3
3
  import { type DeckTheme } from "./themes/index.js";
4
- export { type DeckTheme, ink, mono, paper, resolveTheme, THEME_NAMES, THEMES, } from "./themes/index.js";
4
+ export { type DeckTheme, ink, mono, PACKS, paper, resolveTheme, THEME_NAMES, THEMES, } from "./themes/index.js";
5
5
  /**
6
6
  * The theme a storyboard's scenes are staged with, and the font family it ships.
7
7
  *
@@ -13,6 +13,7 @@
13
13
  import type { Theme } from "../kit.js";
14
14
  import { ink } from "./ink.js";
15
15
  import { mono } from "./mono.js";
16
+ import { PACKS } from "./packs.js";
16
17
  import { paper } from "./paper.js";
17
18
  /**
18
19
  * `Theme` plus what only the shared stylesheet reads.
@@ -25,7 +26,24 @@ import { paper } from "./paper.js";
25
26
  export interface DeckTheme extends Theme {
26
27
  /** `body`'s font-weight. Omitted means 400, which is what `ink` and `paper` want. */
27
28
  bodyWeight?: number;
29
+ /**
30
+ * A v2 style pack's ground, so a picker can keep light and dark both in use.
31
+ * Absent on the classic themes, which are never picked — only named.
32
+ */
33
+ ground?: "dark" | "light";
34
+ /**
35
+ * A v2 pack's finish: CSS appended after the base stylesheet. It may change
36
+ * only what no measurement reads — colour, radius, shadow, decoration, the
37
+ * body's `background-image`, absolutely positioned pseudo-elements. See
38
+ * `./packs.ts`; the rule is enforced in `test/style-packs.test.ts`.
39
+ */
40
+ skin?: string;
28
41
  }
42
+ /**
43
+ * The classic three, then the v2 packs. Every one is reachable by `--theme`,
44
+ * which is also how a storyboard or a caller forces a pack; `--design v2` picks
45
+ * among `PACKS` when nobody named one (`./pick.ts`).
46
+ */
29
47
  export declare const THEMES: Readonly<Record<string, DeckTheme>>;
30
48
  /** Sorted, so an error message and a `--help` listing agree without coordinating. */
31
49
  export declare const THEME_NAMES: readonly string[];
@@ -37,4 +55,4 @@ export declare const THEME_NAMES: readonly string[];
37
55
  * someone presents it.
38
56
  */
39
57
  export declare function resolveTheme(name: string): DeckTheme;
40
- export { ink, mono, paper };
58
+ export { ink, mono, PACKS, paper };
@@ -0,0 +1,15 @@
1
+ import type { DeckTheme } from "./index.js";
2
+ /** Magma, HypePaper's brand accent, on a violet-black ground. Grotesk display. */
3
+ export declare const signal: DeckTheme;
4
+ /** Navy drafting film, a cyan rule, a faint grid. IBM Plex throughout. */
5
+ export declare const blueprint: DeckTheme;
6
+ /** Espresso ground, amber accent, a serif headline over Inter. Matted figures. */
7
+ export declare const atlas: DeckTheme;
8
+ /** Cream stock, oxblood accent, Source Serif throughout. A journal page. */
9
+ export declare const folio: DeckTheme;
10
+ /** Cool white, ultramarine accent, a grotesk headline over Plex. Dotted ground. */
11
+ export declare const chalk: DeckTheme;
12
+ /** Sage ground, forest accent, a Plex headline over a serif body. Tabbed plates. */
13
+ export declare const journal: DeckTheme;
14
+ /** The packs `--design v2` chooses among, in a fixed order the picker hashes into. */
15
+ export declare const PACKS: Readonly<Record<string, DeckTheme>>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Which v2 pack a deck wears, when nobody said.
3
+ *
4
+ * DETERMINISTIC: a pure function of the storyboard's beats and a seed, so the
5
+ * same deck is the same bytes on every build (invariant 4 is about render time,
6
+ * but a pick that moved between builds would be the same failure one step
7
+ * earlier). The seed is the source id unless the caller passes one — HypePaper
8
+ * should pass the PAPER id, so a paper's en and ko decks share a look.
9
+ *
10
+ * WEIGHTED BY CONTENT, so the hash is not the only voice. Each pack carries an
11
+ * affinity for the four archetype families (`ARCHETYPE_FAMILY`): a deck that is
12
+ * mostly equations leans to the serif packs, one that is mostly bars and tables
13
+ * to the grotesk and drafting ones. The lean is mild — every pack keeps a floor
14
+ * weight — because the founder's complaint is sameness across decks, and a
15
+ * strong content rule would put every paper of one field on one pack.
16
+ *
17
+ * Rendezvous hashing: each pack draws `-ln(u) / weight` from its own hash of
18
+ * (seed, pack) and the smallest draw wins. That is a weighted choice whose
19
+ * probabilities are exactly the normalised weights, and the full ordering is a
20
+ * ready-made fallback list — the next pack is the next-smallest draw, so a
21
+ * caller that must refuse one (narration staged against another look) moves
22
+ * along it without re-rolling anything.
23
+ */
24
+ import { type Format, type Source, type Storyboard } from "../../types.js";
25
+ import { type DeckNarration } from "../composition.js";
26
+ /** 32-bit FNV-1a. Stable across platforms and Node versions, unlike any Map order. */
27
+ export declare function fnv1a(text: string): number;
28
+ /** A pack's weight for this deck: 1, plus its affinity times the family mix. */
29
+ export declare function packWeights(beats: Storyboard["beats"]): Record<string, number>;
30
+ /** Every pack, most-preferred first, for this deck and seed. */
31
+ export declare function rankPacks(beats: Storyboard["beats"], seed: string): string[];
32
+ /** The design generation a build emits. `classic` is v0.8.0, byte for byte. */
33
+ export type Design = "classic" | "v2";
34
+ export interface LookChoice {
35
+ /** `--theme` or a config file. Always wins: a named theme is a forced one. */
36
+ stated?: string | undefined;
37
+ storyboard: Pick<Storyboard, "theme" | "beats" | "sourceId">;
38
+ design: Design;
39
+ /** Overrides `storyboard.sourceId` as the hash seed. */
40
+ seed?: string | undefined;
41
+ /**
42
+ * Whether this build can wear a pack. `build` passes a check that the pack
43
+ * stages every beat with the stop count the narration was recorded at; a pack
44
+ * it refuses is skipped for the next in the ranking.
45
+ */
46
+ accepts?: (name: string) => boolean;
47
+ }
48
+ /**
49
+ * The theme name a build, a narration or a timing pass should stage with.
50
+ *
51
+ * Classic: what it always was, `stated ?? storyboard.theme`.
52
+ *
53
+ * v2: a stated theme still wins, and so does a storyboard that names anything
54
+ * but `ink` — that is the storyboard forcing a look. `ink` is the schema's
55
+ * DEFAULT, so it is indistinguishable from "nobody said" once parsed, and is
56
+ * read that way (as `--theme ink` is: the CLI treats a default value as
57
+ * unstated). The v0.8.0 look is `--design classic`, not a v2 deck in ink.
58
+ * Otherwise the first ranked pack `accepts` allows; if it allows none, the
59
+ * storyboard's own theme. Never throws.
60
+ *
61
+ * NOT "what any narration on disk was staged against", which this used to say:
62
+ * once `narrate` ran under v2 it staged under its own pick. `narrate` and
63
+ * `build` agree because both rank with the same seed and both refuse a pack
64
+ * that drops a beat (`pickTheme` below); give them the same `--pack-seed`.
65
+ */
66
+ export declare function chooseLook(c: LookChoice): string;
67
+ /**
68
+ * The theme every entry point builds with: the CLI's `build` and `narrate`,
69
+ * the library's `buildDeck` and the server pipeline all come here, so the same
70
+ * input is the same deck whichever one made it. (Before, only the CLI picked a
71
+ * pack; a v2 deck from the library or the server stayed in ink.)
72
+ *
73
+ * `narration` is the recording on disk, when there is one: a pack must stage
74
+ * every beat at the stop count it was recorded at. `narrate` itself has none
75
+ * yet and asks only the other half — that no beat is dropped — which is what
76
+ * keeps its pick and `build`'s the same.
77
+ */
78
+ export declare function pickTheme(storyboard: Storyboard, source: Source, format: Format, opts: {
79
+ stated?: string | undefined;
80
+ design: Design;
81
+ seed?: string | undefined;
82
+ speed: number;
83
+ narration?: DeckNarration | undefined;
84
+ }): string;
85
+ /**
86
+ * The `accepts` a build hands `chooseLook`: a pack may cost this deck nothing.
87
+ *
88
+ * Two ways it could, both found on real decks:
89
+ *
90
+ * - A beat staged with a different stop count than the narration on disk was
91
+ * recorded at. `planCut` throws for that (`assertNarrationStaging`).
92
+ * - A beat the storyboard's own theme draws but the pack refuses. Measured: an
93
+ * en deck in `folio` lost a six-bar bar-compare ("need 660px of the 653px")
94
+ * because the pack's chrome is a few px taller. That is a whole slide gone for
95
+ * a look, which is never a trade worth making silently, so the pack is
96
+ * skipped for the next one in the ranking instead.
97
+ *
98
+ * Pure: it only runs `planCut`, the same pass the build makes, once per pack it
99
+ * is asked about plus once for the baseline.
100
+ */
101
+ export declare function costsNothing(storyboard: Storyboard, source: Source, format: Format, opts: {
102
+ speed: number;
103
+ narration?: DeckNarration | undefined;
104
+ }): (name: string) => boolean;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Type specs: the typographic half of a v2 style pack.
3
+ *
4
+ * A pack used to be a palette (`themes/`), and every deck set the same Inter at
5
+ * the same 42/64/40 scale, which is half of why the founder's verdict on 219
6
+ * decks was "always the same". A `TypeSpec` is the rest of a look: which face
7
+ * draws the headline and which draws the body, the eyebrow's size, case, weight
8
+ * and tracking, the headline's size, weight, tracking and leading, and how the
9
+ * title slide's big headline grows.
10
+ *
11
+ * EVERY NUMBER HERE IS GEOMETRY, which is why this is not CSS. `chromeHeight`
12
+ * charges the chrome from these numbers and `chromeCss` draws it from the same
13
+ * numbers, so the two cannot disagree. A spec is chosen by the font stack a theme
14
+ * declares (see `faceOf` in svg.ts), so every emitter that already threads a
15
+ * `Face` gets the right measurement without learning that packs exist.
16
+ *
17
+ * `CLASSIC_TYPE` is today's look, number for number. It is never looked up by
18
+ * stack: any stack no spec claims — `ink`, `mono`, `paper`, a user's own theme —
19
+ * measures as classic, which is what keeps v0.8.0's bytes.
20
+ */
21
+ import type { MeasuredFace } from "./faces.js";
22
+ /** A Latin face a deck can be measured in. `inter` is svg.ts's own table. */
23
+ export type LatinFace = "inter" | MeasuredFace;
24
+ export interface TypeSpec {
25
+ /** Stable id, for messages and the gallery. */
26
+ key: string;
27
+ /** Face of everything but the chrome. Measured with this face's table. */
28
+ body: LatinFace;
29
+ /** Face of the eyebrow, the headline and the title slide's headline. */
30
+ display: LatinFace;
31
+ /**
32
+ * The body stack. It IDENTIFIES the spec (`faceOf` matches its tail), so no two
33
+ * specs may share one; the display family rides in it after Inter, where it
34
+ * never draws, purely so a stack names its pair. See `stackFor`.
35
+ */
36
+ stack: string;
37
+ /** The chrome's stack: display face, then Inter for any glyph it lacks. */
38
+ displayStack: string;
39
+ /** `color` is a palette role, so one spec suits a light and a dark pack. */
40
+ eyebrow: {
41
+ size: number;
42
+ weight: 400 | 500 | 600 | 700;
43
+ tracking: number;
44
+ upper: boolean;
45
+ lh: number;
46
+ gap: number;
47
+ color: "muted" | "accent";
48
+ };
49
+ headline: {
50
+ size: number;
51
+ weight: 400 | 500 | 600 | 700;
52
+ tracking: number;
53
+ lh: number;
54
+ };
55
+ /** `fitText`'s bounds for the title slide, and how the big headline is set. */
56
+ title: {
57
+ lo: number;
58
+ hi: number;
59
+ weight: 400 | 500 | 600 | 700;
60
+ tracking: number;
61
+ };
62
+ }
63
+ /** The CSS family a face is declared under — what `vendorFace` writes. */
64
+ export declare function familyOf(face: LatinFace): string;
65
+ /**
66
+ * `"<body>", "Inter", "<display>", system-ui, sans-serif`, with duplicates gone.
67
+ *
68
+ * Inter second, always: every Latin deck vendors it, and `measure-faces.mjs`
69
+ * measured each face with Inter as its fallback, so a glyph the face lacks is
70
+ * drawn by the face the table assumed. The display family after Inter never
71
+ * draws a glyph; it is there so (body, display) pairs give distinct stacks.
72
+ */
73
+ export declare function stackFor(body: LatinFace, display: LatinFace): string;
74
+ /** Today's chrome, exactly. `title.ts` exported these numbers before specs existed. */
75
+ export declare const CLASSIC_TYPE: TypeSpec;
76
+ /**
77
+ * The v2 type specs. Five pairings, each a different voice rather than a
78
+ * different size of the same one:
79
+ *
80
+ * - `grotesk-inter`: a geometric grotesk headline, tight and large, over Inter.
81
+ * - `plex`: IBM Plex Sans throughout, a little compact — engineering drawings.
82
+ * - `serif-inter`: a text serif headline at display size over Inter.
83
+ * - `serif`: Source Serif 4 throughout, the eyebrow in sentence case — a journal.
84
+ * - `grotesk-plex`: Space Grotesk headline over a Plex body.
85
+ * - `plex-serif`: a Plex headline over a serif body, the inverse of `serif-inter`.
86
+ *
87
+ * HEIGHT. A one-line eyebrow plus a one-line headline costs at most classic's
88
+ * 146px in every spec, so a pack's bigger type comes out of its leading and
89
+ * gaps rather than out of the body's room. Measured why: `serif` at 44/70 cost
90
+ * a real deck a six-bar chart (`costsNothing` in themes/pick.ts now refuses
91
+ * such a pack, but a pack that rarely needs refusing is the better pack).
92
+ *
93
+ * FLOORS. Every eyebrow is at least 40px (invariant 5). Title tracking is never
94
+ * positive: `fitText` measures untracked, so only a tightening is free.
95
+ */
96
+ export declare const TYPES: Readonly<Record<string, TypeSpec>>;
97
+ /**
98
+ * The spec a theme's stack names, or undefined for classic.
99
+ *
100
+ * Exact, after the one prefix `deckLook` adds: a CJK deck puts its bundled Noto
101
+ * family first and keeps the pack's stack behind it. A stack that merely
102
+ * CONTAINS a spec's families — a user's own theme — is not that spec.
103
+ */
104
+ export declare function typeForStack(fontStack: string): TypeSpec | undefined;
105
+ /** One line box, the way `title.ts` has always rounded it. */
106
+ export declare function lineBox(size: number, lh: number): number;
107
+ /**
108
+ * A tracking value as the CSS this project has always written: `.14em`,
109
+ * `-.015em`, `0`. Leading zero dropped, so the classic chrome is byte-identical.
110
+ */
111
+ export declare function em(tracking: number): string;