@jokerized/decksmith 0.2.0 → 0.3.1

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/dist/cli.js CHANGED
@@ -110,6 +110,13 @@ var equationWalkParamsSchema = z.object({
110
110
  /** Walked in order, one hold-point each. */
111
111
  terms: z.array(termSchema).min(1).max(4)
112
112
  });
113
+ var equationMorphParamsSchema = z.object({
114
+ eyebrow: z.string().optional(),
115
+ headline: z.string(),
116
+ fromId: z.string(),
117
+ toId: z.string(),
118
+ terms: z.array(termSchema).min(1).max(4)
119
+ });
113
120
  var dataTableParamsSchema = z.object({
114
121
  eyebrow: z.string().optional(),
115
122
  headline: z.string(),
@@ -238,7 +245,22 @@ var stackParamsSchema = z.object({
238
245
  headline: z.string(),
239
246
  /** Drawn bottom-up as offset planes, revealed in order. */
240
247
  layers: z.array(z.object({ label: z.string(), note: z.string().optional() })).min(2).max(7),
241
- note: z.string().optional()
248
+ note: z.string().optional(),
249
+ /**
250
+ * Tilt the slab stack away from the viewer, so the planes read as stacked in
251
+ * depth rather than merely offset up the page.
252
+ *
253
+ * OPTIONAL, and absent means flat — every storyboard written before this
254
+ * existed still parses, and every deck that does not ask for it emits exactly
255
+ * the bytes it did before.
256
+ *
257
+ * Degrees, and bounded at 18 rather than by taste: the tilt is paid for in
258
+ * declared type, because perspective shrinks the far half of the plane and
259
+ * invariant 5 is about what the audience SEES. At 18 degrees a 40px floor
260
+ * already needs 53.8px declared (`src/emit/depth.ts`), and past that a
261
+ * headline cannot spend enough and still fit its own line.
262
+ */
263
+ tilt: z.number().min(0).max(18).optional()
242
264
  });
243
265
  var splitSideSchema = z.object({
244
266
  label: z.string(),
@@ -271,10 +293,16 @@ var insideSchema = z.object({
271
293
  */
272
294
  label: z.string().optional()
273
295
  });
296
+ var beatRoleSchema = z.enum(["intro", "background", "limitations", "conclusion"]);
274
297
  var beatCore = {
275
298
  id: z.string(),
276
299
  /** What the viewer should understand after this beat. */
277
300
  intent: z.string(),
301
+ /**
302
+ * OPTIONAL, and only ever present when `prefs.genre` is `paper`. The
303
+ * structural job this beat does; see `beatRoleSchema`.
304
+ */
305
+ role: beatRoleSchema.optional(),
278
306
  /** Optional: this beat happens inside a named part of the beat before it. */
279
307
  inside: insideSchema.optional(),
280
308
  /** The source sentence or equation this beat is accountable to. */
@@ -303,6 +331,12 @@ var beatSchema = z.discriminatedUnion("archetype", [
303
331
  params: equationWalkParamsSchema,
304
332
  ...beatTail
305
333
  }),
334
+ z.object({
335
+ ...beatCore,
336
+ archetype: z.literal("equation-morph"),
337
+ params: equationMorphParamsSchema,
338
+ ...beatTail
339
+ }),
306
340
  z.object({
307
341
  ...beatCore,
308
342
  archetype: z.literal("data-table"),
@@ -357,6 +391,7 @@ var DIAGRAMMATIC = /* @__PURE__ */ new Set([
357
391
  "stack",
358
392
  "split-compare",
359
393
  "equation-walk",
394
+ "equation-morph",
360
395
  "line-chart"
361
396
  ]);
362
397
  var ARCHETYPE_FAMILY = {
@@ -371,7 +406,8 @@ var ARCHETYPE_FAMILY = {
371
406
  "bar-compare": "quantity",
372
407
  "line-chart": "quantity",
373
408
  "data-table": "quantity",
374
- "equation-walk": "formal"
409
+ "equation-walk": "formal",
410
+ "equation-morph": "formal"
375
411
  };
376
412
  var storyboardSchema = z.object({
377
413
  sourceId: z.string(),
@@ -413,6 +449,26 @@ var prefsSchema = z.object({
413
449
  tone: z.enum(["plain", "academic", "conversational", "punchy"]).default("plain"),
414
450
  /** How much text a slide may carry before it should have been a diagram. */
415
451
  density: z.enum(["sparse", "normal", "dense"]).default("normal"),
452
+ /**
453
+ * What kind of document is being explained, DECLARED and never sniffed.
454
+ *
455
+ * `paper` asks the planner for the shape a research talk has: open on the
456
+ * problem and the ground the work stands on, close on what it does not do and
457
+ * then what to take away. `general` is every deck built before this existed
458
+ * and changes nothing — no prompt block, no `role` in the planner's schema, no
459
+ * scan.
460
+ *
461
+ * WHY DECLARED. A ten-role heading lexicon (en/ko/ja/zh, numbered-prefix
462
+ * tolerant) run over all 351 markdown files in this repository scored 345 of
463
+ * them at zero role hits and none at three or more. `src/source/markdown.ts`
464
+ * says why in its first line: the input is a hypepaper-style ANALYSIS of a
465
+ * paper, a rewrite that has already discarded the headings a detector would
466
+ * key on. A classifier here would be a guess with a confidence score attached,
467
+ * and it would guess wrong on the Korean fixture. So the author says so once —
468
+ * `--genre paper`, or one line in a `decksmith.config.json` above a directory
469
+ * of papers — and every run under it costs no further typing.
470
+ */
471
+ genre: z.enum(["general", "paper"]).default("general"),
416
472
  /**
417
473
  * How long the finished thing should run, in seconds. Optional: absent means
418
474
  * "as long as it takes", which is what every deck built before this did.
@@ -673,7 +729,10 @@ function selectBeats(storyboard, budget2, seconds = {}) {
673
729
  const protectedIds = protect(live, len, cap);
674
730
  const keep = knapsack(live, len, cap, protectedIds);
675
731
  if (!keep) {
676
- const all = knapsack(live, len, cap, /* @__PURE__ */ new Set());
732
+ const ends = new Set(
733
+ [live[0]?.id, live[live.length - 1]?.id].filter((id2) => !!id2)
734
+ );
735
+ const all = knapsack(live, len, cap, ends) ?? knapsack(live, len, cap, /* @__PURE__ */ new Set());
677
736
  const chosen = all ?? [live[0]];
678
737
  return budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, false);
679
738
  }
@@ -705,8 +764,17 @@ function protect(live, len, cap) {
705
764
  }
706
765
  const picture = cheapest && !ids.has(cheapest.id) ? [cheapest] : [];
707
766
  for (const b of picture) ids.add(b.id);
767
+ const byId = new Map(live.map((b) => [b.id, b]));
768
+ const roled = [];
769
+ for (const b of live) {
770
+ if (!b.role) continue;
771
+ for (let hop = b; hop && !ids.has(hop.id); hop = byId.get(hop.inside?.beat ?? "")) {
772
+ ids.add(hop.id);
773
+ roled.push(hop);
774
+ }
775
+ }
708
776
  const ends = /* @__PURE__ */ new Set([first?.id, last?.id]);
709
- const releasable = [...tier, ...coverage, ...picture].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
777
+ const releasable = [...tier, ...coverage, ...picture, ...roled].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
710
778
  const cost = () => live.filter((b) => ids.has(b.id)).reduce((s, b) => s + len(b), 0);
711
779
  for (const b of releasable) {
712
780
  if (cost() <= cap) break;
@@ -3079,7 +3147,7 @@ function locate(tex, term) {
3079
3147
  end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
3080
3148
  return { start, end: Math.min(end, tex.length) };
3081
3149
  }
3082
- function wrapTerms(tex, terms, beatId) {
3150
+ function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
3083
3151
  let parts = [{ text: tex, raw: true }];
3084
3152
  const used = [];
3085
3153
  const missing = [];
@@ -3095,7 +3163,7 @@ function wrapTerms(tex, terms, beatId) {
3095
3163
  1,
3096
3164
  { text: part.text.slice(0, at.start), raw: true },
3097
3165
  {
3098
- text: `\\htmlClass{term t-${term.tone}}{${part.text.slice(at.start, at.end)}}`,
3166
+ text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
3099
3167
  raw: false
3100
3168
  },
3101
3169
  { text: part.text.slice(at.end), raw: true }
@@ -3128,6 +3196,24 @@ function statements(tex, stacked) {
3128
3196
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
3129
3197
  return parts.length > 0 ? parts : [tex];
3130
3198
  }
3199
+ function legendRows(sid, terms, theme) {
3200
+ return terms.map(
3201
+ (t2) => `<div class="leg" id="${sid}-leg-${t2.tone}"><span class="chip" id="${sid}-chip-${t2.tone}" style="color:${theme.tones[t2.tone]}"></span><span>${esc(t2.label)}</span></div>`
3202
+ ).join("\n ");
3203
+ }
3204
+ function legendCss(theme) {
3205
+ return [
3206
+ // `width:fit-content` + auto margins, not `align-items:center`: centring
3207
+ // each row individually gave the legend a ragged left edge, because a short
3208
+ // label indented its own chip further than a long one did. The column is
3209
+ // centred as one block and the rows start on a shared spine.
3210
+ ".legend{display:flex;flex-direction:column;gap:30px;width:fit-content;margin-inline:auto}",
3211
+ `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
3212
+ // A common chip width, so the labels share a spine too — the glyphs inside
3213
+ // are one symbol each and their natural widths differ by a few pixels.
3214
+ `.chip{display:inline-block;min-width:72px;text-align:center;background:${theme.panel};border-radius:10px;padding:2px 20px;white-space:nowrap;font-weight:700}`
3215
+ ].join("\n");
3216
+ }
3131
3217
  var equationWalk = (beat, ctx) => {
3132
3218
  const { sid, theme } = ctx;
3133
3219
  const p = beat.params;
@@ -3139,9 +3225,7 @@ var equationWalk = (beat, ctx) => {
3139
3225
  }
3140
3226
  const walk = wrapTerms(eq.tex, p.terms, beat.id);
3141
3227
  const terms = walk.used;
3142
- const legend = terms.map(
3143
- (t2) => `<div class="leg" id="${sid}-leg-${t2.tone}"><span class="chip" id="${sid}-chip-${t2.tone}" style="color:${theme.tones[t2.tone]}"></span><span>${esc(t2.label)}</span></div>`
3144
- ).join("\n ");
3228
+ const legend = legendRows(sid, terms, theme);
3145
3229
  const stacked = isPortrait(ctx.format);
3146
3230
  const raw2 = statements(eq.tex, stacked);
3147
3231
  const shown = statements(walk.tex, stacked);
@@ -3240,15 +3324,7 @@ var equationWalk = (beat, ctx) => {
3240
3324
  ".eqstack{display:flex;flex-direction:column;gap:32px}",
3241
3325
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
3242
3326
  ".term{display:inline-block}",
3243
- // `width:fit-content` + auto margins, not `align-items:center`: centring
3244
- // each row individually gave the legend a ragged left edge, because a short
3245
- // label indented its own chip further than a long one did. The column is
3246
- // centred as one block and the rows start on a shared spine.
3247
- ".legend{display:flex;flex-direction:column;gap:30px;width:fit-content;margin-inline:auto}",
3248
- `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
3249
- // A common chip width, so the labels share a spine too — the glyphs inside
3250
- // are one symbol each and their natural widths differ by a few pixels.
3251
- `.chip{display:inline-block;min-width:72px;text-align:center;background:${theme.panel};border-radius:10px;padding:2px 20px;white-space:nowrap;font-weight:700}`,
3327
+ legendCss(theme),
3252
3328
  // The block, not the term under discussion: which term that is, is a fact
3253
3329
  // about the paused timeline, and CSS cannot see it. The terms are also the
3254
3330
  // one thing here GSAP tints and swells, so a rule on them would win the
@@ -3258,6 +3334,117 @@ var equationWalk = (beat, ctx) => {
3258
3334
  };
3259
3335
  };
3260
3336
 
3337
+ // src/emit/archetypes/equation-morph.ts
3338
+ var MORPH_SECONDS = 1.6;
3339
+ var equationMorph = (beat, ctx) => {
3340
+ const { sid, theme } = ctx;
3341
+ const p = beat.params;
3342
+ const find2 = (id2) => {
3343
+ const eq = ctx.source.equations.find((e) => e.id === id2);
3344
+ if (!eq)
3345
+ throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3346
+ return eq;
3347
+ };
3348
+ const a = find2(p.fromId);
3349
+ const b = find2(p.toId);
3350
+ const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3351
+ if (both.length === 0) {
3352
+ throw new Error(
3353
+ `equation-morph ${beat.id}: none of its ${p.terms.length} term(s) occur in both equations. Terms: ${p.terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. From: ${JSON.stringify(a.tex)}. To: ${JSON.stringify(b.tex)}`
3354
+ );
3355
+ }
3356
+ const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
3357
+ const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
3358
+ const wb = wrapTerms(b.tex, both, beat.id, cls).tex;
3359
+ const size3 = Math.max(
3360
+ MIN_FONT,
3361
+ Math.min(
3362
+ equationSize(a.tex.length > b.tex.length ? a.tex : b.tex),
3363
+ Math.floor(contentW(ctx.format) / Math.max(texUnits(a.tex), texUnits(b.tex)))
3364
+ )
3365
+ );
3366
+ const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
3367
+ <div class="eqslide">
3368
+ <div class="morph" id="${sid}-morph" style="font-size:${size3}px">
3369
+ <div class="side" data-morph="a" id="${sid}-eqa"></div>
3370
+ <div class="side" data-morph="b" id="${sid}-eqb"></div>
3371
+ </div>
3372
+ <div class="legend">
3373
+ ${legendRows(sid, both, theme)}
3374
+ </div>
3375
+ </div>`;
3376
+ const setup = [
3377
+ `var OPTS = ${OPTS};`,
3378
+ `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
3379
+ `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
3380
+ ...both.map(
3381
+ (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
3382
+ )
3383
+ ];
3384
+ const first = 1.8;
3385
+ const at = Math.round(
3386
+ Math.max(2.6, Math.min(beat.seconds - MORPH_SECONDS - 0.9, beat.seconds * 0.45)) * 100
3387
+ ) / 100;
3388
+ const tl = [
3389
+ ...chromeIn(sid, p.eyebrow !== void 0),
3390
+ tween(`#${sid}-morph`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8),
3391
+ ...both.map(
3392
+ (t2, i) => tween(
3393
+ `#${sid}-leg-${t2.tone}`,
3394
+ { opacity: 0, x: -18 },
3395
+ { opacity: 1, x: 0, duration: 0.5 },
3396
+ 1 + i * 0.15
3397
+ )
3398
+ ),
3399
+ // ONE tween, on the host, driving the plugin. Its ease is "none" because the
3400
+ // plan carries its own eases per segment; `pace` scales this duration and
3401
+ // the plan, being in fractions of it, scales with it.
3402
+ tween(
3403
+ `#${sid}-morph`,
3404
+ { dsMorph: 0 },
3405
+ { dsMorph: 1, duration: MORPH_SECONDS, ease: "none" },
3406
+ at
3407
+ )
3408
+ ];
3409
+ return {
3410
+ html,
3411
+ tl,
3412
+ setup,
3413
+ // SEAM B: the plan is browser geometry after fonts, so it is built inside
3414
+ // the ready gate, and the plugin tween above finds it on the host.
3415
+ measure: [`DSMorph.build(document.getElementById("${sid}-morph"));`],
3416
+ plugins: ["dsMorph"],
3417
+ holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
3418
+ css: [
3419
+ chromeCss(theme),
3420
+ ".eqslide{display:flex;flex-direction:column;justify-content:space-evenly;gap:64px;flex:1;min-height:0}",
3421
+ ".katex-display{margin:0 !important}",
3422
+ // Both sides in one grid cell, so the host is as tall as the taller line
3423
+ // and neither needs a guessed height; the overlay is absolute over it.
3424
+ // Padded by the room an arc needs, so a bowing glyph stays inside its
3425
+ // offset parent and the layout gate's `escaped_container` stays quiet; the
3426
+ // bow is capped to the same 0.8em in `plan`.
3427
+ `.morph{position:relative;display:grid;place-items:center;text-align:center;padding:0.8em 0.5em;color:${theme.fg}}`,
3428
+ ".side{grid-area:1/1}",
3429
+ // B is measured, never seen: the runtime lifts its glyphs into the overlay
3430
+ // and drives them from there. Hidden by the sheet so nothing is captured
3431
+ // before the gate has built the plan.
3432
+ '.side[data-morph="b"]{visibility:hidden}',
3433
+ ".ds-morph-layer{position:absolute;inset:0}",
3434
+ ".term{display:inline-block}",
3435
+ // Keys are tinted from the start, on BOTH lines — the colour is what lets
3436
+ // a viewer follow a body across the move. Scoped under `.morph` because
3437
+ // `equation-walk` tweens `.t-<tone>` from the foreground colour, and a
3438
+ // bare rule on the class would win that cascade and cancel its walk.
3439
+ ...["a", "b", "c", "d"].map(
3440
+ (tone2) => `.morph .t-${tone2}{color:${theme.tones[tone2]}}`
3441
+ ),
3442
+ legendCss(theme),
3443
+ ambient(sid, "-morph", BREATHE)
3444
+ ].join("\n")
3445
+ };
3446
+ };
3447
+
3261
3448
  // src/emit/archetypes/grid.ts
3262
3449
  var LABEL = 42;
3263
3450
  var LH = 1.25;
@@ -4541,9 +4728,36 @@ var splitCompare = (beat, ctx) => {
4541
4728
  };
4542
4729
  };
4543
4730
 
4731
+ // src/emit/depth.ts
4732
+ var DEFAULT_POSE = { rotateX: 12, perspective: 1400 };
4733
+ function scaleAt(pose, dy) {
4734
+ const t2 = pose.rotateX * Math.PI / 180;
4735
+ const denom = pose.perspective - dy * Math.sin(t2);
4736
+ if (denom <= 0) return 0;
4737
+ return Math.cos(t2) * (pose.perspective / denom) ** 2;
4738
+ }
4739
+ var MODEL_SLACK = 0.98;
4740
+ function worstScale(pose, height) {
4741
+ if (scaleAt(pose, height / 2) <= 0) return 0;
4742
+ return scaleAt(pose, -height / 2) * MODEL_SLACK;
4743
+ }
4744
+ function tiltedFloor(pose, height, floor) {
4745
+ const s = worstScale(pose, height);
4746
+ return s > 0 ? floor / s : Number.POSITIVE_INFINITY;
4747
+ }
4748
+ function depthCss(sid, pose, part) {
4749
+ return [
4750
+ `#${sid} { perspective: ${pose.perspective}px; }`,
4751
+ `#${sid} ${part} { transform: rotateX(${pose.rotateX}deg); transform-origin: 50% 50%; }`
4752
+ ].join("\n");
4753
+ }
4754
+
4544
4755
  // src/emit/archetypes/stack.ts
4545
4756
  var GAP3 = 44;
4546
4757
  var NUM_X = 48;
4758
+ function numSpine(floor) {
4759
+ return Math.round(NUM_X * floor * 100 / MIN_FONT) / 100;
4760
+ }
4547
4761
  var PROBE_X = 16;
4548
4762
  var PROBE_H = 46;
4549
4763
  var NUM_W = 70;
@@ -4564,15 +4778,20 @@ function stackLayout(p, format, face = "latin") {
4564
4778
  function labelWeight(i, count) {
4565
4779
  return i === count - 1 ? 700 : 600;
4566
4780
  }
4781
+ function floorFor(p, format) {
4782
+ if (!p.tilt) return MIN_FONT;
4783
+ return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
4784
+ }
4567
4785
  function solve2(p, format, inline, face) {
4568
4786
  const width = contentW(format);
4569
4787
  const boxH = contentH(format);
4788
+ const floor = floorFor(p, format);
4570
4789
  const count = p.layers.length;
4571
4790
  const k = isPortrait(format) ? "tall" : "wide";
4572
4791
  const riseMax = RISE_MAX[k];
4573
4792
  const syMax = SY_MAX[k];
4574
4793
  const tMax = T_MAX[k];
4575
- const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, MIN_FONT, 400, 0, false, face) + 28 : 0;
4794
+ const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4576
4795
  const want = Math.max(
4577
4796
  ...p.layers.map(
4578
4797
  (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
@@ -4587,7 +4806,7 @@ function solve2(p, format, inline, face) {
4587
4806
  (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count), 0, false, face))
4588
4807
  )
4589
4808
  );
4590
- const labelSize = Math.max(MIN_FONT, Math.min(LABEL_SIZE2, labelRoom));
4809
+ const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
4591
4810
  const lines = p.layers.map((l, i) => {
4592
4811
  const nw = noteW(l);
4593
4812
  const labelMaxW = Math.max(labelSize, colW - nw);
@@ -4595,14 +4814,14 @@ function solve2(p, format, inline, face) {
4595
4814
  label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4596
4815
  // Inline notes stay on one line by contract — the schema calls a note "one
4597
4816
  // short line" — and wrapping one would put its second line under the label.
4598
- note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, MIN_FONT, colW, 400, 0, face),
4817
+ note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
4599
4818
  noteW: nw,
4600
4819
  labelMaxW
4601
4820
  };
4602
4821
  });
4603
4822
  const blockH = Math.max(
4604
4823
  ...lines.map(
4605
- (l) => inline ? Math.max(l.label.length * labelSize, l.note.length * MIN_FONT) * 1.16 : l.label.length * labelSize * 1.16 + (l.note.length > 0 ? 6 + l.note.length * MIN_FONT * 1.16 : 0)
4824
+ (l) => inline ? Math.max(l.label.length * labelSize, l.note.length * floor) * 1.16 : l.label.length * labelSize * 1.16 + (l.note.length > 0 ? 6 + l.note.length * floor * 1.16 : 0)
4606
4825
  )
4607
4826
  );
4608
4827
  const pad = Math.max(EDGE2, blockH / 2);
@@ -4622,6 +4841,7 @@ function solve2(p, format, inline, face) {
4622
4841
  // BOTH directions. A layout that fits the height and not the width is not a
4623
4842
  // layout that fits; it is one whose overflow is in the axis nothing measured.
4624
4843
  fits: room >= blockH + 10 && height <= free && wide,
4844
+ floor,
4625
4845
  wide,
4626
4846
  inline,
4627
4847
  width,
@@ -4661,7 +4881,7 @@ var stack = (beat, ctx) => {
4661
4881
  const last = count - 1;
4662
4882
  if (!L.fits) {
4663
4883
  throw new Error(
4664
- `stack ${beat.id}: ${count} layers with ${p.layers.filter((l) => l.note).length} note(s) need ${Math.round(L.blockH)}px of label block against ${Math.round(L.avail)}px of room, at the ${MIN_FONT}px floor and with the notes already moved beside their labels. Nothing here may be set smaller, so the lever is upstream of this beat: drop a layer, or shorten the notes.`
4884
+ `stack ${beat.id}: ${count} layers with ${p.layers.filter((l) => l.note).length} note(s) need ${Math.round(L.blockH)}px of label block against ${Math.round(L.avail)}px of room, at the ${Math.round(L.floor)}px floor and with the notes already moved beside their labels. Nothing here may be set smaller, so the lever is upstream of this beat: drop a layer, or shorten the notes.`
4665
4885
  );
4666
4886
  }
4667
4887
  const parts = {};
@@ -4683,7 +4903,7 @@ var stack = (beat, ctx) => {
4683
4903
  const dot = circle({ x: L.x0 + L.w + L.sx / 2 + 8, y: mid }, 6, { fill: tint });
4684
4904
  const block = L.lines[i] ?? { label: [], note: [], noteW: 0, labelMaxW: L.colW };
4685
4905
  const labelH = block.label.length * L.labelSize * 1.16;
4686
- const noteH = block.note.length > 0 ? 6 + block.note.length * MIN_FONT * 1.16 : 0;
4906
+ const noteH = block.note.length > 0 ? 6 + block.note.length * L.floor * 1.16 : 0;
4687
4907
  const label = text(
4688
4908
  layer.label,
4689
4909
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
@@ -4709,7 +4929,7 @@ var stack = (beat, ctx) => {
4709
4929
  { x: L.labelX, y: mid + labelH / 2 + 3 }
4710
4930
  ),
4711
4931
  {
4712
- size: MIN_FONT,
4932
+ size: L.floor,
4713
4933
  fill: theme.muted,
4714
4934
  anchor: L.inline ? "end" : "start",
4715
4935
  maxWidth: L.inline ? void 0 : L.colW,
@@ -4720,8 +4940,8 @@ var stack = (beat, ctx) => {
4720
4940
  );
4721
4941
  const num = text(
4722
4942
  String(i + 1),
4723
- { x: NUM_X, y: mid },
4724
- { size: MIN_FONT, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4943
+ { x: numSpine(L.floor), y: mid },
4944
+ { size: L.floor, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4725
4945
  );
4726
4946
  return group(slab(L.x0, y0, L, tint, lift2, stroke), { id: id(sid, "lay", i), class: "lay" }) + group(num + leader + dot + label + note2, { id: id(sid, "cap", i), class: "cap" });
4727
4947
  }).join("");
@@ -4743,6 +4963,8 @@ var stack = (beat, ctx) => {
4743
4963
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4744
4964
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4745
4965
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe2)}</div>${noteHtml}`;
4966
+ const centre2 = (count - 1) / 2;
4967
+ const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4746
4968
  const first = 0.9;
4747
4969
  const step2 = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count));
4748
4970
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
@@ -4753,7 +4975,7 @@ var stack = (beat, ctx) => {
4753
4975
  tl.push(
4754
4976
  tween(
4755
4977
  `#${sid}-lay${i}`,
4756
- { opacity: 0, y: 34 },
4978
+ { opacity: 0, y: enterFrom(i) },
4757
4979
  { opacity: 1, y: 0, duration: 0.55, ease: "power2.out" },
4758
4980
  at
4759
4981
  )
@@ -4796,8 +5018,12 @@ var stack = (beat, ctx) => {
4796
5018
  // The top plane is the focal point — last built, differently toned, and the
4797
5019
  // one the final hold sits on. Its entrance owns `opacity` and `transform`,
4798
5020
  // so the breath takes `filter`, the property nothing else writes.
4799
- ambient(sid, `-lay${last}`, BREATHE)
4800
- ].join("\n")
5021
+ ambient(sid, `-lay${last}`, BREATHE),
5022
+ // Absent unless the beat asked for it, so a flat stack emits the bytes it
5023
+ // always did.
5024
+ // `.stackwrap` and not the scene: the slabs lean, the headline does not.
5025
+ p.tilt ? depthCss(sid, { ...DEFAULT_POSE, rotateX: p.tilt }, ".stackwrap") : ""
5026
+ ].filter(Boolean).join("\n")
4801
5027
  };
4802
5028
  };
4803
5029
 
@@ -4812,6 +5038,7 @@ var emitters = {
4812
5038
  stack,
4813
5039
  "split-compare": splitCompare,
4814
5040
  "equation-walk": equationWalk,
5041
+ "equation-morph": equationMorph,
4815
5042
  "line-chart": lineChart,
4816
5043
  // The ones that describe.
4817
5044
  title,
@@ -5004,6 +5231,7 @@ function round4(n3) {
5004
5231
  // src/emit/composition.ts
5005
5232
  var GSAP_SRC = "./vendor/gsap.min.js";
5006
5233
  var DRAWSVG_SRC = "./vendor/DrawSVGPlugin.min.js";
5234
+ var MORPH_SRC = "./vendor/ds-morph.js";
5007
5235
  var KATEX_JS = "./vendor/katex.min.js";
5008
5236
  var KATEX_CSS = "./katex/katex.min.css";
5009
5237
  function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
@@ -5094,6 +5322,7 @@ function layout(storyboard, source, format, opts = {}) {
5094
5322
  });
5095
5323
  let start = 0;
5096
5324
  let builds = false;
5325
+ const plugins = /* @__PURE__ */ new Set();
5097
5326
  cuts.forEach((cut2, i) => {
5098
5327
  const { beat, sid, dive, inside, duration } = cut2;
5099
5328
  if (cut2.segments?.length) spoken[sid] = cut2.segments;
@@ -5108,6 +5337,7 @@ function layout(storyboard, source, format, opts = {}) {
5108
5337
  }
5109
5338
  if (scene.css) archetypeCss.add(scene.css.trim());
5110
5339
  if (scene.measure?.length) builds = true;
5340
+ for (const p of scene.plugins ?? []) plugins.add(p);
5111
5341
  scenes.push(
5112
5342
  sceneHtml(
5113
5343
  sid,
@@ -5139,7 +5369,8 @@ function layout(storyboard, source, format, opts = {}) {
5139
5369
  spoken,
5140
5370
  total: start,
5141
5371
  cut,
5142
- builds
5372
+ builds,
5373
+ plugins
5143
5374
  };
5144
5375
  }
5145
5376
  function enteredParts(beats) {
@@ -5220,6 +5451,9 @@ function renderComposition(storyboard, format, laid) {
5220
5451
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5221
5452
  const island = format.navigable ? `
5222
5453
  ${emitIsland(slides)}` : "";
5454
+ const morph = laid.plugins.has("dsMorph") ? `
5455
+ <script src="${MORPH_SRC}"></script>
5456
+ <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5223
5457
  return `<!doctype html>
5224
5458
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5225
5459
  <head>
@@ -5228,7 +5462,7 @@ ${emitIsland(slides)}` : "";
5228
5462
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5229
5463
  <script src="${GSAP_SRC}"></script>
5230
5464
  <script src="${DRAWSVG_SRC}"></script>
5231
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>
5465
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
5232
5466
  <link rel="stylesheet" href="${KATEX_CSS}" />
5233
5467
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5234
5468
  <style>
@@ -5476,6 +5710,76 @@ import { tmpdir } from "node:os";
5476
5710
  import { join as join3 } from "node:path";
5477
5711
  import { z as z2 } from "zod";
5478
5712
 
5713
+ // src/plan/arc.ts
5714
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
5715
+ function requiredRoles(beatCount) {
5716
+ if (beatCount >= 8) return ARC_ROLES;
5717
+ if (beatCount >= 5) return ["limitations", "conclusion"];
5718
+ return [];
5719
+ }
5720
+ function paperArcRequested(prefs) {
5721
+ return prefs.genre === "paper";
5722
+ }
5723
+ function arcBeats(storyboard) {
5724
+ const by = /* @__PURE__ */ new Map();
5725
+ for (const beat of storyboard.beats) {
5726
+ if (!beat.role) continue;
5727
+ by.set(beat.role, [...by.get(beat.role) ?? [], beat]);
5728
+ }
5729
+ return by;
5730
+ }
5731
+ function arcProblems(storyboard, prefs) {
5732
+ if (!paperArcRequested(prefs)) return [];
5733
+ const beats = storyboard.beats;
5734
+ const need = requiredRoles(Math.min(prefs.slides, beats.length));
5735
+ if (!need.length) return [];
5736
+ const out = [];
5737
+ const by = arcBeats(storyboard);
5738
+ for (const role of need) {
5739
+ const held = by.get(role) ?? [];
5740
+ if (held.length === 0) {
5741
+ out.push(
5742
+ `No beat carries role "${role}". A paper deck is meant to have one, and the source may not support it \u2014 write the beat, or drop \`--genre paper\` for this document.`
5743
+ );
5744
+ continue;
5745
+ }
5746
+ if (held.length > 1) {
5747
+ out.push(
5748
+ `${held.length} beats carry role "${role}" (${held.map((b) => b.id).join(", ")}). A structural job belongs to one slide.`
5749
+ );
5750
+ }
5751
+ }
5752
+ const last = beats[beats.length - 1];
5753
+ if (need.includes("conclusion") && by.has("conclusion") && last?.role !== "conclusion") {
5754
+ out.push(
5755
+ `The deck ends on "${last?.id}" (${last?.archetype}), not on its conclusion. The conclusion is the last thing the viewer sees or it is not a conclusion.`
5756
+ );
5757
+ }
5758
+ const limIdx = beats.findIndex((b) => b.role === "limitations");
5759
+ const conIdx = beats.findIndex((b) => b.role === "conclusion");
5760
+ if (need.includes("limitations") && limIdx >= 0 && conIdx >= 0 && limIdx !== conIdx - 1) {
5761
+ out.push(
5762
+ `The limitations beat is not the slide immediately before the conclusion. The two are a pair and the caveat comes first.`
5763
+ );
5764
+ }
5765
+ const openingWindow = beats.slice(0, 3).map((b) => b.role);
5766
+ for (const role of need.filter((r) => r === "intro" || r === "background")) {
5767
+ if (by.has(role) && !openingWindow.includes(role)) {
5768
+ out.push(
5769
+ `The "${role}" beat is not in the first three slides. It is what the rest of the deck is understood against, so it has to arrive before the mechanism does.`
5770
+ );
5771
+ }
5772
+ }
5773
+ const lim = by.get("limitations")?.[0];
5774
+ const con = by.get("conclusion")?.[0];
5775
+ if (lim && con && lim.archetype === con.archetype) {
5776
+ out.push(
5777
+ `The limitations and conclusion beats are both \`${lim.archetype}\`. Two of the same picture running is what RULE 1 forbids; draw the conclusion where the source states it.`
5778
+ );
5779
+ }
5780
+ return out;
5781
+ }
5782
+
5479
5783
  // src/plan/duration.ts
5480
5784
  var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
5481
5785
  var LAST_HOLD_SECONDS = 4.2;
@@ -5672,6 +5976,7 @@ var REVEALS = {
5672
5976
  title: "1",
5673
5977
  "claim-figure": "2",
5674
5978
  "equation-walk": "one per term",
5979
+ "equation-morph": "2",
5675
5980
  "data-table": "one per highlighted row, plus 1",
5676
5981
  "line-chart": "1",
5677
5982
  callout: "one per panel",
@@ -5703,9 +6008,9 @@ A beat is one idea, one visual, one hold. It carries:
5703
6008
  beat immediately before it. See RULE 11. Leave it off unless the
5704
6009
  source itself puts one inside the other.
5705
6010
 
5706
- THE TWELVE ARCHETYPES
6011
+ THE THIRTEEN ARCHETYPES
5707
6012
 
5708
- Eight of them DRAW: they build a vector graphic out of the source's own content
6013
+ Nine of them DRAW: they build a vector graphic out of the source's own content
5709
6014
  and reveal it stage by stage, so the viewer watches the idea assemble. Four only
5710
6015
  describe. The drawing ones are the default. The describing ones are what you
5711
6016
  fall back to when a point genuinely has no shape.
@@ -5783,6 +6088,14 @@ DRAWING ARCHETYPES \u2014 reach here first
5783
6088
  An equation quoted to back a claim someone else is making is
5784
6089
  evidence under another archetype, not a beat of its own.
5785
6090
 
6091
+ equation-morph One equation becoming the next, the shared terms carried
6092
+ across. The tell: THE SOURCE DERIVES ONE LINE FROM ANOTHER \u2014 a
6093
+ substitution, a rearrangement, a special case \u2014 and the point
6094
+ is what moved. \`fromId\` and \`toId\` name two equations from
6095
+ the inventory. Each terms[].tex must appear verbatim in BOTH,
6096
+ and travels as one piece; a term in only one of them is
6097
+ dropped. Four terms maximum.
6098
+
5786
6099
  line-chart A trend the source states numerically but does not plot. The
5787
6100
  tell: A QUANTITY MOVING ALONG AN ORDERED AXIS \u2014 over length, over
5788
6101
  scale, over training. Points come from the source's numbers;
@@ -6056,6 +6369,46 @@ ${REVEAL_COUNTS}` : ` - ${n3 === 1 ? "ONE SENTENCE" : `${n3} SENTENCES`} FOR TH
6056
6369
  miss its duration.`;
6057
6370
  return { sentences, length };
6058
6371
  }
6372
+ function paperArc(slides) {
6373
+ const asked = requiredRoles(slides);
6374
+ const full = asked.includes("intro");
6375
+ return `
6376
+
6377
+ PAPER ARC \u2014 this source was declared a research paper.
6378
+
6379
+ Four beats have a structural job, and each one NAMES its job in \`role\`. Every
6380
+ other beat leaves \`role\` off. A role is a job, not a heading: never write
6381
+ "Related work" or "Conclusion" as a headline, because RULE 8 still applies to
6382
+ all four.
6383
+ ${full ? `
6384
+ role: "intro" Near the front. What problem exists and who has it, in
6385
+ the viewer's own terms. This is the opening RULE 6
6386
+ already asks for, named so the deck can be checked.
6387
+ role: "background" In the first three beats. What people did before this
6388
+ work, and where that ran out. Take it from what the
6389
+ source itself says about earlier approaches \u2014 if the
6390
+ source says nothing about them, leave the role off
6391
+ rather than inventing a literature (RULE 3).` : `
6392
+ This deck is short, so only the ENDING is required \u2014 an opening the deck
6393
+ already has is not worth a slide of its own here.`}
6394
+ role: "limitations" THE SECOND-TO-LAST beat. What the work does not do, in
6395
+ the source's own admission. Not a hedge inside another
6396
+ beat's sentence: its own slide.
6397
+ role: "conclusion" THE LAST beat, with nothing after it. What the viewer
6398
+ should carry away.
6399
+
6400
+ - The closing pair is TWO beats and they must not share an archetype (RULE 1).
6401
+ A limitation the source admits to is usually a panel; the conclusion is the
6402
+ claim the deck lands, so draw it where the source states it \u2014 bars, a
6403
+ contrast, the figure that settles it \u2014 and fall back to a panel only when it
6404
+ genuinely has no shape.
6405
+ - Give all four a weight of 0.8 or above. A short cut keeps the
6406
+ highest-weighted beats, and a structural beat below 0.8 is one the deck
6407
+ loses at the first budget.
6408
+ - If the source does not support one of these, LEAVE IT OUT. A slide that
6409
+ admits a limitation the paper never admits is worse than no slide.
6410
+ `;
6411
+ }
6059
6412
  function illustrations(images) {
6060
6413
  return `
6061
6414
 
@@ -6084,7 +6437,7 @@ nothing, so it can never dangle.
6084
6437
  }
6085
6438
  function systemPrompt(prefs) {
6086
6439
  const plan = durationPlan(prefs);
6087
- return `${rules(cadenceFor(prefs, plan))}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6440
+ return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6088
6441
 
6089
6442
  PREFERENCES \u2014 chosen by the person who asked for this deck.
6090
6443
  ${prefs.duration === void 0 ? "" : `
@@ -6181,7 +6534,8 @@ function renderSource(source) {
6181
6534
  out.push("", "== EQUATIONS ==");
6182
6535
  for (const e of source.equations)
6183
6536
  out.push(`[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${e.tex}`);
6184
- if (!source.equations.length) out.push("(none \u2014 no equation-walk beat is possible)");
6537
+ if (!source.equations.length)
6538
+ out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
6185
6539
  out.push("", "== TABLES ==");
6186
6540
  for (const t2 of source.tables) {
6187
6541
  out.push(`[table ${t2.id}] ${t2.caption ?? "(no caption)"}`);
@@ -6253,6 +6607,10 @@ function assertRefsResolve(storyboard, source, opts = {}) {
6253
6607
  case "equation-walk":
6254
6608
  check3(beat, "equation", beat.params.equationId, "params.equationId");
6255
6609
  break;
6610
+ case "equation-morph":
6611
+ check3(beat, "equation", beat.params.fromId, "params.fromId");
6612
+ check3(beat, "equation", beat.params.toId, "params.toId");
6613
+ break;
6256
6614
  case "data-table": {
6257
6615
  check3(beat, "table", beat.params.tableId, "params.tableId");
6258
6616
  const table = source.tables.find((t2) => t2.id === beat.params.tableId);
@@ -6389,14 +6747,45 @@ function stripNulls(node) {
6389
6747
  }
6390
6748
  return out;
6391
6749
  }
6392
- var SCHEMA = forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }));
6750
+ var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
6751
+ function plannerInvisible(prefs) {
6752
+ return paperArcRequested(prefs) ? PLANNER_INVISIBLE : /* @__PURE__ */ new Set([...PLANNER_INVISIBLE, "role"]);
6753
+ }
6754
+ function hideFromPlanner(node, hidden) {
6755
+ if (Array.isArray(node)) return node.map((n3) => hideFromPlanner(n3, hidden));
6756
+ if (node === null || typeof node !== "object") return node;
6757
+ const src = node;
6758
+ const out = {};
6759
+ for (const [key, value] of Object.entries(src)) {
6760
+ if (key === "properties" && value && typeof value === "object") {
6761
+ const kept = {};
6762
+ for (const [prop, sub] of Object.entries(value))
6763
+ if (!hidden.has(prop)) kept[prop] = hideFromPlanner(sub, hidden);
6764
+ out.properties = kept;
6765
+ continue;
6766
+ }
6767
+ if (key === "required" && Array.isArray(value)) {
6768
+ out.required = value.filter((r) => typeof r !== "string" || !hidden.has(r));
6769
+ continue;
6770
+ }
6771
+ out[key] = hideFromPlanner(value, hidden);
6772
+ }
6773
+ return out;
6774
+ }
6775
+ function schemaFor(prefs) {
6776
+ return hideFromPlanner(
6777
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
6778
+ plannerInvisible(prefs)
6779
+ );
6780
+ }
6781
+ var SCHEMA = schemaFor({ genre: "general" });
6393
6782
  async function codexPlanner(source, opts = {}) {
6394
6783
  const prefs = opts.prefs ?? prefsSchema.parse({});
6395
6784
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
6396
6785
  try {
6397
6786
  const schemaPath = join3(dir, "storyboard.schema.json");
6398
6787
  const outPath = join3(dir, "storyboard.json");
6399
- await writeFile3(schemaPath, JSON.stringify(SCHEMA));
6788
+ await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
6400
6789
  await (opts.run ?? runCodex)({
6401
6790
  prompt: buildPrompt(source, prefs),
6402
6791
  schemaPath,
@@ -7461,6 +7850,7 @@ function prefsFromFlags(flags2) {
7461
7850
  if (flags2.lang !== void 0) patch.lang = flags2.lang;
7462
7851
  if (flags2.tone !== void 0) patch.tone = flags2.tone;
7463
7852
  if (flags2.density !== void 0) patch.density = flags2.density;
7853
+ if (flags2.genre !== void 0) patch.genre = flags2.genre;
7464
7854
  if (flags2.duration !== void 0) patch.duration = number("--duration", flags2.duration);
7465
7855
  if (flags2.theme !== void 0) patch.theme = flags2.theme;
7466
7856
  if (flags2.speed !== void 0) patch.animationSpeed = number("--speed", flags2.speed);
@@ -8212,13 +8602,14 @@ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
8212
8602
  out
8213
8603
  ];
8214
8604
  }
8605
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
8215
8606
  function audioGraph(inputs, seconds, first = 1) {
8216
8607
  const lines = inputs.map(
8217
8608
  (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
8218
8609
  );
8219
8610
  const labels = inputs.map((_, i) => `[d${i}]`).join("");
8220
8611
  lines.push(
8221
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,apad=whole_dur=${seconds.toFixed(3)}[aout]`
8612
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
8222
8613
  );
8223
8614
  return lines.join(";\n");
8224
8615
  }
@@ -8932,6 +9323,178 @@ function tail(s) {
8932
9323
  import { readFile as readFile13 } from "node:fs/promises";
8933
9324
  import { join as join12 } from "node:path";
8934
9325
 
9326
+ // src/verify/typefloor.ts
9327
+ var TYPE_FLOOR_PX = 40;
9328
+ var CSS_PX = /font-size\s*:\s*([0-9.]+)px/g;
9329
+ var SVG_ATTR = /\bfont-size="([0-9.]+)"/g;
9330
+ var RELATIVE = /font-size\s*:\s*[0-9.]+(em|rem|%|vw|vh|ch|ex|pt)\b/g;
9331
+ function scanTypeFloor(html, file, floorPx = TYPE_FLOOR_PX) {
9332
+ const findings = [];
9333
+ const zones = svgZones(html);
9334
+ const small = [];
9335
+ for (const pattern of [CSS_PX, SVG_ATTR]) {
9336
+ pattern.lastIndex = 0;
9337
+ for (const m of html.matchAll(pattern)) {
9338
+ const px = Number(m[1]) * userUnit(zones, m.index);
9339
+ if (px < floorPx) small.push({ px, where: where(html, m.index) });
9340
+ }
9341
+ }
9342
+ if (small.length > 0) {
9343
+ const worst = [...small].sort((a, b) => a.px - b.px);
9344
+ const named = worst.slice(0, 6).map((s) => `${round6(s.px)}px on ${s.where}`).join(", ");
9345
+ findings.push({
9346
+ severity: "error",
9347
+ gate: "typography",
9348
+ rule: "type_below_floor",
9349
+ message: `${file} declares ${small.length} text size(s) under the ${floorPx}px floor: ${named}${worst.length > 6 ? ", \u2026" : ""}. Sizes are in reference space, so this is the floor as authored and not an artefact of the format's zoom. Raise the size, or give the archetype less to say \u2014 a beat that only fits below the floor is a beat that has to be cut in two.`
9350
+ });
9351
+ }
9352
+ const units = [...new Set([...html.matchAll(RELATIVE)].map((m) => m[1]))];
9353
+ if (units.length > 0) {
9354
+ findings.push({
9355
+ severity: "warning",
9356
+ gate: "typography",
9357
+ rule: "type_unmeasurable",
9358
+ message: `${file} sizes some text in ${units.join("/")}, which resolves against an inherited size this scan cannot follow, so the ${floorPx}px floor was not checked there. Declare it in px, or measure it in a browser.`
9359
+ });
9360
+ }
9361
+ return findings;
9362
+ }
9363
+ function svgZones(html) {
9364
+ const zones = [];
9365
+ for (const m of html.matchAll(/<svg\b([^>]*)>/g)) {
9366
+ const width = Number(/\bwidth="([0-9.]+)"/.exec(m[1])?.[1]);
9367
+ const box = Number(/\bviewBox="[0-9.-]+\s+[0-9.-]+\s+([0-9.]+)/.exec(m[1])?.[1]);
9368
+ const close = html.indexOf("</svg>", m.index);
9369
+ zones.push({
9370
+ start: m.index,
9371
+ end: close < 0 ? html.length : close,
9372
+ unit: width > 0 && box > 0 ? width / box : 1
9373
+ });
9374
+ }
9375
+ return zones;
9376
+ }
9377
+ function userUnit(zones, at) {
9378
+ return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
9379
+ }
9380
+ function where(html, at) {
9381
+ const open = html.lastIndexOf("<", at);
9382
+ const closed = html.lastIndexOf(">", at);
9383
+ if (open > closed) {
9384
+ const end = html.indexOf(">", at);
9385
+ const tag = html.slice(open, end < 0 ? void 0 : end + 1);
9386
+ const id2 = /\bid="([^"]+)"/.exec(tag)?.[1];
9387
+ if (id2) return `#${id2}`;
9388
+ const name = /^<([a-zA-Z][\w-]*)/.exec(tag)?.[1] ?? "an element";
9389
+ const cls = /\bclass="([^"]+)"/.exec(tag)?.[1]?.split(/\s+/)[0];
9390
+ return cls ? `${name}.${cls}` : name;
9391
+ }
9392
+ const brace = html.lastIndexOf("{", at);
9393
+ if (brace < 0) return "the stylesheet";
9394
+ const from = Math.max(html.lastIndexOf("}", brace), html.lastIndexOf(">", brace));
9395
+ const selector = html.slice(from + 1, brace).trim().split("\n").pop()?.trim();
9396
+ return selector ? selector : "the stylesheet";
9397
+ }
9398
+ function round6(px) {
9399
+ return Math.round(px * 100) / 100;
9400
+ }
9401
+
9402
+ // src/verify/apparent.ts
9403
+ function collectApparent(sid) {
9404
+ const scene = document.querySelector(`[data-composition-id="${CSS.escape(sid)}"]`);
9405
+ if (!scene) return { runs: [], stage: 1 };
9406
+ const declaredW = Number(scene.dataset.width ?? 0);
9407
+ const sceneRect = scene.getBoundingClientRect();
9408
+ const stage = declaredW > 0 && sceneRect.width > 0 ? sceneRect.width / declaredW : 1;
9409
+ const runs = [];
9410
+ const walk = (el, inSvg) => {
9411
+ const style = getComputedStyle(el);
9412
+ if (style.display === "none" || style.visibility === "hidden") return;
9413
+ if (Number(style.opacity) === 0) return;
9414
+ const within = inSvg || el.tagName.toLowerCase() === "svg";
9415
+ if (within && typeof el.getBBox === "function") {
9416
+ let hasText = false;
9417
+ for (const node of Array.from(el.childNodes))
9418
+ if (node.nodeType === 3 && node.textContent?.trim()) hasText = true;
9419
+ if (hasText) {
9420
+ let box = null;
9421
+ try {
9422
+ box = el.getBBox();
9423
+ } catch {
9424
+ box = null;
9425
+ }
9426
+ const painted = el.getBoundingClientRect().height;
9427
+ if (box && box.height > 0 && painted > 0) {
9428
+ let opacity = 1;
9429
+ for (let node = el; node; node = node.parentElement) {
9430
+ const own = Number(getComputedStyle(node).opacity);
9431
+ if (Number.isFinite(own)) opacity *= own;
9432
+ if (node === scene) break;
9433
+ }
9434
+ runs.push({
9435
+ text: (el.textContent ?? "").trim().slice(0, 40),
9436
+ declared: Number.parseFloat(getComputedStyle(el).fontSize) || 0,
9437
+ ratio: painted / box.height,
9438
+ opacity
9439
+ });
9440
+ }
9441
+ }
9442
+ }
9443
+ for (const child of Array.from(el.children)) walk(child, within);
9444
+ };
9445
+ walk(scene, false);
9446
+ return { runs, stage };
9447
+ }
9448
+ function apparentPx(run4, stage) {
9449
+ if (stage <= 0) return run4.declared;
9450
+ return run4.declared * run4.ratio / stage;
9451
+ }
9452
+ var SETTLED_OPACITY = 0.95;
9453
+ function midpoints(stops) {
9454
+ const out = [];
9455
+ for (let i = 1; i < stops.length; i++) {
9456
+ const a = stops[i - 1];
9457
+ const b = stops[i];
9458
+ if (a.sid !== b.sid || b.t <= a.t) continue;
9459
+ out.push({ sid: b.sid, t: Math.round((a.t + b.t) / 2 * 1e3) / 1e3 });
9460
+ }
9461
+ return out;
9462
+ }
9463
+ function gradeApparent(stops, floor = TYPE_FLOOR_PX) {
9464
+ const worst = /* @__PURE__ */ new Map();
9465
+ for (const stop of stops) {
9466
+ for (const run4 of stop.runs) {
9467
+ if (!stop.settled && run4.opacity < SETTLED_OPACITY) continue;
9468
+ const px = apparentPx(run4, stop.stage);
9469
+ if (px >= floor - 0.1) continue;
9470
+ const seen = worst.get(run4.text);
9471
+ if (!seen || px < seen.px)
9472
+ worst.set(run4.text, { sid: stop.sid, t: stop.t, text: run4.text, px });
9473
+ }
9474
+ }
9475
+ if (worst.size === 0) return [];
9476
+ const byScene = /* @__PURE__ */ new Map();
9477
+ for (const row of worst.values()) {
9478
+ const list = byScene.get(row.sid) ?? [];
9479
+ list.push(row);
9480
+ byScene.set(row.sid, list);
9481
+ }
9482
+ return [...byScene].map(([sid, rows]) => {
9483
+ rows.sort((a, b) => a.px - b.px);
9484
+ const smallest = rows[0];
9485
+ const named = rows.slice(0, 3).map((r) => `"${r.text}" at ${Math.round(r.px * 10) / 10}px`).join(", ");
9486
+ return {
9487
+ severity: "error",
9488
+ gate: "apparent",
9489
+ rule: "apparent_type_floor",
9490
+ // `#${sid}` as a selector, like every other finding here: `scripts/sweep.mjs`
9491
+ // reads the selector back to decide which beat a finding belongs to, and a
9492
+ // bare id was once filed as a deck-level orphan while the beat reported clean.
9493
+ message: `#${sid} draws ${rows.length} text run(s) below the ${floor}px floor once the frame is rendered: ${named}, at t=${smallest.t}s. Their DECLARED sizes pass \u2014 something between the source and the glyph scales them down, a transform at a hold or a 3D projection. Invariant 5 is about what the audience sees, and the declared-size scan cannot see this.`
9494
+ };
9495
+ });
9496
+ }
9497
+
8935
9498
  // src/verify/overprint.ts
8936
9499
  var MIN_OVERLAP = 8;
8937
9500
  function collectSvgTextRuns(sid) {
@@ -9215,6 +9778,7 @@ async function fidelity(dir, opts = {}) {
9215
9778
  const { page, height } = deck;
9216
9779
  const measured = [];
9217
9780
  const collided = [];
9781
+ const apparent = [];
9218
9782
  for (const stop of stops) {
9219
9783
  await deck.seek(stop.t);
9220
9784
  const bandTopPx = await page.evaluate(
@@ -9227,6 +9791,11 @@ async function fidelity(dir, opts = {}) {
9227
9791
  ...stop,
9228
9792
  pairs: overprints(await page.evaluate(collectSvgTextRuns, stop.sid))
9229
9793
  });
9794
+ apparent.push({
9795
+ ...stop,
9796
+ settled: true,
9797
+ ...await page.evaluate(collectApparent, stop.sid)
9798
+ });
9230
9799
  const frame = await decodePng(await deck.shoot());
9231
9800
  measured.push({
9232
9801
  ...stop,
@@ -9234,9 +9803,17 @@ async function fidelity(dir, opts = {}) {
9234
9803
  bandTop: Math.round(1e3 * bandTopPx / height) / 1e3
9235
9804
  });
9236
9805
  }
9806
+ for (const mid of midpoints(stops)) {
9807
+ await deck.seek(mid.t);
9808
+ apparent.push({ ...mid, settled: false, ...await page.evaluate(collectApparent, mid.sid) });
9809
+ }
9237
9810
  return {
9238
9811
  stops: measured,
9239
- findings: [...gradeFidelity(measured, floor), ...gradeOverprint(collided)],
9812
+ findings: [
9813
+ ...gradeFidelity(measured, floor),
9814
+ ...gradeOverprint(collided),
9815
+ ...gradeApparent(apparent)
9816
+ ],
9240
9817
  elapsedMs: Date.now() - started
9241
9818
  };
9242
9819
  } catch (err) {
@@ -9247,82 +9824,6 @@ async function fidelity(dir, opts = {}) {
9247
9824
  }
9248
9825
  }
9249
9826
 
9250
- // src/verify/typefloor.ts
9251
- var TYPE_FLOOR_PX = 40;
9252
- var CSS_PX = /font-size\s*:\s*([0-9.]+)px/g;
9253
- var SVG_ATTR = /\bfont-size="([0-9.]+)"/g;
9254
- var RELATIVE = /font-size\s*:\s*[0-9.]+(em|rem|%|vw|vh|ch|ex|pt)\b/g;
9255
- function scanTypeFloor(html, file, floorPx = TYPE_FLOOR_PX) {
9256
- const findings = [];
9257
- const zones = svgZones(html);
9258
- const small = [];
9259
- for (const pattern of [CSS_PX, SVG_ATTR]) {
9260
- pattern.lastIndex = 0;
9261
- for (const m of html.matchAll(pattern)) {
9262
- const px = Number(m[1]) * userUnit(zones, m.index);
9263
- if (px < floorPx) small.push({ px, where: where(html, m.index) });
9264
- }
9265
- }
9266
- if (small.length > 0) {
9267
- const worst = [...small].sort((a, b) => a.px - b.px);
9268
- const named = worst.slice(0, 6).map((s) => `${round6(s.px)}px on ${s.where}`).join(", ");
9269
- findings.push({
9270
- severity: "error",
9271
- gate: "typography",
9272
- rule: "type_below_floor",
9273
- message: `${file} declares ${small.length} text size(s) under the ${floorPx}px floor: ${named}${worst.length > 6 ? ", \u2026" : ""}. Sizes are in reference space, so this is the floor as authored and not an artefact of the format's zoom. Raise the size, or give the archetype less to say \u2014 a beat that only fits below the floor is a beat that has to be cut in two.`
9274
- });
9275
- }
9276
- const units = [...new Set([...html.matchAll(RELATIVE)].map((m) => m[1]))];
9277
- if (units.length > 0) {
9278
- findings.push({
9279
- severity: "warning",
9280
- gate: "typography",
9281
- rule: "type_unmeasurable",
9282
- message: `${file} sizes some text in ${units.join("/")}, which resolves against an inherited size this scan cannot follow, so the ${floorPx}px floor was not checked there. Declare it in px, or measure it in a browser.`
9283
- });
9284
- }
9285
- return findings;
9286
- }
9287
- function svgZones(html) {
9288
- const zones = [];
9289
- for (const m of html.matchAll(/<svg\b([^>]*)>/g)) {
9290
- const width = Number(/\bwidth="([0-9.]+)"/.exec(m[1])?.[1]);
9291
- const box = Number(/\bviewBox="[0-9.-]+\s+[0-9.-]+\s+([0-9.]+)/.exec(m[1])?.[1]);
9292
- const close = html.indexOf("</svg>", m.index);
9293
- zones.push({
9294
- start: m.index,
9295
- end: close < 0 ? html.length : close,
9296
- unit: width > 0 && box > 0 ? width / box : 1
9297
- });
9298
- }
9299
- return zones;
9300
- }
9301
- function userUnit(zones, at) {
9302
- return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
9303
- }
9304
- function where(html, at) {
9305
- const open = html.lastIndexOf("<", at);
9306
- const closed = html.lastIndexOf(">", at);
9307
- if (open > closed) {
9308
- const end = html.indexOf(">", at);
9309
- const tag = html.slice(open, end < 0 ? void 0 : end + 1);
9310
- const id2 = /\bid="([^"]+)"/.exec(tag)?.[1];
9311
- if (id2) return `#${id2}`;
9312
- const name = /^<([a-zA-Z][\w-]*)/.exec(tag)?.[1] ?? "an element";
9313
- const cls = /\bclass="([^"]+)"/.exec(tag)?.[1]?.split(/\s+/)[0];
9314
- return cls ? `${name}.${cls}` : name;
9315
- }
9316
- const brace = html.lastIndexOf("{", at);
9317
- if (brace < 0) return "the stylesheet";
9318
- const from = Math.max(html.lastIndexOf("}", brace), html.lastIndexOf(">", brace));
9319
- const selector = html.slice(from + 1, brace).trim().split("\n").pop()?.trim();
9320
- return selector ? selector : "the stylesheet";
9321
- }
9322
- function round6(px) {
9323
- return Math.round(px * 100) / 100;
9324
- }
9325
-
9326
9827
  // src/verify/drift.ts
9327
9828
  import { execFile as execFile3 } from "node:child_process";
9328
9829
  import { createHash as createHash8 } from "node:crypto";
@@ -9657,24 +10158,25 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
9657
10158
  check2(dir, { ...opts, at: stops.map((s) => s.t) }),
9658
10159
  opts.fidelity === false ? null : fidelity(dir, { stops })
9659
10160
  ]);
9660
- const seen = [...ours, ...frames2?.findings ?? []];
10161
+ const storyboardFindings = storyboard ? [
10162
+ ...scanDiagrammatic(storyboard),
10163
+ ...scanHeadlines(storyboard),
10164
+ ...scanRepeatedObject(storyboard),
10165
+ // Needs the source as well, and says nothing without it — the same
10166
+ // silence `scanNarrationLead` keeps when its manifest is missing.
10167
+ ...source ? scanUnusedFigures(storyboard, source) : []
10168
+ ] : [];
10169
+ const seen = [...ours, ...frames2?.findings ?? [], ...storyboardFindings];
9661
10170
  return {
9662
10171
  passed: verdict.passed && seen.every((f) => f.severity !== "error"),
9663
10172
  findings: [
9664
10173
  ...seen,
9665
- // `scanBeatCount` is deliberately NOT here. It needs the preferences the
9666
- // deck was asked for, and `verify <dir>` is handed a built directory and
9667
- // nothing else — the same gating `scanNarrationLead` gets above, and for
9668
- // the same reason: a check that cannot see its inputs must not report that
9669
- // it found nothing. It runs at `plan` and `build`, where prefs exist.
9670
- ...storyboard ? [
9671
- ...scanDiagrammatic(storyboard),
9672
- ...scanHeadlines(storyboard),
9673
- ...scanRepeatedObject(storyboard),
9674
- // Needs the source as well, and says nothing without it — the same
9675
- // silence `scanNarrationLead` keeps when its manifest is missing.
9676
- ...source ? scanUnusedFigures(storyboard, source) : []
9677
- ] : [],
10174
+ // `scanBeatCount` and `scanPaperArc` are deliberately NOT here. Both need
10175
+ // the preferences the deck was asked for, and `verify <dir>` is handed a
10176
+ // built directory and nothing else — the same gating `scanNarrationLead`
10177
+ // gets above, and for the same reason: a check that cannot see its inputs
10178
+ // must not report that it found nothing. They run at `plan` and `build`,
10179
+ // where prefs exist.
9678
10180
  ...verdict.findings
9679
10181
  ]
9680
10182
  };
@@ -9859,6 +10361,32 @@ function scanBeatCount(storyboard, prefs) {
9859
10361
  }
9860
10362
  ];
9861
10363
  }
10364
+ function scanPaperArc(storyboard, prefs) {
10365
+ return arcProblems(storyboard, prefs).map((message) => ({
10366
+ severity: "warning",
10367
+ gate: "storyboard",
10368
+ rule: "paper_arc",
10369
+ message
10370
+ }));
10371
+ }
10372
+ function scanNarrationDrift(storyboard, narration) {
10373
+ const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
10374
+ const stale = [];
10375
+ for (const beat of storyboard.beats) {
10376
+ const segments = narration.beats[beat.id];
10377
+ if (!segments?.length || !flat(beat.narration)) continue;
10378
+ if (flat(segments.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
10379
+ }
10380
+ if (!stale.length) return [];
10381
+ return [
10382
+ {
10383
+ severity: "error",
10384
+ gate: "storyboard",
10385
+ rule: "narration_drift",
10386
+ message: `The recorded narration does not say what ${stale.length} beat(s) say they say: ${stale.join(", ")}. narration.json is keyed by beat id and carries no link to the plan it was made for, so a storyboard whose beats were renumbered or rewritten keeps matching ids and speaks the wrong slide. Re-run \`decksmith narrate\` for this storyboard \u2014 the audio cache is keyed by TEXT, so lines that did not change are not re-synthesised.`
10387
+ }
10388
+ ];
10389
+ }
9862
10390
  var LEAD_SECONDS = 1;
9863
10391
  var EMPHASISED = /* @__PURE__ */ new Set(["equation-walk", "data-table"]);
9864
10392
  function scanNarrationLead(beats, timing) {
@@ -10023,6 +10551,10 @@ async function vendorScripts(out) {
10023
10551
  const from = join15(dirname4(require2.resolve(pkg)), rel);
10024
10552
  await cp(from, join15(out, "vendor", name));
10025
10553
  }
10554
+ await cp(
10555
+ fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
10556
+ join15(out, "vendor", "ds-morph.js")
10557
+ );
10026
10558
  }
10027
10559
  var HYPERFRAMES_JSON = `${JSON.stringify(
10028
10560
  {
@@ -10034,7 +10566,10 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
10034
10566
  )}
10035
10567
  `;
10036
10568
  function planFlags(cmd) {
10037
- return cmd.option("--lang <bcp47>", "language of the deck's copy").option("--tone <tone>", "plain | academic | conversational | punchy").option("--density <level>", "sparse | normal | dense");
10569
+ return cmd.option("--lang <bcp47>", "language of the deck's copy").option("--tone <tone>", "plain | academic | conversational | punchy").option("--density <level>", "sparse | normal | dense").option(
10570
+ "--genre <genre>",
10571
+ "general | paper \u2014 paper asks for an intro, background, limitations and conclusion"
10572
+ );
10038
10573
  }
10039
10574
  function lengthFlags(cmd) {
10040
10575
  return cmd.option("--duration <s>", "target length of the finished video, in seconds (10\u20131800)").option("--slides <n>", "target beat count (3\u201340)").option(
@@ -10061,6 +10596,7 @@ function flags(o) {
10061
10596
  "lang",
10062
10597
  "tone",
10063
10598
  "density",
10599
+ "genre",
10064
10600
  "duration",
10065
10601
  "narrationDensity",
10066
10602
  "theme",
@@ -10130,6 +10666,11 @@ imageFlags(
10130
10666
  }
10131
10667
  for (const f of [
10132
10668
  ...scanBeatCount(storyboard, prefs),
10669
+ // Silent unless `--genre paper` was declared. Here for the same reason
10670
+ // `scanBeatCount` is: the fix is a beat in a file the author has open, and
10671
+ // this is the last moment before a minute of TTS is spent on a deck that
10672
+ // does not end where it was asked to.
10673
+ ...scanPaperArc(storyboard, prefs),
10133
10674
  ...scanHeadlines(storyboard),
10134
10675
  ...scanRepeatedObject(storyboard),
10135
10676
  // A figure the plan ignored is cheapest to fix here, where the answer is one
@@ -10203,7 +10744,7 @@ voiceFlags(
10203
10744
  });
10204
10745
  lookFlags(
10205
10746
  sizeFlags(
10206
- lengthFlags(program.command("build")).description("Emit the composition, write its assets, and run the gates.").argument("<storyboard>", "storyboard.json, edited to taste").requiredOption("--source <file>", "source.json the storyboard was planned from").requiredOption("-o, --out <dir>", "directory to write the deck into").option("--format <id>", `output profile: ${Object.keys(FORMATS).join(" | ")}`, "deck-16x9").option("--min-weight <n>", "keep only beats at or above this weight \u2014 see the budget gate").option("--narration <file>", `${NARRATION_FILE} from \`decksmith narrate\``).option("--no-narration", "ignore narration sitting beside the storyboard").option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser")
10747
+ lengthFlags(program.command("build")).description("Emit the composition, write its assets, and run the gates.").argument("<storyboard>", "storyboard.json, edited to taste").requiredOption("--source <file>", "source.json the storyboard was planned from").requiredOption("-o, --out <dir>", "directory to write the deck into").option("--format <id>", `output profile: ${Object.keys(FORMATS).join(" | ")}`, "deck-16x9").option("--min-weight <n>", "keep only beats at or above this weight \u2014 see the budget gate").option("--genre <genre>", "general | paper \u2014 report when a paper deck lacks its arc").option("--narration <file>", `${NARRATION_FILE} from \`decksmith narrate\``).option("--no-narration", "ignore narration sitting beside the storyboard").option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser")
10207
10748
  )
10208
10749
  ).action(
10209
10750
  async (sbPath, o) => {
@@ -10217,6 +10758,10 @@ lookFlags(
10217
10758
  await mkdir9(out, { recursive: true });
10218
10759
  const found = await findNarration(sbPath, o.narration);
10219
10760
  const narration = found ? await loadNarration(found) : void 0;
10761
+ if (narration) {
10762
+ const drift2 = scanNarrationDrift(storyboard, narration);
10763
+ if (drift2.length > 0) throw new Error(drift2[0]?.message ?? "narration drift");
10764
+ }
10220
10765
  if (narration && !format.navigable) {
10221
10766
  step(`build: ${format.id} renders linearly, so its narration is timing only`);
10222
10767
  }
@@ -10267,6 +10812,8 @@ lookFlags(
10267
10812
  step(
10268
10813
  `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join15(out, "index.html")}`
10269
10814
  );
10815
+ for (const f of scanPaperArc({ ...storyboard, beats: cut.kept }, prefs))
10816
+ step(`build: ${f.message}`);
10270
10817
  reportCut(cut);
10271
10818
  if (deck.page) step(`build: navigable deck \u2192 ${join15(out, DECK_PAGE)}`);
10272
10819
  await gate(out, false, storyboard, cut.kept, o.fidelity !== false, source);