@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
@@ -1,6 +1,6 @@
1
1
  import { type DeckNarration } from "./emit/composition.js";
2
2
  import type { Cut } from "./plan/select.js";
3
- import { type Format, type Source, type Storyboard } from "./types.js";
3
+ import { type Design, type Format, type Source, type Storyboard } from "./types.js";
4
4
  /**
5
5
  * Markdown (and LaTeX-ish markdown) in, typed `Source` out. The one parser we
6
6
  * have; a consumer that already holds structured text can skip it and hand
@@ -103,10 +103,22 @@ export type { Deck, DeckNarration, DeckOptions } from "./emit/composition.js";
103
103
  * itself, exported so it can be run against a budget no `Format` states.
104
104
  */
105
105
  export { selectBeats } from "./plan/select.js";
106
+ /**
107
+ * `--design v2`: the Director that picks each beat's look, the look vocabulary
108
+ * it picks from, and the sameness numbers it reports. Exported so an eval can
109
+ * measure a corpus of storyboards without building each one.
110
+ */
111
+ export { direct, summarize } from "./plan/direct.js";
112
+ export type { BeatLook, Direction, LookSummary } from "./plan/direct.js";
113
+ export { candidates, classicLook, LOOK_FILE, signature, VARIANTS } from "./emit/look.js";
114
+ export type { Look, Placement } from "./emit/look.js";
106
115
  export type { Cut, Dangling, Dropped, DropRule, SelectionBudget } from "./plan/select.js";
107
116
  /** Themes are a named, closed set; a consumer needs to enumerate and validate. */
108
117
  export { resolveTheme, THEME_NAMES, THEMES } from "./emit/themes/index.js";
109
118
  export type { DeckTheme } from "./emit/themes/index.js";
119
+ export { PACKS } from "./emit/themes/packs.js";
120
+ export { chooseLook, type Design, type LookChoice, pickTheme, rankPacks, } from "./emit/themes/pick.js";
121
+ export { TYPES, type TypeSpec } from "./emit/type.js";
110
122
  /**
111
123
  * The gates. `verify` is ours plus HyperFrames'; `check` is HyperFrames' alone
112
124
  * and needs its CLI. A build that never opens a browser can still run `verify`
@@ -137,7 +149,25 @@ export type { CheckOptions } from "./verify/check.js";
137
149
  * verdict. `verify` runs it; nothing else has to.
138
150
  */
139
151
  export { gradeOverprint, MIN_OVERLAP, overprints } from "./verify/overprint.js";
152
+ /**
153
+ * Motion variety measured off a built composition: modal-entrance share, seam
154
+ * kinds, ease shares. The M4 exit metrics; see experiments/019-motion.
155
+ */
156
+ export { motionStats, topTwoEaseShare } from "./verify/motion.js";
157
+ export type { MotionStats } from "./verify/motion.js";
140
158
  export type { Overprint, Overprinted, TextRun } from "./verify/overprint.js";
159
+ /**
160
+ * The frame gate on its own, and the fill measure it carries: one browser over
161
+ * a built deck, every stop seeked and captured. `verify` runs it beside `check`;
162
+ * exported so an eval can read `fills` — main-axis fill at each scene's last
163
+ * hold, on a classic deck as on a v2 one — without running the slower half.
164
+ * `fillBand` and `FIT_FILE` are the definition and the manifest both sides use.
165
+ */
166
+ export { fidelity } from "./verify/fidelity.js";
167
+ export type { FidelityReport } from "./verify/fidelity.js";
168
+ export { fillBand, FIT_FILE } from "./emit/fit.js";
169
+ export type { Fit, FillBand, FitManifest } from "./emit/fit.js";
170
+ export type { FillRow } from "./verify/fill.js";
141
171
  /**
142
172
  * The determinism gate: render the deck twice and compare. Separate from
143
173
  * `verify` because it costs two full renders — minutes, not milliseconds — so
@@ -221,6 +251,10 @@ export interface BuildDeckOptions {
221
251
  onBeatWarning?: (beatId: string, warning: string) => void;
222
252
  /** Any name in `THEME_NAMES`. Overrides `storyboard.theme`. */
223
253
  theme?: string;
254
+ /** `classic` (default) or `v2`. Overrides `storyboard.design`. See `designSchema`. */
255
+ design?: Design;
256
+ /** v2: the seed the style pack is ranked by. Default `storyboard.sourceId`. */
257
+ packSeed?: string;
224
258
  /** Multiplies every duration and hold. 1 leaves the bytes untouched. */
225
259
  speed?: number;
226
260
  /** Default `FORMATS["deck-16x9"]`. */
@@ -0,0 +1,66 @@
1
+ import type { EmitContext, Scene, Theme } from "../emit/kit.js";
2
+ import { type Look } from "../emit/look.js";
3
+ import type { Archetype, Beat, Design, Format, Source } from "../types.js";
4
+ export interface DirectOptions {
5
+ source: Source;
6
+ format: Format;
7
+ theme: Theme;
8
+ /** Normally `storyboard.sourceId`. */
9
+ seed: string;
10
+ /**
11
+ * The design every candidate is emitted under. `v2` from `emitDeck`, so each
12
+ * look is measured as the fit engine will actually draw it; absent in tests
13
+ * that exercise the Director on its own.
14
+ */
15
+ design?: Design;
16
+ /** The emitter. `emitScene` unless a test substitutes one that misbehaves. */
17
+ emit?: (beat: Beat, ctx: EmitContext) => Scene;
18
+ }
19
+ /** One beat's decision, as `out/look.json` records it. */
20
+ export interface BeatLook {
21
+ beat: string;
22
+ archetype: Archetype;
23
+ variant: string;
24
+ placement: Look["placement"];
25
+ signature: string;
26
+ /** The chosen look's `Scene.fill`, when its emitter reports one. */
27
+ fill?: number;
28
+ /** Candidates that drew and kept the classic timing. Always ≥ 1. */
29
+ viable: number;
30
+ /** Candidates that were refused, and why — the emitter's own sentence. */
31
+ refused: {
32
+ signature: string;
33
+ reason: string;
34
+ }[];
35
+ }
36
+ export interface Direction {
37
+ /** One per beat, in order: what `EmitContext.look` is set to. */
38
+ looks: Look[];
39
+ beats: BeatLook[];
40
+ summary: LookSummary;
41
+ }
42
+ export interface LookSummary {
43
+ beats: number;
44
+ distinct: number;
45
+ /** Share of beats whose chrome sits on top of the slide (title excluded from the numerator). */
46
+ modalChrome: number;
47
+ /** Adjacent pairs with the same signature. */
48
+ adjacentRepeats: number;
49
+ /** The four most-used signatures' share of beats. */
50
+ top4: number;
51
+ }
52
+ export declare function direct(beats: readonly Beat[], opts: DirectOptions): Direction;
53
+ /**
54
+ * The least share of the classic look's figure area another look may draw the
55
+ * figure at. The review (2026-10-08) measured v2's rail and foot looks shrinking
56
+ * the paper's own figure to 43-61% of its classic area (ja s4, ko s6, en s5) to
57
+ * make room for a headline: fill went up because white plate and bigger text
58
+ * count as ink, and the most informative thing on the slide got smaller. A
59
+ * look that keeps 80% or more is still a different slide; one below it is not
60
+ * worth the variety.
61
+ */
62
+ export declare const FIGURE_FLOOR = 0.8;
63
+ /** The sameness numbers for one deck. Also used by the corpus measurement. */
64
+ export declare function summarize(beats: readonly Pick<BeatLook, "archetype" | "signature" | "placement">[]): LookSummary;
65
+ /** 32-bit FNV-1a. Deterministic, dependency-free, and enough to spread ties. */
66
+ export declare function fnv1a(s: string): number;
@@ -1,5 +1,5 @@
1
1
  import type { z } from "zod";
2
- import { prefsSchema, type Source } from "./types.js";
2
+ import { type Design, prefsSchema, type Source } from "./types.js";
3
3
  export type Prefs = z.infer<typeof prefsSchema>;
4
4
  /**
5
5
  * A partial Prefs. `narration` and `images` are the two nested objects, so they
@@ -33,6 +33,16 @@ export declare function loadPrefs(overrides?: PrefsPatch, cwd?: string, source?:
33
33
  * schema, so there is exactly one place that decides what a legal tone is.
34
34
  */
35
35
  export declare function prefsFromFlags(flags: PrefFlags): PrefsPatch;
36
+ /**
37
+ * The design a build uses: a `--design` flag or a config file, else what the
38
+ * storyboard was planned under, else `classic`.
39
+ *
40
+ * NOT `stated()`, which treats a value at its default as unsaid. `design` has
41
+ * no default — it is optional in the schema — so an explicit `classic` is
42
+ * distinguishable from silence, and it IS the rollback: it has to win over a
43
+ * storyboard planned under v2.
44
+ */
45
+ export declare function designFor(prefs: Pick<Prefs, "design">, recorded?: Design): Design;
36
46
  /** The flag surface the CLI exposes. Every one optional: absent means unstated. */
37
47
  export interface PrefFlags {
38
48
  slides?: string | number;
@@ -42,6 +52,10 @@ export interface PrefFlags {
42
52
  genre?: string;
43
53
  duration?: string | number;
44
54
  theme?: string;
55
+ /** `--design classic | v2`. */
56
+ design?: string;
57
+ /** `--pack-seed`: what v2 hashes to pick a pack. */
58
+ packSeed?: string;
45
59
  speed?: string | number;
46
60
  narrate?: boolean;
47
61
  voice?: string;
@@ -6,10 +6,20 @@ export interface FontBundle {
6
6
  /** The woff2 basenames written into `dir`, beside `fonts.css`. */
7
7
  files: string[];
8
8
  }
9
+ /**
10
+ * The serif companion to `familyFor`: what a v2 pack whose type spec sets a role
11
+ * in a serif draws that role's CJK in. Without it every CJK deck set its
12
+ * headlines in Noto Sans whatever the pack said, so the font change that makes
13
+ * a serif pack a serif pack disappeared for three languages out of four
14
+ * (review, 2026-10-08).
15
+ */
16
+ export declare function serifFamilyFor(lang: string): string | null;
9
17
  /** The CJK family a language needs subset, or null: the deck ships Inter (`vendorInter`). */
10
18
  export declare function familyFor(lang: string): string | null;
11
19
  /**
12
20
  * Write `dir/fonts.css` plus its woff2 for `lang`, covering `glyphs`. Returns
13
21
  * null for languages the renderer already resolves, which needs no bundle.
14
22
  */
15
- export declare function bundleFont(lang: string, glyphs: string, dir: string): Promise<FontBundle | null>;
23
+ export declare function bundleFont(lang: string, glyphs: string, dir: string, opts?: {
24
+ serif?: boolean;
25
+ }): Promise<FontBundle | null>;
@@ -1001,11 +1001,35 @@ export declare const DIAGRAMMATIC: ReadonlySet<string>;
1001
1001
  */
1002
1002
  export type ArchetypeFamily = "frame" | "structure" | "quantity" | "formal";
1003
1003
  export declare const ARCHETYPE_FAMILY: Readonly<Record<Archetype, ArchetypeFamily>>;
1004
+ /**
1005
+ * Which generation of the look a deck is built in.
1006
+ *
1007
+ * `classic` is every deck built before 0.9 and stays byte-identical: it is what
1008
+ * an unset design means, so an npm user and every ink golden see no change. `v2`
1009
+ * opts into the redesign: the v2 player in deck.html (speed, CC, caption size,
1010
+ * remembered preferences — src/deck/playback.ts), the fit engine
1011
+ * (src/emit/fit.ts), a style pack per deck (src/emit/themes/pick.ts), varied
1012
+ * layouts (src/plan/direct.ts) and the motion grammar (src/emit/motion.ts). One
1013
+ * switch rather than one flag per feature, so a deck is always one coherent
1014
+ * design and `--design classic` is a complete rollback.
1015
+ *
1016
+ * OPTIONAL rather than defaulted in both schemas, so a storyboard, a config or
1017
+ * a pack that never mentions it serialises exactly as it did.
1018
+ */
1019
+ export declare const designSchema: z.ZodEnum<{
1020
+ classic: "classic";
1021
+ v2: "v2";
1022
+ }>;
1023
+ export type Design = z.infer<typeof designSchema>;
1004
1024
  export declare const storyboardSchema: z.ZodObject<{
1005
1025
  sourceId: z.ZodString;
1006
1026
  title: z.ZodString;
1007
1027
  lang: z.ZodDefault<z.ZodString>;
1008
1028
  theme: z.ZodDefault<z.ZodString>;
1029
+ design: z.ZodOptional<z.ZodEnum<{
1030
+ classic: "classic";
1031
+ v2: "v2";
1032
+ }>>;
1009
1033
  beats: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
1010
1034
  narration: z.ZodOptional<z.ZodString>;
1011
1035
  archetype: z.ZodLiteral<"title">;
@@ -1600,6 +1624,11 @@ export declare const prefsSchema: z.ZodObject<{
1600
1624
  }>>;
1601
1625
  duration: z.ZodOptional<z.ZodNumber>;
1602
1626
  theme: z.ZodDefault<z.ZodString>;
1627
+ design: z.ZodOptional<z.ZodEnum<{
1628
+ classic: "classic";
1629
+ v2: "v2";
1630
+ }>>;
1631
+ packSeed: z.ZodOptional<z.ZodString>;
1603
1632
  animationSpeed: z.ZodDefault<z.ZodNumber>;
1604
1633
  narration: z.ZodDefault<z.ZodObject<{
1605
1634
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -1739,6 +1768,11 @@ export declare const packSchema: z.ZodObject<{
1739
1768
  }>>;
1740
1769
  duration: z.ZodOptional<z.ZodNumber>;
1741
1770
  theme: z.ZodDefault<z.ZodString>;
1771
+ design: z.ZodOptional<z.ZodEnum<{
1772
+ classic: "classic";
1773
+ v2: "v2";
1774
+ }>>;
1775
+ packSeed: z.ZodOptional<z.ZodString>;
1742
1776
  animationSpeed: z.ZodDefault<z.ZodNumber>;
1743
1777
  narration: z.ZodDefault<z.ZodObject<{
1744
1778
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -1807,6 +1841,10 @@ export declare const packSchema: z.ZodObject<{
1807
1841
  title: z.ZodString;
1808
1842
  lang: z.ZodDefault<z.ZodString>;
1809
1843
  theme: z.ZodDefault<z.ZodString>;
1844
+ design: z.ZodOptional<z.ZodEnum<{
1845
+ classic: "classic";
1846
+ v2: "v2";
1847
+ }>>;
1810
1848
  beats: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
1811
1849
  narration: z.ZodOptional<z.ZodString>;
1812
1850
  archetype: z.ZodLiteral<"title">;
@@ -140,6 +140,7 @@
140
140
  * a gate nobody runs; this one costs nothing, so it runs by default.
141
141
  */
142
142
  import type { Finding } from "../types.js";
143
+ import { type FillRow } from "./fill.js";
143
144
  /**
144
145
  * Ink at a stop, as a fraction of the WHOLE frame — not of the measured band.
145
146
  *
@@ -213,6 +214,11 @@ export interface FidelityOptions {
213
214
  export declare function readReserve(timingText: string | null): number;
214
215
  export interface FidelityReport {
215
216
  stops: Measured[];
217
+ /**
218
+ * Main-axis fill at each scene's last hold — measured on every deck, graded
219
+ * only on a v2 one (`fit.json` present). See `./fill.ts`.
220
+ */
221
+ fills: FillRow[];
216
222
  /**
217
223
  * Both gates' findings — this one's `blank_at_stop` and `overprint`'s
218
224
  * `svg_text_overprint`.
@@ -252,6 +258,34 @@ export declare function decodePng(png: Uint8Array): Promise<Frame>;
252
258
  * of those have been wrong in this project inside the last week.
253
259
  */
254
260
  export declare function inkBelow(frame: Frame, bandTopPx: number): number;
261
+ /**
262
+ * Where a scene's BODY is, in device px: the part of the frame that is not its
263
+ * chrome. Classic chrome is a block across the top, so the body is everything
264
+ * below its bottom edge — exactly what `inkBelow` measured, and still is. The
265
+ * `--design v2` placements (src/emit/look.ts) move the chrome, and "below the
266
+ * headline" stops meaning "the body": under a `foot` headline there is nothing
267
+ * at all by design, and beside a `rail` the body is the column to its right.
268
+ * Measured where the body is, rather than below a headline that is no longer
269
+ * on top, the same floor means the same thing.
270
+ */
271
+ export interface BodyRegion {
272
+ top: number;
273
+ left: number;
274
+ bottom: number;
275
+ }
276
+ /**
277
+ * Non-background pixels inside `region`, over the WHOLE frame's pixels.
278
+ *
279
+ * `plate` is the deck's bare background (every scene hidden), when the build
280
+ * captured one — a v2 deck does, for the fill gate. Against it each pixel is
281
+ * compared with the same pixel of the empty ground. Against the frame's modal
282
+ * colour alone a style pack's painted ground — signal's glow, blueprint's grid
283
+ * — read as ink: with every scene hidden, 4.42% of a signal frame and 2.40% of
284
+ * a blueprint one measured "inked", 29x and 16x `INK_FLOOR`, so a stop with
285
+ * nothing drawn on it passed this gate by a wide margin (review, 2026-10-08).
286
+ * A plate of another size is ignored.
287
+ */
288
+ export declare function inkIn(frame: Frame, region: BodyRegion, plate?: Frame | null): number;
255
289
  /**
256
290
  * The stops a built deck declares, from `timing.json` if it is there and the
257
291
  * slideshow island if it is not.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * THE FILL GATE — at each scene's last hold, how much of its region did the
3
+ * body actually paint, and did the build predict that?
4
+ *
5
+ * WHY THIS EXISTS. The founder's complaint was "the content doesn't fill the
6
+ * screen", and v0.8.0 passes every gate while doing exactly that: the solvers
7
+ * could only shrink, so a two-bar comparison drew two 96px bars in a ~700px
8
+ * region and `.scene` centred the rest into air. The v2 archetypes grow into
9
+ * their region and PREDICT the result (`Scene.fit`, `src/emit/fit.ts`); a
10
+ * prediction nobody checks is the green-gate-over-wrong-output shape this
11
+ * project keeps producing, so the browser measures the same ratio here, on the
12
+ * frame `fidelity` has already seeked and captured.
13
+ *
14
+ * WHAT IS MEASURED: MAIN-AXIS FILL, IN PIXELS. The region is the room the body
15
+ * was given: `.scene`'s content box less the height of its `.headline` and
16
+ * `.eyebrow` (see `FillRegion` for why that is not "everything under the
17
+ * headline"). The body's extent is the first to the last pixel ROW, from the
18
+ * chrome's bottom edge down to the frame's bottom edge, that holds at least
19
+ * `MIN_ROW_INK` pixels off the frame's modal background (the same
20
+ * background-relative test `fidelity` uses, so it is theme-independent). Fill is
21
+ * that extent over the region's height. Scanning past the content box is
22
+ * deliberate: a body that spills into the padding reads above 1, which is the
23
+ * SPILLAGE band rather than a silently clipped 1.0.
24
+ *
25
+ * PIXELS, NOT DOM BOXES, for the reason `fidelity.ts` gives at length: a
26
+ * wrapper's box is the size of its slot, not of what it shows — `.titleslide` is
27
+ * `height:100%`, `.panels` is `flex:1` — and visibility read off the DOM has been
28
+ * wrong in this project before. A panel wash too close to the background to
29
+ * clear `INK_DELTA` does not count; its border and its text do.
30
+ *
31
+ * ALSO REPORTED, for the eval and never graded: `cross`, the same ratio across
32
+ * the region's width, and `canvas`, the union box of ALL ink on the frame
33
+ * (chrome included) over the frame's area — the "bbox" share the 2026-10-07
34
+ * density audit quoted for bar-compare and pipeline (51%).
35
+ *
36
+ * AND TWO AREA MEASURES, because `fill` is an EXTENT and cannot see area: two
37
+ * thin bars spanning a 1700px axis, a white figure plate, or cards with empty
38
+ * bottom halves all read full (review, 2026-10-08). `occupancy` cuts the whole
39
+ * frame (less a 3% border) into 40px cells, at 1080p scale, and reports the
40
+ * share holding ink (`cells`) and the share whose pixels VARY (`detail`): a
41
+ * flat run of colour — a plate's empty white, a panel's wash, a bar's inside —
42
+ * is ink but not detail. Both are placement-independent: they do not move when
43
+ * a look redefines where its body region is.
44
+ *
45
+ * GRADED ONLY ON A V2 DECK, i.e. one whose build wrote `fit.json`. A classic deck
46
+ * is measured (so the eval can compare the two designs on the same storyboard)
47
+ * but never gets a finding: v0.8.0's verdicts do not move. Both findings are
48
+ * WARNINGS, as the plan's M1 sets them — a new gate starts by reporting:
49
+ *
50
+ * hollow_at_hold measured fill under 0.70, the EMPTY band
51
+ * fill_model_disagrees |predicted − measured| over 0.20 — the build-time
52
+ * arithmetic and the browser disagree, which is a bug
53
+ * in the model, not a matter of taste
54
+ */
55
+ import { type FitManifest } from "../emit/fit.js";
56
+ import type { Finding } from "../types.js";
57
+ /** A row counts as painted from this many off-background pixels: one is a stray fringe. */
58
+ export declare const MIN_ROW_INK = 2;
59
+ /** Predicted and measured fill may differ by this much before the model is called wrong. */
60
+ export declare const FILL_TOLERANCE = 0.2;
61
+ /** Device px, in the frame's own coordinates. */
62
+ export interface Box {
63
+ left: number;
64
+ right: number;
65
+ top: number;
66
+ bottom: number;
67
+ }
68
+ /** The pixel test `fidelity` uses: what counts as ink, and against what. */
69
+ export interface InkTest {
70
+ bg: readonly [number, number, number];
71
+ delta: number;
72
+ /**
73
+ * The page's background as a FRAME — every scene hidden, the body's own paint
74
+ * left — compared pixel for pixel instead of against `bg`. A v2 style pack
75
+ * paints its ground with gradients, a grid or dots (src/emit/themes/packs.ts),
76
+ * so no single colour is the background: a 7% cyan grid line on blueprint is
77
+ * 12 levels off its navy, and atlas's vignette darkens the corners by far more.
78
+ * Against one colour those read as ink and a sparse slide measured full. When
79
+ * its size differs from the frame's it is ignored and `bg` is used.
80
+ */
81
+ plate?: FrameLike;
82
+ }
83
+ interface FrameLike {
84
+ width: number;
85
+ height: number;
86
+ channels: number;
87
+ pixels: Uint8Array;
88
+ }
89
+ /**
90
+ * The extent of ink inside `box`, or null when there is none. Pure, so what the
91
+ * gate counts can be tested without a browser.
92
+ *
93
+ * A row is painted when it holds `MIN_ROW_INK` ink pixels between `box.left` and
94
+ * `box.right`; the vertical extent is the first and last such row. The
95
+ * horizontal extent is then read over those rows only, by the same rule turned
96
+ * ninety degrees.
97
+ */
98
+ export declare function inkExtent(frame: FrameLike, test: InkTest, box: Box): Box | null;
99
+ /**
100
+ * A scene's body region, as the page reports it: where to LOOK for the body's
101
+ * ink (`top` is the chrome's bottom edge, `bottom` the content box's), and how
102
+ * tall the region IS (`height`).
103
+ *
104
+ * THE TWO DIFFER ON PURPOSE, and the difference is the defect being measured.
105
+ * `.scene` centres its column, so a short body pushes the headline DOWN by half
106
+ * the slack — and a region drawn from the headline's bottom edge to the content
107
+ * bottom silently drops the other half, the band of nothing ABOVE the headline.
108
+ * The first version did exactly that and called a two-bar chart with 250px of
109
+ * empty canvas over its eyebrow 70% full. So the height is the content box less
110
+ * the chrome's own height, wherever the centring put it: the room the body was
111
+ * given, which is the same number `bodyBudget` hands the emitter.
112
+ */
113
+ export interface FillRegion extends Box {
114
+ height: number;
115
+ /**
116
+ * How far down body ink is looked for. Absent: the frame's bottom edge, so a
117
+ * body that spills past its region is measured, not clipped. A `foot` slide
118
+ * sets it to the region's bottom, because what lies under that region is the
119
+ * slide's own headline, not spill.
120
+ */
121
+ scanBottom?: number;
122
+ /**
123
+ * The page's own background, `[r, g, b]`, or null when it is not an opaque
124
+ * colour. What "ink" is measured against — see `fillInk`.
125
+ */
126
+ bg: [number, number, number] | null;
127
+ }
128
+ /**
129
+ * Run IN THE PAGE: the scene's body region, in device px. Null when the scene
130
+ * is not in the document.
131
+ *
132
+ * The content box is `.scene`'s padding box, read off computed style rather
133
+ * than restated from `kit.ts`, so a deck built with a caption reserve (bottom
134
+ * padding grows) is measured against the box it actually laid out in.
135
+ */
136
+ export declare function collectFillRegion(sid: string): FillRegion | null;
137
+ /**
138
+ * The ink test for a fill measurement: the page's declared background when the
139
+ * region reported one, else the frame's modal colour, at `delta`.
140
+ *
141
+ * NOT the modal colour by default, and the reason is this gate's own success.
142
+ * `fidelity` takes the background to be the frame's most common colour, which
143
+ * holds while a slide is mostly air — "80–98% of every frame measured". A v2
144
+ * callout whose panels grew into their region covers MORE of the frame with
145
+ * panel wash than with background; the modal colour is then the panel's, the
146
+ * real background reads as ink, and a 0.95 hold measured 1.107 with a canvas
147
+ * share of 1.0 (HypePaper ko deck 894a874a, s18, 2026-10-07). The body
148
+ * stylesheet says what the background is, so that is what is asked.
149
+ */
150
+ export declare function fillInk(region: FillRegion | null, modal: readonly [number, number, number], delta: number, plate?: FrameLike | null): InkTest;
151
+ /**
152
+ * Run IN THE PAGE: hide (`true`) or restore (`false`) every scene, so one
153
+ * screenshot between the two is the deck's bare background — `InkTest.plate`.
154
+ * Opacity on the scene roots rather than visibility, because GSAP's `autoAlpha`
155
+ * writes `visibility:visible` on descendants and would show through.
156
+ */
157
+ export declare function hideScenes(on: boolean): void;
158
+ /** One scene's last hold, measured. */
159
+ export interface FillRow {
160
+ sid: string;
161
+ t: number;
162
+ /** Main-axis (vertical) fill: body ink extent over region height. Above 1 spills. */
163
+ fill: number;
164
+ /** The same across the region's width. Reported, not graded. */
165
+ cross: number;
166
+ /** Union box of every ink pixel on the frame, chrome included, over the frame's area. */
167
+ canvas: number;
168
+ /** Region height in device px — 0 when the scene had no region to measure. */
169
+ region: number;
170
+ /** Share of the frame's cells holding ink, and of cells whose pixels vary. See `occupancy`. */
171
+ cells: number;
172
+ detail: number;
173
+ }
174
+ /**
175
+ * How much of the frame carries content, by area. Pure.
176
+ *
177
+ * `cells`: share of cells with at least `CELL_MIN_INK` of their pixels off the
178
+ * ground (the ink test `fidelity` uses — the bare-background plate when there is
179
+ * one). `detail`: share of cells that hold ink AND whose pixels span more than
180
+ * the ink threshold in luma — an edge, a glyph, a picture — so a flat slab of
181
+ * one colour counts as ink and not as detail.
182
+ */
183
+ export declare function occupancy(frame: FrameLike, test: InkTest): {
184
+ cells: number;
185
+ detail: number;
186
+ };
187
+ /**
188
+ * Fill at one stop, from a decoded frame and the region the page reported.
189
+ * Pure. A scene with no region (not found) or no ink reads 0, which grades as
190
+ * hollow — "nothing painted below the headline" is exactly what that is.
191
+ */
192
+ export declare function measureFill(frame: FrameLike, test: InkTest, region: FillRegion | null, stop: {
193
+ sid: string;
194
+ t: number;
195
+ }): FillRow;
196
+ /** The last declared stop of each scene: the final hold the fill is judged at. */
197
+ export declare function finalStops<S extends {
198
+ sid: string;
199
+ t: number;
200
+ }>(stops: readonly S[]): S[];
201
+ /** Parse `fit.json`. Null when the deck has none — a classic build — or it does not parse. */
202
+ export declare function readFitManifest(text: string | null): FitManifest | null;
203
+ /** Findings for a v2 deck's measured fills. Nothing at all for a classic deck. */
204
+ export declare function gradeFill(rows: readonly FillRow[], manifest: FitManifest | null): Finding[];
205
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Motion variety, measured off a built composition — the M4 exit metrics.
3
+ *
4
+ * Read from the emitted `index.html` rather than from the in-memory plan, so the
5
+ * number is about what ships: a plan that said "push" and an emitter that wrote
6
+ * a dissolve would read as a dissolve here. The three numbers are the ones the
7
+ * redesign plan set against v0.8.0's audit of 124 HypePaper scenes:
8
+ *
9
+ * | metric | v0.8.0 | v2 target |
10
+ * |--------------------------------|--------|-----------|
11
+ * | modal entrance (share) | 92% | ≤ 40% |
12
+ * | seam kinds (deck of 10+ beats) | 1 | ≥ 3 |
13
+ * | top-2 ease share of tweens | 85% | ≤ 60% |
14
+ *
15
+ * MODAL is deliberately generous to the old look: a scene "opens modal" when its
16
+ * headline enters as a bare opacity + y fade-up, whatever the ease. v2's `rise`
17
+ * verb therefore counts as modal, which is why `planMotion` caps it.
18
+ */
19
+ export interface MotionStats {
20
+ scenes: number;
21
+ /** Scenes whose headline (`#sN-h`, or the title's `#sN-t`) enters as opacity + y only. */
22
+ modalScenes: number;
23
+ /** Scenes with at least one emphasis (glow, underline, or a sine-eased pulse) after the build. */
24
+ emphasisScenes: number;
25
+ /** Seam kind per handoff, in scene order. */
26
+ seams: string[];
27
+ /** Tween count per ease name; a tween naming none is `"default"`. */
28
+ eases: Record<string, number>;
29
+ tweens: number;
30
+ }
31
+ export declare function motionStats(composition: string): MotionStats;
32
+ /** The share of tweens carried by the two most common eases. */
33
+ export declare function topTwoEaseShare(stats: MotionStats): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jokerized/decksmith",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Turn a source document into an animated explanation deck.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,7 +58,10 @@
58
58
  "demo": "node demo/build.mjs && node dist/cli.js build demo/storyboard.json --source demo/source.json -o demo/deck && python3 -m http.server 8080 --directory demo/deck --bind 127.0.0.1"
59
59
  },
60
60
  "dependencies": {
61
+ "@fontsource-variable/ibm-plex-sans": "5.3.0",
61
62
  "@fontsource-variable/inter": "5.3.0",
63
+ "@fontsource-variable/source-serif-4": "5.3.0",
64
+ "@fontsource-variable/space-grotesk": "5.3.0",
62
65
  "@modelcontextprotocol/sdk": "^1.30.0",
63
66
  "@puppeteer/browsers": "^3.0.6",
64
67
  "commander": "^14.0.1",