@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/index.js CHANGED
@@ -107,6 +107,13 @@ var equationWalkParamsSchema = z.object({
107
107
  /** Walked in order, one hold-point each. */
108
108
  terms: z.array(termSchema).min(1).max(4)
109
109
  });
110
+ var equationMorphParamsSchema = z.object({
111
+ eyebrow: z.string().optional(),
112
+ headline: z.string(),
113
+ fromId: z.string(),
114
+ toId: z.string(),
115
+ terms: z.array(termSchema).min(1).max(4)
116
+ });
110
117
  var dataTableParamsSchema = z.object({
111
118
  eyebrow: z.string().optional(),
112
119
  headline: z.string(),
@@ -235,7 +242,22 @@ var stackParamsSchema = z.object({
235
242
  headline: z.string(),
236
243
  /** Drawn bottom-up as offset planes, revealed in order. */
237
244
  layers: z.array(z.object({ label: z.string(), note: z.string().optional() })).min(2).max(7),
238
- note: z.string().optional()
245
+ note: z.string().optional(),
246
+ /**
247
+ * Tilt the slab stack away from the viewer, so the planes read as stacked in
248
+ * depth rather than merely offset up the page.
249
+ *
250
+ * OPTIONAL, and absent means flat — every storyboard written before this
251
+ * existed still parses, and every deck that does not ask for it emits exactly
252
+ * the bytes it did before.
253
+ *
254
+ * Degrees, and bounded at 18 rather than by taste: the tilt is paid for in
255
+ * declared type, because perspective shrinks the far half of the plane and
256
+ * invariant 5 is about what the audience SEES. At 18 degrees a 40px floor
257
+ * already needs 53.8px declared (`src/emit/depth.ts`), and past that a
258
+ * headline cannot spend enough and still fit its own line.
259
+ */
260
+ tilt: z.number().min(0).max(18).optional()
239
261
  });
240
262
  var splitSideSchema = z.object({
241
263
  label: z.string(),
@@ -268,10 +290,16 @@ var insideSchema = z.object({
268
290
  */
269
291
  label: z.string().optional()
270
292
  });
293
+ var beatRoleSchema = z.enum(["intro", "background", "limitations", "conclusion"]);
271
294
  var beatCore = {
272
295
  id: z.string(),
273
296
  /** What the viewer should understand after this beat. */
274
297
  intent: z.string(),
298
+ /**
299
+ * OPTIONAL, and only ever present when `prefs.genre` is `paper`. The
300
+ * structural job this beat does; see `beatRoleSchema`.
301
+ */
302
+ role: beatRoleSchema.optional(),
275
303
  /** Optional: this beat happens inside a named part of the beat before it. */
276
304
  inside: insideSchema.optional(),
277
305
  /** The source sentence or equation this beat is accountable to. */
@@ -300,6 +328,12 @@ var beatSchema = z.discriminatedUnion("archetype", [
300
328
  params: equationWalkParamsSchema,
301
329
  ...beatTail
302
330
  }),
331
+ z.object({
332
+ ...beatCore,
333
+ archetype: z.literal("equation-morph"),
334
+ params: equationMorphParamsSchema,
335
+ ...beatTail
336
+ }),
303
337
  z.object({
304
338
  ...beatCore,
305
339
  archetype: z.literal("data-table"),
@@ -354,6 +388,7 @@ var DIAGRAMMATIC = /* @__PURE__ */ new Set([
354
388
  "stack",
355
389
  "split-compare",
356
390
  "equation-walk",
391
+ "equation-morph",
357
392
  "line-chart"
358
393
  ]);
359
394
  var ARCHETYPE_FAMILY = {
@@ -368,7 +403,8 @@ var ARCHETYPE_FAMILY = {
368
403
  "bar-compare": "quantity",
369
404
  "line-chart": "quantity",
370
405
  "data-table": "quantity",
371
- "equation-walk": "formal"
406
+ "equation-walk": "formal",
407
+ "equation-morph": "formal"
372
408
  };
373
409
  var storyboardSchema = z.object({
374
410
  sourceId: z.string(),
@@ -410,6 +446,26 @@ var prefsSchema = z.object({
410
446
  tone: z.enum(["plain", "academic", "conversational", "punchy"]).default("plain"),
411
447
  /** How much text a slide may carry before it should have been a diagram. */
412
448
  density: z.enum(["sparse", "normal", "dense"]).default("normal"),
449
+ /**
450
+ * What kind of document is being explained, DECLARED and never sniffed.
451
+ *
452
+ * `paper` asks the planner for the shape a research talk has: open on the
453
+ * problem and the ground the work stands on, close on what it does not do and
454
+ * then what to take away. `general` is every deck built before this existed
455
+ * and changes nothing — no prompt block, no `role` in the planner's schema, no
456
+ * scan.
457
+ *
458
+ * WHY DECLARED. A ten-role heading lexicon (en/ko/ja/zh, numbered-prefix
459
+ * tolerant) run over all 351 markdown files in this repository scored 345 of
460
+ * them at zero role hits and none at three or more. `src/source/markdown.ts`
461
+ * says why in its first line: the input is a hypepaper-style ANALYSIS of a
462
+ * paper, a rewrite that has already discarded the headings a detector would
463
+ * key on. A classifier here would be a guess with a confidence score attached,
464
+ * and it would guess wrong on the Korean fixture. So the author says so once —
465
+ * `--genre paper`, or one line in a `decksmith.config.json` above a directory
466
+ * of papers — and every run under it costs no further typing.
467
+ */
468
+ genre: z.enum(["general", "paper"]).default("general"),
413
469
  /**
414
470
  * How long the finished thing should run, in seconds. Optional: absent means
415
471
  * "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 size2 = 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:${size2}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 step = 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) {
@@ -5223,6 +5454,9 @@ function renderComposition(storyboard, format, laid) {
5223
5454
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5224
5455
  const island = format.navigable ? `
5225
5456
  ${emitIsland(slides)}` : "";
5457
+ const morph = laid.plugins.has("dsMorph") ? `
5458
+ <script src="${MORPH_SRC}"></script>
5459
+ <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5226
5460
  return `<!doctype html>
5227
5461
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5228
5462
  <head>
@@ -5231,7 +5465,7 @@ ${emitIsland(slides)}` : "";
5231
5465
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5232
5466
  <script src="${GSAP_SRC}"></script>
5233
5467
  <script src="${DRAWSVG_SRC}"></script>
5234
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>
5468
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
5235
5469
  <link rel="stylesheet" href="${KATEX_CSS}" />
5236
5470
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5237
5471
  <style>
@@ -5942,6 +6176,76 @@ import { tmpdir } from "node:os";
5942
6176
  import { join as join3 } from "node:path";
5943
6177
  import { z as z2 } from "zod";
5944
6178
 
6179
+ // src/plan/arc.ts
6180
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
6181
+ function requiredRoles(beatCount) {
6182
+ if (beatCount >= 8) return ARC_ROLES;
6183
+ if (beatCount >= 5) return ["limitations", "conclusion"];
6184
+ return [];
6185
+ }
6186
+ function paperArcRequested(prefs) {
6187
+ return prefs.genre === "paper";
6188
+ }
6189
+ function arcBeats(storyboard) {
6190
+ const by = /* @__PURE__ */ new Map();
6191
+ for (const beat of storyboard.beats) {
6192
+ if (!beat.role) continue;
6193
+ by.set(beat.role, [...by.get(beat.role) ?? [], beat]);
6194
+ }
6195
+ return by;
6196
+ }
6197
+ function arcProblems(storyboard, prefs) {
6198
+ if (!paperArcRequested(prefs)) return [];
6199
+ const beats = storyboard.beats;
6200
+ const need = requiredRoles(Math.min(prefs.slides, beats.length));
6201
+ if (!need.length) return [];
6202
+ const out = [];
6203
+ const by = arcBeats(storyboard);
6204
+ for (const role of need) {
6205
+ const held = by.get(role) ?? [];
6206
+ if (held.length === 0) {
6207
+ out.push(
6208
+ `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.`
6209
+ );
6210
+ continue;
6211
+ }
6212
+ if (held.length > 1) {
6213
+ out.push(
6214
+ `${held.length} beats carry role "${role}" (${held.map((b) => b.id).join(", ")}). A structural job belongs to one slide.`
6215
+ );
6216
+ }
6217
+ }
6218
+ const last = beats[beats.length - 1];
6219
+ if (need.includes("conclusion") && by.has("conclusion") && last?.role !== "conclusion") {
6220
+ out.push(
6221
+ `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.`
6222
+ );
6223
+ }
6224
+ const limIdx = beats.findIndex((b) => b.role === "limitations");
6225
+ const conIdx = beats.findIndex((b) => b.role === "conclusion");
6226
+ if (need.includes("limitations") && limIdx >= 0 && conIdx >= 0 && limIdx !== conIdx - 1) {
6227
+ out.push(
6228
+ `The limitations beat is not the slide immediately before the conclusion. The two are a pair and the caveat comes first.`
6229
+ );
6230
+ }
6231
+ const openingWindow = beats.slice(0, 3).map((b) => b.role);
6232
+ for (const role of need.filter((r) => r === "intro" || r === "background")) {
6233
+ if (by.has(role) && !openingWindow.includes(role)) {
6234
+ out.push(
6235
+ `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.`
6236
+ );
6237
+ }
6238
+ }
6239
+ const lim = by.get("limitations")?.[0];
6240
+ const con = by.get("conclusion")?.[0];
6241
+ if (lim && con && lim.archetype === con.archetype) {
6242
+ out.push(
6243
+ `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.`
6244
+ );
6245
+ }
6246
+ return out;
6247
+ }
6248
+
5945
6249
  // src/plan/duration.ts
5946
6250
  var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
5947
6251
  var LAST_HOLD_SECONDS = 4.2;
@@ -6138,6 +6442,7 @@ var REVEALS = {
6138
6442
  title: "1",
6139
6443
  "claim-figure": "2",
6140
6444
  "equation-walk": "one per term",
6445
+ "equation-morph": "2",
6141
6446
  "data-table": "one per highlighted row, plus 1",
6142
6447
  "line-chart": "1",
6143
6448
  callout: "one per panel",
@@ -6169,9 +6474,9 @@ A beat is one idea, one visual, one hold. It carries:
6169
6474
  beat immediately before it. See RULE 11. Leave it off unless the
6170
6475
  source itself puts one inside the other.
6171
6476
 
6172
- THE TWELVE ARCHETYPES
6477
+ THE THIRTEEN ARCHETYPES
6173
6478
 
6174
- Eight of them DRAW: they build a vector graphic out of the source's own content
6479
+ Nine of them DRAW: they build a vector graphic out of the source's own content
6175
6480
  and reveal it stage by stage, so the viewer watches the idea assemble. Four only
6176
6481
  describe. The drawing ones are the default. The describing ones are what you
6177
6482
  fall back to when a point genuinely has no shape.
@@ -6249,6 +6554,14 @@ DRAWING ARCHETYPES \u2014 reach here first
6249
6554
  An equation quoted to back a claim someone else is making is
6250
6555
  evidence under another archetype, not a beat of its own.
6251
6556
 
6557
+ equation-morph One equation becoming the next, the shared terms carried
6558
+ across. The tell: THE SOURCE DERIVES ONE LINE FROM ANOTHER \u2014 a
6559
+ substitution, a rearrangement, a special case \u2014 and the point
6560
+ is what moved. \`fromId\` and \`toId\` name two equations from
6561
+ the inventory. Each terms[].tex must appear verbatim in BOTH,
6562
+ and travels as one piece; a term in only one of them is
6563
+ dropped. Four terms maximum.
6564
+
6252
6565
  line-chart A trend the source states numerically but does not plot. The
6253
6566
  tell: A QUANTITY MOVING ALONG AN ORDERED AXIS \u2014 over length, over
6254
6567
  scale, over training. Points come from the source's numbers;
@@ -6522,6 +6835,46 @@ ${REVEAL_COUNTS}` : ` - ${n3 === 1 ? "ONE SENTENCE" : `${n3} SENTENCES`} FOR TH
6522
6835
  miss its duration.`;
6523
6836
  return { sentences, length };
6524
6837
  }
6838
+ function paperArc(slides) {
6839
+ const asked = requiredRoles(slides);
6840
+ const full = asked.includes("intro");
6841
+ return `
6842
+
6843
+ PAPER ARC \u2014 this source was declared a research paper.
6844
+
6845
+ Four beats have a structural job, and each one NAMES its job in \`role\`. Every
6846
+ other beat leaves \`role\` off. A role is a job, not a heading: never write
6847
+ "Related work" or "Conclusion" as a headline, because RULE 8 still applies to
6848
+ all four.
6849
+ ${full ? `
6850
+ role: "intro" Near the front. What problem exists and who has it, in
6851
+ the viewer's own terms. This is the opening RULE 6
6852
+ already asks for, named so the deck can be checked.
6853
+ role: "background" In the first three beats. What people did before this
6854
+ work, and where that ran out. Take it from what the
6855
+ source itself says about earlier approaches \u2014 if the
6856
+ source says nothing about them, leave the role off
6857
+ rather than inventing a literature (RULE 3).` : `
6858
+ This deck is short, so only the ENDING is required \u2014 an opening the deck
6859
+ already has is not worth a slide of its own here.`}
6860
+ role: "limitations" THE SECOND-TO-LAST beat. What the work does not do, in
6861
+ the source's own admission. Not a hedge inside another
6862
+ beat's sentence: its own slide.
6863
+ role: "conclusion" THE LAST beat, with nothing after it. What the viewer
6864
+ should carry away.
6865
+
6866
+ - The closing pair is TWO beats and they must not share an archetype (RULE 1).
6867
+ A limitation the source admits to is usually a panel; the conclusion is the
6868
+ claim the deck lands, so draw it where the source states it \u2014 bars, a
6869
+ contrast, the figure that settles it \u2014 and fall back to a panel only when it
6870
+ genuinely has no shape.
6871
+ - Give all four a weight of 0.8 or above. A short cut keeps the
6872
+ highest-weighted beats, and a structural beat below 0.8 is one the deck
6873
+ loses at the first budget.
6874
+ - If the source does not support one of these, LEAVE IT OUT. A slide that
6875
+ admits a limitation the paper never admits is worse than no slide.
6876
+ `;
6877
+ }
6525
6878
  function illustrations(images) {
6526
6879
  return `
6527
6880
 
@@ -6550,7 +6903,7 @@ nothing, so it can never dangle.
6550
6903
  }
6551
6904
  function systemPrompt(prefs) {
6552
6905
  const plan = durationPlan(prefs);
6553
- return `${rules(cadenceFor(prefs, plan))}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6906
+ return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6554
6907
 
6555
6908
  PREFERENCES \u2014 chosen by the person who asked for this deck.
6556
6909
  ${prefs.duration === void 0 ? "" : `
@@ -6647,7 +7000,8 @@ function renderSource(source) {
6647
7000
  out.push("", "== EQUATIONS ==");
6648
7001
  for (const e of source.equations)
6649
7002
  out.push(`[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${e.tex}`);
6650
- if (!source.equations.length) out.push("(none \u2014 no equation-walk beat is possible)");
7003
+ if (!source.equations.length)
7004
+ out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
6651
7005
  out.push("", "== TABLES ==");
6652
7006
  for (const t2 of source.tables) {
6653
7007
  out.push(`[table ${t2.id}] ${t2.caption ?? "(no caption)"}`);
@@ -6719,6 +7073,10 @@ function assertRefsResolve(storyboard, source, opts = {}) {
6719
7073
  case "equation-walk":
6720
7074
  check3(beat, "equation", beat.params.equationId, "params.equationId");
6721
7075
  break;
7076
+ case "equation-morph":
7077
+ check3(beat, "equation", beat.params.fromId, "params.fromId");
7078
+ check3(beat, "equation", beat.params.toId, "params.toId");
7079
+ break;
6722
7080
  case "data-table": {
6723
7081
  check3(beat, "table", beat.params.tableId, "params.tableId");
6724
7082
  const table = source.tables.find((t2) => t2.id === beat.params.tableId);
@@ -6855,14 +7213,45 @@ function stripNulls(node) {
6855
7213
  }
6856
7214
  return out;
6857
7215
  }
6858
- var SCHEMA = forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }));
7216
+ var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
7217
+ function plannerInvisible(prefs) {
7218
+ return paperArcRequested(prefs) ? PLANNER_INVISIBLE : /* @__PURE__ */ new Set([...PLANNER_INVISIBLE, "role"]);
7219
+ }
7220
+ function hideFromPlanner(node, hidden) {
7221
+ if (Array.isArray(node)) return node.map((n3) => hideFromPlanner(n3, hidden));
7222
+ if (node === null || typeof node !== "object") return node;
7223
+ const src = node;
7224
+ const out = {};
7225
+ for (const [key, value] of Object.entries(src)) {
7226
+ if (key === "properties" && value && typeof value === "object") {
7227
+ const kept = {};
7228
+ for (const [prop, sub] of Object.entries(value))
7229
+ if (!hidden.has(prop)) kept[prop] = hideFromPlanner(sub, hidden);
7230
+ out.properties = kept;
7231
+ continue;
7232
+ }
7233
+ if (key === "required" && Array.isArray(value)) {
7234
+ out.required = value.filter((r) => typeof r !== "string" || !hidden.has(r));
7235
+ continue;
7236
+ }
7237
+ out[key] = hideFromPlanner(value, hidden);
7238
+ }
7239
+ return out;
7240
+ }
7241
+ function schemaFor(prefs) {
7242
+ return hideFromPlanner(
7243
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
7244
+ plannerInvisible(prefs)
7245
+ );
7246
+ }
7247
+ var SCHEMA = schemaFor({ genre: "general" });
6859
7248
  async function codexPlanner(source, opts = {}) {
6860
7249
  const prefs = opts.prefs ?? prefsSchema.parse({});
6861
7250
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
6862
7251
  try {
6863
7252
  const schemaPath = join3(dir, "storyboard.schema.json");
6864
7253
  const outPath = join3(dir, "storyboard.json");
6865
- await writeFile3(schemaPath, JSON.stringify(SCHEMA));
7254
+ await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
6866
7255
  await (opts.run ?? runCodex)({
6867
7256
  prompt: buildPrompt(source, prefs),
6868
7257
  schemaPath,
@@ -8061,6 +8450,178 @@ async function openDeck(dir, opts = {}) {
8061
8450
  }
8062
8451
  }
8063
8452
 
8453
+ // src/verify/typefloor.ts
8454
+ var TYPE_FLOOR_PX = 40;
8455
+ var CSS_PX = /font-size\s*:\s*([0-9.]+)px/g;
8456
+ var SVG_ATTR = /\bfont-size="([0-9.]+)"/g;
8457
+ var RELATIVE = /font-size\s*:\s*[0-9.]+(em|rem|%|vw|vh|ch|ex|pt)\b/g;
8458
+ function scanTypeFloor(html, file, floorPx = TYPE_FLOOR_PX) {
8459
+ const findings = [];
8460
+ const zones = svgZones(html);
8461
+ const small = [];
8462
+ for (const pattern of [CSS_PX, SVG_ATTR]) {
8463
+ pattern.lastIndex = 0;
8464
+ for (const m of html.matchAll(pattern)) {
8465
+ const px = Number(m[1]) * userUnit(zones, m.index);
8466
+ if (px < floorPx) small.push({ px, where: where(html, m.index) });
8467
+ }
8468
+ }
8469
+ if (small.length > 0) {
8470
+ const worst = [...small].sort((a, b) => a.px - b.px);
8471
+ const named = worst.slice(0, 6).map((s) => `${round6(s.px)}px on ${s.where}`).join(", ");
8472
+ findings.push({
8473
+ severity: "error",
8474
+ gate: "typography",
8475
+ rule: "type_below_floor",
8476
+ 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.`
8477
+ });
8478
+ }
8479
+ const units = [...new Set([...html.matchAll(RELATIVE)].map((m) => m[1]))];
8480
+ if (units.length > 0) {
8481
+ findings.push({
8482
+ severity: "warning",
8483
+ gate: "typography",
8484
+ rule: "type_unmeasurable",
8485
+ 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.`
8486
+ });
8487
+ }
8488
+ return findings;
8489
+ }
8490
+ function svgZones(html) {
8491
+ const zones = [];
8492
+ for (const m of html.matchAll(/<svg\b([^>]*)>/g)) {
8493
+ const width = Number(/\bwidth="([0-9.]+)"/.exec(m[1])?.[1]);
8494
+ const box = Number(/\bviewBox="[0-9.-]+\s+[0-9.-]+\s+([0-9.]+)/.exec(m[1])?.[1]);
8495
+ const close = html.indexOf("</svg>", m.index);
8496
+ zones.push({
8497
+ start: m.index,
8498
+ end: close < 0 ? html.length : close,
8499
+ unit: width > 0 && box > 0 ? width / box : 1
8500
+ });
8501
+ }
8502
+ return zones;
8503
+ }
8504
+ function userUnit(zones, at) {
8505
+ return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
8506
+ }
8507
+ function where(html, at) {
8508
+ const open = html.lastIndexOf("<", at);
8509
+ const closed = html.lastIndexOf(">", at);
8510
+ if (open > closed) {
8511
+ const end = html.indexOf(">", at);
8512
+ const tag = html.slice(open, end < 0 ? void 0 : end + 1);
8513
+ const id2 = /\bid="([^"]+)"/.exec(tag)?.[1];
8514
+ if (id2) return `#${id2}`;
8515
+ const name = /^<([a-zA-Z][\w-]*)/.exec(tag)?.[1] ?? "an element";
8516
+ const cls = /\bclass="([^"]+)"/.exec(tag)?.[1]?.split(/\s+/)[0];
8517
+ return cls ? `${name}.${cls}` : name;
8518
+ }
8519
+ const brace = html.lastIndexOf("{", at);
8520
+ if (brace < 0) return "the stylesheet";
8521
+ const from = Math.max(html.lastIndexOf("}", brace), html.lastIndexOf(">", brace));
8522
+ const selector = html.slice(from + 1, brace).trim().split("\n").pop()?.trim();
8523
+ return selector ? selector : "the stylesheet";
8524
+ }
8525
+ function round6(px) {
8526
+ return Math.round(px * 100) / 100;
8527
+ }
8528
+
8529
+ // src/verify/apparent.ts
8530
+ function collectApparent(sid) {
8531
+ const scene = document.querySelector(`[data-composition-id="${CSS.escape(sid)}"]`);
8532
+ if (!scene) return { runs: [], stage: 1 };
8533
+ const declaredW = Number(scene.dataset.width ?? 0);
8534
+ const sceneRect = scene.getBoundingClientRect();
8535
+ const stage = declaredW > 0 && sceneRect.width > 0 ? sceneRect.width / declaredW : 1;
8536
+ const runs = [];
8537
+ const walk = (el, inSvg) => {
8538
+ const style = getComputedStyle(el);
8539
+ if (style.display === "none" || style.visibility === "hidden") return;
8540
+ if (Number(style.opacity) === 0) return;
8541
+ const within = inSvg || el.tagName.toLowerCase() === "svg";
8542
+ if (within && typeof el.getBBox === "function") {
8543
+ let hasText = false;
8544
+ for (const node of Array.from(el.childNodes))
8545
+ if (node.nodeType === 3 && node.textContent?.trim()) hasText = true;
8546
+ if (hasText) {
8547
+ let box = null;
8548
+ try {
8549
+ box = el.getBBox();
8550
+ } catch {
8551
+ box = null;
8552
+ }
8553
+ const painted = el.getBoundingClientRect().height;
8554
+ if (box && box.height > 0 && painted > 0) {
8555
+ let opacity = 1;
8556
+ for (let node = el; node; node = node.parentElement) {
8557
+ const own = Number(getComputedStyle(node).opacity);
8558
+ if (Number.isFinite(own)) opacity *= own;
8559
+ if (node === scene) break;
8560
+ }
8561
+ runs.push({
8562
+ text: (el.textContent ?? "").trim().slice(0, 40),
8563
+ declared: Number.parseFloat(getComputedStyle(el).fontSize) || 0,
8564
+ ratio: painted / box.height,
8565
+ opacity
8566
+ });
8567
+ }
8568
+ }
8569
+ }
8570
+ for (const child of Array.from(el.children)) walk(child, within);
8571
+ };
8572
+ walk(scene, false);
8573
+ return { runs, stage };
8574
+ }
8575
+ function apparentPx(run4, stage) {
8576
+ if (stage <= 0) return run4.declared;
8577
+ return run4.declared * run4.ratio / stage;
8578
+ }
8579
+ var SETTLED_OPACITY = 0.95;
8580
+ function midpoints(stops) {
8581
+ const out = [];
8582
+ for (let i = 1; i < stops.length; i++) {
8583
+ const a = stops[i - 1];
8584
+ const b = stops[i];
8585
+ if (a.sid !== b.sid || b.t <= a.t) continue;
8586
+ out.push({ sid: b.sid, t: Math.round((a.t + b.t) / 2 * 1e3) / 1e3 });
8587
+ }
8588
+ return out;
8589
+ }
8590
+ function gradeApparent(stops, floor = TYPE_FLOOR_PX) {
8591
+ const worst = /* @__PURE__ */ new Map();
8592
+ for (const stop of stops) {
8593
+ for (const run4 of stop.runs) {
8594
+ if (!stop.settled && run4.opacity < SETTLED_OPACITY) continue;
8595
+ const px = apparentPx(run4, stop.stage);
8596
+ if (px >= floor - 0.1) continue;
8597
+ const seen = worst.get(run4.text);
8598
+ if (!seen || px < seen.px)
8599
+ worst.set(run4.text, { sid: stop.sid, t: stop.t, text: run4.text, px });
8600
+ }
8601
+ }
8602
+ if (worst.size === 0) return [];
8603
+ const byScene = /* @__PURE__ */ new Map();
8604
+ for (const row of worst.values()) {
8605
+ const list = byScene.get(row.sid) ?? [];
8606
+ list.push(row);
8607
+ byScene.set(row.sid, list);
8608
+ }
8609
+ return [...byScene].map(([sid, rows]) => {
8610
+ rows.sort((a, b) => a.px - b.px);
8611
+ const smallest = rows[0];
8612
+ const named = rows.slice(0, 3).map((r) => `"${r.text}" at ${Math.round(r.px * 10) / 10}px`).join(", ");
8613
+ return {
8614
+ severity: "error",
8615
+ gate: "apparent",
8616
+ rule: "apparent_type_floor",
8617
+ // `#${sid}` as a selector, like every other finding here: `scripts/sweep.mjs`
8618
+ // reads the selector back to decide which beat a finding belongs to, and a
8619
+ // bare id was once filed as a deck-level orphan while the beat reported clean.
8620
+ 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.`
8621
+ };
8622
+ });
8623
+ }
8624
+
8064
8625
  // src/verify/overprint.ts
8065
8626
  var MIN_OVERLAP = 8;
8066
8627
  function collectSvgTextRuns(sid) {
@@ -8344,6 +8905,7 @@ async function fidelity(dir, opts = {}) {
8344
8905
  const { page, height } = deck;
8345
8906
  const measured = [];
8346
8907
  const collided = [];
8908
+ const apparent = [];
8347
8909
  for (const stop of stops) {
8348
8910
  await deck.seek(stop.t);
8349
8911
  const bandTopPx = await page.evaluate(
@@ -8356,6 +8918,11 @@ async function fidelity(dir, opts = {}) {
8356
8918
  ...stop,
8357
8919
  pairs: overprints(await page.evaluate(collectSvgTextRuns, stop.sid))
8358
8920
  });
8921
+ apparent.push({
8922
+ ...stop,
8923
+ settled: true,
8924
+ ...await page.evaluate(collectApparent, stop.sid)
8925
+ });
8359
8926
  const frame = await decodePng(await deck.shoot());
8360
8927
  measured.push({
8361
8928
  ...stop,
@@ -8363,9 +8930,17 @@ async function fidelity(dir, opts = {}) {
8363
8930
  bandTop: Math.round(1e3 * bandTopPx / height) / 1e3
8364
8931
  });
8365
8932
  }
8933
+ for (const mid of midpoints(stops)) {
8934
+ await deck.seek(mid.t);
8935
+ apparent.push({ ...mid, settled: false, ...await page.evaluate(collectApparent, mid.sid) });
8936
+ }
8366
8937
  return {
8367
8938
  stops: measured,
8368
- findings: [...gradeFidelity(measured, floor), ...gradeOverprint(collided)],
8939
+ findings: [
8940
+ ...gradeFidelity(measured, floor),
8941
+ ...gradeOverprint(collided),
8942
+ ...gradeApparent(apparent)
8943
+ ],
8369
8944
  elapsedMs: Date.now() - started
8370
8945
  };
8371
8946
  } catch (err) {
@@ -8376,82 +8951,6 @@ async function fidelity(dir, opts = {}) {
8376
8951
  }
8377
8952
  }
8378
8953
 
8379
- // src/verify/typefloor.ts
8380
- var TYPE_FLOOR_PX = 40;
8381
- var CSS_PX = /font-size\s*:\s*([0-9.]+)px/g;
8382
- var SVG_ATTR = /\bfont-size="([0-9.]+)"/g;
8383
- var RELATIVE = /font-size\s*:\s*[0-9.]+(em|rem|%|vw|vh|ch|ex|pt)\b/g;
8384
- function scanTypeFloor(html, file, floorPx = TYPE_FLOOR_PX) {
8385
- const findings = [];
8386
- const zones = svgZones(html);
8387
- const small = [];
8388
- for (const pattern of [CSS_PX, SVG_ATTR]) {
8389
- pattern.lastIndex = 0;
8390
- for (const m of html.matchAll(pattern)) {
8391
- const px = Number(m[1]) * userUnit(zones, m.index);
8392
- if (px < floorPx) small.push({ px, where: where(html, m.index) });
8393
- }
8394
- }
8395
- if (small.length > 0) {
8396
- const worst = [...small].sort((a, b) => a.px - b.px);
8397
- const named = worst.slice(0, 6).map((s) => `${round6(s.px)}px on ${s.where}`).join(", ");
8398
- findings.push({
8399
- severity: "error",
8400
- gate: "typography",
8401
- rule: "type_below_floor",
8402
- 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.`
8403
- });
8404
- }
8405
- const units = [...new Set([...html.matchAll(RELATIVE)].map((m) => m[1]))];
8406
- if (units.length > 0) {
8407
- findings.push({
8408
- severity: "warning",
8409
- gate: "typography",
8410
- rule: "type_unmeasurable",
8411
- 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.`
8412
- });
8413
- }
8414
- return findings;
8415
- }
8416
- function svgZones(html) {
8417
- const zones = [];
8418
- for (const m of html.matchAll(/<svg\b([^>]*)>/g)) {
8419
- const width = Number(/\bwidth="([0-9.]+)"/.exec(m[1])?.[1]);
8420
- const box = Number(/\bviewBox="[0-9.-]+\s+[0-9.-]+\s+([0-9.]+)/.exec(m[1])?.[1]);
8421
- const close = html.indexOf("</svg>", m.index);
8422
- zones.push({
8423
- start: m.index,
8424
- end: close < 0 ? html.length : close,
8425
- unit: width > 0 && box > 0 ? width / box : 1
8426
- });
8427
- }
8428
- return zones;
8429
- }
8430
- function userUnit(zones, at) {
8431
- return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
8432
- }
8433
- function where(html, at) {
8434
- const open = html.lastIndexOf("<", at);
8435
- const closed = html.lastIndexOf(">", at);
8436
- if (open > closed) {
8437
- const end = html.indexOf(">", at);
8438
- const tag = html.slice(open, end < 0 ? void 0 : end + 1);
8439
- const id2 = /\bid="([^"]+)"/.exec(tag)?.[1];
8440
- if (id2) return `#${id2}`;
8441
- const name = /^<([a-zA-Z][\w-]*)/.exec(tag)?.[1] ?? "an element";
8442
- const cls = /\bclass="([^"]+)"/.exec(tag)?.[1]?.split(/\s+/)[0];
8443
- return cls ? `${name}.${cls}` : name;
8444
- }
8445
- const brace = html.lastIndexOf("{", at);
8446
- if (brace < 0) return "the stylesheet";
8447
- const from = Math.max(html.lastIndexOf("}", brace), html.lastIndexOf(">", brace));
8448
- const selector = html.slice(from + 1, brace).trim().split("\n").pop()?.trim();
8449
- return selector ? selector : "the stylesheet";
8450
- }
8451
- function round6(px) {
8452
- return Math.round(px * 100) / 100;
8453
- }
8454
-
8455
8954
  // src/verify/drift.ts
8456
8955
  import { execFile as execFile2 } from "node:child_process";
8457
8956
  import { createHash as createHash7 } from "node:crypto";
@@ -8786,24 +9285,25 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
8786
9285
  check(dir, { ...opts, at: stops.map((s) => s.t) }),
8787
9286
  opts.fidelity === false ? null : fidelity(dir, { stops })
8788
9287
  ]);
8789
- const seen = [...ours, ...frames2?.findings ?? []];
9288
+ const storyboardFindings = storyboard ? [
9289
+ ...scanDiagrammatic(storyboard),
9290
+ ...scanHeadlines(storyboard),
9291
+ ...scanRepeatedObject(storyboard),
9292
+ // Needs the source as well, and says nothing without it — the same
9293
+ // silence `scanNarrationLead` keeps when its manifest is missing.
9294
+ ...source ? scanUnusedFigures(storyboard, source) : []
9295
+ ] : [];
9296
+ const seen = [...ours, ...frames2?.findings ?? [], ...storyboardFindings];
8790
9297
  return {
8791
9298
  passed: verdict.passed && seen.every((f) => f.severity !== "error"),
8792
9299
  findings: [
8793
9300
  ...seen,
8794
- // `scanBeatCount` is deliberately NOT here. It needs the preferences the
8795
- // deck was asked for, and `verify <dir>` is handed a built directory and
8796
- // nothing else — the same gating `scanNarrationLead` gets above, and for
8797
- // the same reason: a check that cannot see its inputs must not report that
8798
- // it found nothing. It runs at `plan` and `build`, where prefs exist.
8799
- ...storyboard ? [
8800
- ...scanDiagrammatic(storyboard),
8801
- ...scanHeadlines(storyboard),
8802
- ...scanRepeatedObject(storyboard),
8803
- // Needs the source as well, and says nothing without it — the same
8804
- // silence `scanNarrationLead` keeps when its manifest is missing.
8805
- ...source ? scanUnusedFigures(storyboard, source) : []
8806
- ] : [],
9301
+ // `scanBeatCount` and `scanPaperArc` are deliberately NOT here. Both need
9302
+ // the preferences the deck was asked for, and `verify <dir>` is handed a
9303
+ // built directory and nothing else — the same gating `scanNarrationLead`
9304
+ // gets above, and for the same reason: a check that cannot see its inputs
9305
+ // must not report that it found nothing. They run at `plan` and `build`,
9306
+ // where prefs exist.
8807
9307
  ...verdict.findings
8808
9308
  ]
8809
9309
  };
@@ -8988,6 +9488,32 @@ function scanBeatCount(storyboard, prefs) {
8988
9488
  }
8989
9489
  ];
8990
9490
  }
9491
+ function scanPaperArc(storyboard, prefs) {
9492
+ return arcProblems(storyboard, prefs).map((message) => ({
9493
+ severity: "warning",
9494
+ gate: "storyboard",
9495
+ rule: "paper_arc",
9496
+ message
9497
+ }));
9498
+ }
9499
+ function scanNarrationDrift(storyboard, narration) {
9500
+ const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
9501
+ const stale = [];
9502
+ for (const beat of storyboard.beats) {
9503
+ const segments = narration.beats[beat.id];
9504
+ if (!segments?.length || !flat(beat.narration)) continue;
9505
+ if (flat(segments.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
9506
+ }
9507
+ if (!stale.length) return [];
9508
+ return [
9509
+ {
9510
+ severity: "error",
9511
+ gate: "storyboard",
9512
+ rule: "narration_drift",
9513
+ 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.`
9514
+ }
9515
+ ];
9516
+ }
8991
9517
  var LEAD_SECONDS = 1;
8992
9518
  var EMPHASISED = /* @__PURE__ */ new Set(["equation-walk", "data-table"]);
8993
9519
  function scanNarrationLead(beats, timing) {
@@ -9371,13 +9897,14 @@ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
9371
9897
  out
9372
9898
  ];
9373
9899
  }
9900
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
9374
9901
  function audioGraph(inputs, seconds, first = 1) {
9375
9902
  const lines = inputs.map(
9376
9903
  (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
9377
9904
  );
9378
9905
  const labels = inputs.map((_, i) => `[d${i}]`).join("");
9379
9906
  lines.push(
9380
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,apad=whole_dur=${seconds.toFixed(3)}[aout]`
9907
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
9381
9908
  );
9382
9909
  return lines.join(";\n");
9383
9910
  }
@@ -10110,6 +10637,7 @@ export {
10110
10637
  assertInsideResolves,
10111
10638
  assertRefsResolve,
10112
10639
  barCompareParamsSchema,
10640
+ beatRoleSchema,
10113
10641
  beatSchema,
10114
10642
  buildDeck,
10115
10643
  bundleFont,
@@ -10127,6 +10655,7 @@ export {
10127
10655
  edgeProvider,
10128
10656
  emitComposition,
10129
10657
  emitDeck,
10658
+ equationMorphParamsSchema,
10130
10659
  equationSchema,
10131
10660
  equationWalkParamsSchema,
10132
10661
  fetchFigures,
@@ -10175,7 +10704,9 @@ export {
10175
10704
  sampleTimes,
10176
10705
  scanBeatCount,
10177
10706
  scanHeadlines,
10707
+ scanNarrationDrift,
10178
10708
  scanNarrationLead,
10709
+ scanPaperArc,
10179
10710
  scanRepeatedObject,
10180
10711
  scanUnusedFigures,
10181
10712
  sectionSchema,