@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/mcp.js CHANGED
@@ -127,6 +127,13 @@ var equationWalkParamsSchema = z.object({
127
127
  /** Walked in order, one hold-point each. */
128
128
  terms: z.array(termSchema).min(1).max(4)
129
129
  });
130
+ var equationMorphParamsSchema = z.object({
131
+ eyebrow: z.string().optional(),
132
+ headline: z.string(),
133
+ fromId: z.string(),
134
+ toId: z.string(),
135
+ terms: z.array(termSchema).min(1).max(4)
136
+ });
130
137
  var dataTableParamsSchema = z.object({
131
138
  eyebrow: z.string().optional(),
132
139
  headline: z.string(),
@@ -255,7 +262,22 @@ var stackParamsSchema = z.object({
255
262
  headline: z.string(),
256
263
  /** Drawn bottom-up as offset planes, revealed in order. */
257
264
  layers: z.array(z.object({ label: z.string(), note: z.string().optional() })).min(2).max(7),
258
- note: z.string().optional()
265
+ note: z.string().optional(),
266
+ /**
267
+ * Tilt the slab stack away from the viewer, so the planes read as stacked in
268
+ * depth rather than merely offset up the page.
269
+ *
270
+ * OPTIONAL, and absent means flat — every storyboard written before this
271
+ * existed still parses, and every deck that does not ask for it emits exactly
272
+ * the bytes it did before.
273
+ *
274
+ * Degrees, and bounded at 18 rather than by taste: the tilt is paid for in
275
+ * declared type, because perspective shrinks the far half of the plane and
276
+ * invariant 5 is about what the audience SEES. At 18 degrees a 40px floor
277
+ * already needs 53.8px declared (`src/emit/depth.ts`), and past that a
278
+ * headline cannot spend enough and still fit its own line.
279
+ */
280
+ tilt: z.number().min(0).max(18).optional()
259
281
  });
260
282
  var splitSideSchema = z.object({
261
283
  label: z.string(),
@@ -288,10 +310,16 @@ var insideSchema = z.object({
288
310
  */
289
311
  label: z.string().optional()
290
312
  });
313
+ var beatRoleSchema = z.enum(["intro", "background", "limitations", "conclusion"]);
291
314
  var beatCore = {
292
315
  id: z.string(),
293
316
  /** What the viewer should understand after this beat. */
294
317
  intent: z.string(),
318
+ /**
319
+ * OPTIONAL, and only ever present when `prefs.genre` is `paper`. The
320
+ * structural job this beat does; see `beatRoleSchema`.
321
+ */
322
+ role: beatRoleSchema.optional(),
295
323
  /** Optional: this beat happens inside a named part of the beat before it. */
296
324
  inside: insideSchema.optional(),
297
325
  /** The source sentence or equation this beat is accountable to. */
@@ -320,6 +348,12 @@ var beatSchema = z.discriminatedUnion("archetype", [
320
348
  params: equationWalkParamsSchema,
321
349
  ...beatTail
322
350
  }),
351
+ z.object({
352
+ ...beatCore,
353
+ archetype: z.literal("equation-morph"),
354
+ params: equationMorphParamsSchema,
355
+ ...beatTail
356
+ }),
323
357
  z.object({
324
358
  ...beatCore,
325
359
  archetype: z.literal("data-table"),
@@ -377,7 +411,8 @@ var ARCHETYPE_FAMILY = {
377
411
  "bar-compare": "quantity",
378
412
  "line-chart": "quantity",
379
413
  "data-table": "quantity",
380
- "equation-walk": "formal"
414
+ "equation-walk": "formal",
415
+ "equation-morph": "formal"
381
416
  };
382
417
  var storyboardSchema = z.object({
383
418
  sourceId: z.string(),
@@ -419,6 +454,26 @@ var prefsSchema = z.object({
419
454
  tone: z.enum(["plain", "academic", "conversational", "punchy"]).default("plain"),
420
455
  /** How much text a slide may carry before it should have been a diagram. */
421
456
  density: z.enum(["sparse", "normal", "dense"]).default("normal"),
457
+ /**
458
+ * What kind of document is being explained, DECLARED and never sniffed.
459
+ *
460
+ * `paper` asks the planner for the shape a research talk has: open on the
461
+ * problem and the ground the work stands on, close on what it does not do and
462
+ * then what to take away. `general` is every deck built before this existed
463
+ * and changes nothing — no prompt block, no `role` in the planner's schema, no
464
+ * scan.
465
+ *
466
+ * WHY DECLARED. A ten-role heading lexicon (en/ko/ja/zh, numbered-prefix
467
+ * tolerant) run over all 351 markdown files in this repository scored 345 of
468
+ * them at zero role hits and none at three or more. `src/source/markdown.ts`
469
+ * says why in its first line: the input is a hypepaper-style ANALYSIS of a
470
+ * paper, a rewrite that has already discarded the headings a detector would
471
+ * key on. A classifier here would be a guess with a confidence score attached,
472
+ * and it would guess wrong on the Korean fixture. So the author says so once —
473
+ * `--genre paper`, or one line in a `decksmith.config.json` above a directory
474
+ * of papers — and every run under it costs no further typing.
475
+ */
476
+ genre: z.enum(["general", "paper"]).default("general"),
422
477
  /**
423
478
  * How long the finished thing should run, in seconds. Optional: absent means
424
479
  * "as long as it takes", which is what every deck built before this did.
@@ -676,7 +731,10 @@ function selectBeats(storyboard, budget2, seconds = {}) {
676
731
  const protectedIds = protect(live, len, cap);
677
732
  const keep = knapsack(live, len, cap, protectedIds);
678
733
  if (!keep) {
679
- const all = knapsack(live, len, cap, /* @__PURE__ */ new Set());
734
+ const ends = new Set(
735
+ [live[0]?.id, live[live.length - 1]?.id].filter((id2) => !!id2)
736
+ );
737
+ const all = knapsack(live, len, cap, ends) ?? knapsack(live, len, cap, /* @__PURE__ */ new Set());
680
738
  const chosen = all ?? [live[0]];
681
739
  return budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, false);
682
740
  }
@@ -708,8 +766,17 @@ function protect(live, len, cap) {
708
766
  }
709
767
  const picture = cheapest && !ids.has(cheapest.id) ? [cheapest] : [];
710
768
  for (const b of picture) ids.add(b.id);
769
+ const byId = new Map(live.map((b) => [b.id, b]));
770
+ const roled = [];
771
+ for (const b of live) {
772
+ if (!b.role) continue;
773
+ for (let hop = b; hop && !ids.has(hop.id); hop = byId.get(hop.inside?.beat ?? "")) {
774
+ ids.add(hop.id);
775
+ roled.push(hop);
776
+ }
777
+ }
711
778
  const ends = /* @__PURE__ */ new Set([first?.id, last?.id]);
712
- const releasable = [...tier, ...coverage, ...picture].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
779
+ const releasable = [...tier, ...coverage, ...picture, ...roled].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
713
780
  const cost = () => live.filter((b) => ids.has(b.id)).reduce((s, b) => s + len(b), 0);
714
781
  for (const b of releasable) {
715
782
  if (cost() <= cap) break;
@@ -3082,7 +3149,7 @@ function locate(tex, term) {
3082
3149
  end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
3083
3150
  return { start, end: Math.min(end, tex.length) };
3084
3151
  }
3085
- function wrapTerms(tex, terms, beatId) {
3152
+ function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
3086
3153
  let parts = [{ text: tex, raw: true }];
3087
3154
  const used = [];
3088
3155
  const missing = [];
@@ -3098,7 +3165,7 @@ function wrapTerms(tex, terms, beatId) {
3098
3165
  1,
3099
3166
  { text: part.text.slice(0, at.start), raw: true },
3100
3167
  {
3101
- text: `\\htmlClass{term t-${term.tone}}{${part.text.slice(at.start, at.end)}}`,
3168
+ text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
3102
3169
  raw: false
3103
3170
  },
3104
3171
  { text: part.text.slice(at.end), raw: true }
@@ -3131,6 +3198,24 @@ function statements(tex, stacked) {
3131
3198
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
3132
3199
  return parts.length > 0 ? parts : [tex];
3133
3200
  }
3201
+ function legendRows(sid, terms, theme) {
3202
+ return terms.map(
3203
+ (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>`
3204
+ ).join("\n ");
3205
+ }
3206
+ function legendCss(theme) {
3207
+ return [
3208
+ // `width:fit-content` + auto margins, not `align-items:center`: centring
3209
+ // each row individually gave the legend a ragged left edge, because a short
3210
+ // label indented its own chip further than a long one did. The column is
3211
+ // centred as one block and the rows start on a shared spine.
3212
+ ".legend{display:flex;flex-direction:column;gap:30px;width:fit-content;margin-inline:auto}",
3213
+ `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
3214
+ // A common chip width, so the labels share a spine too — the glyphs inside
3215
+ // are one symbol each and their natural widths differ by a few pixels.
3216
+ `.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}`
3217
+ ].join("\n");
3218
+ }
3134
3219
  var equationWalk = (beat, ctx) => {
3135
3220
  const { sid, theme } = ctx;
3136
3221
  const p = beat.params;
@@ -3142,9 +3227,7 @@ var equationWalk = (beat, ctx) => {
3142
3227
  }
3143
3228
  const walk = wrapTerms(eq.tex, p.terms, beat.id);
3144
3229
  const terms = walk.used;
3145
- const legend = terms.map(
3146
- (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>`
3147
- ).join("\n ");
3230
+ const legend = legendRows(sid, terms, theme);
3148
3231
  const stacked = isPortrait(ctx.format);
3149
3232
  const raw2 = statements(eq.tex, stacked);
3150
3233
  const shown = statements(walk.tex, stacked);
@@ -3243,15 +3326,7 @@ var equationWalk = (beat, ctx) => {
3243
3326
  ".eqstack{display:flex;flex-direction:column;gap:32px}",
3244
3327
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
3245
3328
  ".term{display:inline-block}",
3246
- // `width:fit-content` + auto margins, not `align-items:center`: centring
3247
- // each row individually gave the legend a ragged left edge, because a short
3248
- // label indented its own chip further than a long one did. The column is
3249
- // centred as one block and the rows start on a shared spine.
3250
- ".legend{display:flex;flex-direction:column;gap:30px;width:fit-content;margin-inline:auto}",
3251
- `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
3252
- // A common chip width, so the labels share a spine too — the glyphs inside
3253
- // are one symbol each and their natural widths differ by a few pixels.
3254
- `.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}`,
3329
+ legendCss(theme),
3255
3330
  // The block, not the term under discussion: which term that is, is a fact
3256
3331
  // about the paused timeline, and CSS cannot see it. The terms are also the
3257
3332
  // one thing here GSAP tints and swells, so a rule on them would win the
@@ -3261,6 +3336,117 @@ var equationWalk = (beat, ctx) => {
3261
3336
  };
3262
3337
  };
3263
3338
 
3339
+ // src/emit/archetypes/equation-morph.ts
3340
+ var MORPH_SECONDS = 1.6;
3341
+ var equationMorph = (beat, ctx) => {
3342
+ const { sid, theme } = ctx;
3343
+ const p = beat.params;
3344
+ const find2 = (id2) => {
3345
+ const eq = ctx.source.equations.find((e) => e.id === id2);
3346
+ if (!eq)
3347
+ throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3348
+ return eq;
3349
+ };
3350
+ const a = find2(p.fromId);
3351
+ const b = find2(p.toId);
3352
+ const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3353
+ if (both.length === 0) {
3354
+ throw new Error(
3355
+ `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)}`
3356
+ );
3357
+ }
3358
+ const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
3359
+ const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
3360
+ const wb = wrapTerms(b.tex, both, beat.id, cls).tex;
3361
+ const size = Math.max(
3362
+ MIN_FONT,
3363
+ Math.min(
3364
+ equationSize(a.tex.length > b.tex.length ? a.tex : b.tex),
3365
+ Math.floor(contentW(ctx.format) / Math.max(texUnits(a.tex), texUnits(b.tex)))
3366
+ )
3367
+ );
3368
+ const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
3369
+ <div class="eqslide">
3370
+ <div class="morph" id="${sid}-morph" style="font-size:${size}px">
3371
+ <div class="side" data-morph="a" id="${sid}-eqa"></div>
3372
+ <div class="side" data-morph="b" id="${sid}-eqb"></div>
3373
+ </div>
3374
+ <div class="legend">
3375
+ ${legendRows(sid, both, theme)}
3376
+ </div>
3377
+ </div>`;
3378
+ const setup = [
3379
+ `var OPTS = ${OPTS};`,
3380
+ `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
3381
+ `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
3382
+ ...both.map(
3383
+ (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
3384
+ )
3385
+ ];
3386
+ const first = 1.8;
3387
+ const at = Math.round(
3388
+ Math.max(2.6, Math.min(beat.seconds - MORPH_SECONDS - 0.9, beat.seconds * 0.45)) * 100
3389
+ ) / 100;
3390
+ const tl = [
3391
+ ...chromeIn(sid, p.eyebrow !== void 0),
3392
+ tween(`#${sid}-morph`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8),
3393
+ ...both.map(
3394
+ (t2, i) => tween(
3395
+ `#${sid}-leg-${t2.tone}`,
3396
+ { opacity: 0, x: -18 },
3397
+ { opacity: 1, x: 0, duration: 0.5 },
3398
+ 1 + i * 0.15
3399
+ )
3400
+ ),
3401
+ // ONE tween, on the host, driving the plugin. Its ease is "none" because the
3402
+ // plan carries its own eases per segment; `pace` scales this duration and
3403
+ // the plan, being in fractions of it, scales with it.
3404
+ tween(
3405
+ `#${sid}-morph`,
3406
+ { dsMorph: 0 },
3407
+ { dsMorph: 1, duration: MORPH_SECONDS, ease: "none" },
3408
+ at
3409
+ )
3410
+ ];
3411
+ return {
3412
+ html,
3413
+ tl,
3414
+ setup,
3415
+ // SEAM B: the plan is browser geometry after fonts, so it is built inside
3416
+ // the ready gate, and the plugin tween above finds it on the host.
3417
+ measure: [`DSMorph.build(document.getElementById("${sid}-morph"));`],
3418
+ plugins: ["dsMorph"],
3419
+ holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
3420
+ css: [
3421
+ chromeCss(theme),
3422
+ ".eqslide{display:flex;flex-direction:column;justify-content:space-evenly;gap:64px;flex:1;min-height:0}",
3423
+ ".katex-display{margin:0 !important}",
3424
+ // Both sides in one grid cell, so the host is as tall as the taller line
3425
+ // and neither needs a guessed height; the overlay is absolute over it.
3426
+ // Padded by the room an arc needs, so a bowing glyph stays inside its
3427
+ // offset parent and the layout gate's `escaped_container` stays quiet; the
3428
+ // bow is capped to the same 0.8em in `plan`.
3429
+ `.morph{position:relative;display:grid;place-items:center;text-align:center;padding:0.8em 0.5em;color:${theme.fg}}`,
3430
+ ".side{grid-area:1/1}",
3431
+ // B is measured, never seen: the runtime lifts its glyphs into the overlay
3432
+ // and drives them from there. Hidden by the sheet so nothing is captured
3433
+ // before the gate has built the plan.
3434
+ '.side[data-morph="b"]{visibility:hidden}',
3435
+ ".ds-morph-layer{position:absolute;inset:0}",
3436
+ ".term{display:inline-block}",
3437
+ // Keys are tinted from the start, on BOTH lines — the colour is what lets
3438
+ // a viewer follow a body across the move. Scoped under `.morph` because
3439
+ // `equation-walk` tweens `.t-<tone>` from the foreground colour, and a
3440
+ // bare rule on the class would win that cascade and cancel its walk.
3441
+ ...["a", "b", "c", "d"].map(
3442
+ (tone2) => `.morph .t-${tone2}{color:${theme.tones[tone2]}}`
3443
+ ),
3444
+ legendCss(theme),
3445
+ ambient(sid, "-morph", BREATHE)
3446
+ ].join("\n")
3447
+ };
3448
+ };
3449
+
3264
3450
  // src/emit/archetypes/grid.ts
3265
3451
  var LABEL = 42;
3266
3452
  var LH = 1.25;
@@ -4544,9 +4730,36 @@ var splitCompare = (beat, ctx) => {
4544
4730
  };
4545
4731
  };
4546
4732
 
4733
+ // src/emit/depth.ts
4734
+ var DEFAULT_POSE = { rotateX: 12, perspective: 1400 };
4735
+ function scaleAt(pose, dy) {
4736
+ const t2 = pose.rotateX * Math.PI / 180;
4737
+ const denom = pose.perspective - dy * Math.sin(t2);
4738
+ if (denom <= 0) return 0;
4739
+ return Math.cos(t2) * (pose.perspective / denom) ** 2;
4740
+ }
4741
+ var MODEL_SLACK = 0.98;
4742
+ function worstScale(pose, height) {
4743
+ if (scaleAt(pose, height / 2) <= 0) return 0;
4744
+ return scaleAt(pose, -height / 2) * MODEL_SLACK;
4745
+ }
4746
+ function tiltedFloor(pose, height, floor) {
4747
+ const s = worstScale(pose, height);
4748
+ return s > 0 ? floor / s : Number.POSITIVE_INFINITY;
4749
+ }
4750
+ function depthCss(sid, pose, part) {
4751
+ return [
4752
+ `#${sid} { perspective: ${pose.perspective}px; }`,
4753
+ `#${sid} ${part} { transform: rotateX(${pose.rotateX}deg); transform-origin: 50% 50%; }`
4754
+ ].join("\n");
4755
+ }
4756
+
4547
4757
  // src/emit/archetypes/stack.ts
4548
4758
  var GAP3 = 44;
4549
4759
  var NUM_X = 48;
4760
+ function numSpine(floor) {
4761
+ return Math.round(NUM_X * floor * 100 / MIN_FONT) / 100;
4762
+ }
4550
4763
  var PROBE_X = 16;
4551
4764
  var PROBE_H = 46;
4552
4765
  var NUM_W = 70;
@@ -4567,15 +4780,20 @@ function stackLayout(p, format, face = "latin") {
4567
4780
  function labelWeight(i, count) {
4568
4781
  return i === count - 1 ? 700 : 600;
4569
4782
  }
4783
+ function floorFor(p, format) {
4784
+ if (!p.tilt) return MIN_FONT;
4785
+ return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
4786
+ }
4570
4787
  function solve2(p, format, inline, face) {
4571
4788
  const width = contentW(format);
4572
4789
  const boxH = contentH(format);
4790
+ const floor = floorFor(p, format);
4573
4791
  const count = p.layers.length;
4574
4792
  const k = isPortrait(format) ? "tall" : "wide";
4575
4793
  const riseMax = RISE_MAX[k];
4576
4794
  const syMax = SY_MAX[k];
4577
4795
  const tMax = T_MAX[k];
4578
- const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, MIN_FONT, 400, 0, false, face) + 28 : 0;
4796
+ const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4579
4797
  const want = Math.max(
4580
4798
  ...p.layers.map(
4581
4799
  (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
@@ -4590,7 +4808,7 @@ function solve2(p, format, inline, face) {
4590
4808
  (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count), 0, false, face))
4591
4809
  )
4592
4810
  );
4593
- const labelSize = Math.max(MIN_FONT, Math.min(LABEL_SIZE2, labelRoom));
4811
+ const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
4594
4812
  const lines = p.layers.map((l, i) => {
4595
4813
  const nw = noteW(l);
4596
4814
  const labelMaxW = Math.max(labelSize, colW - nw);
@@ -4598,14 +4816,14 @@ function solve2(p, format, inline, face) {
4598
4816
  label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4599
4817
  // Inline notes stay on one line by contract — the schema calls a note "one
4600
4818
  // short line" — and wrapping one would put its second line under the label.
4601
- note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, MIN_FONT, colW, 400, 0, face),
4819
+ note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
4602
4820
  noteW: nw,
4603
4821
  labelMaxW
4604
4822
  };
4605
4823
  });
4606
4824
  const blockH = Math.max(
4607
4825
  ...lines.map(
4608
- (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)
4826
+ (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)
4609
4827
  )
4610
4828
  );
4611
4829
  const pad = Math.max(EDGE2, blockH / 2);
@@ -4625,6 +4843,7 @@ function solve2(p, format, inline, face) {
4625
4843
  // BOTH directions. A layout that fits the height and not the width is not a
4626
4844
  // layout that fits; it is one whose overflow is in the axis nothing measured.
4627
4845
  fits: room >= blockH + 10 && height <= free && wide,
4846
+ floor,
4628
4847
  wide,
4629
4848
  inline,
4630
4849
  width,
@@ -4664,7 +4883,7 @@ var stack = (beat, ctx) => {
4664
4883
  const last = count - 1;
4665
4884
  if (!L.fits) {
4666
4885
  throw new Error(
4667
- `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.`
4886
+ `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.`
4668
4887
  );
4669
4888
  }
4670
4889
  const parts = {};
@@ -4686,7 +4905,7 @@ var stack = (beat, ctx) => {
4686
4905
  const dot = circle({ x: L.x0 + L.w + L.sx / 2 + 8, y: mid }, 6, { fill: tint });
4687
4906
  const block = L.lines[i] ?? { label: [], note: [], noteW: 0, labelMaxW: L.colW };
4688
4907
  const labelH = block.label.length * L.labelSize * 1.16;
4689
- const noteH = block.note.length > 0 ? 6 + block.note.length * MIN_FONT * 1.16 : 0;
4908
+ const noteH = block.note.length > 0 ? 6 + block.note.length * L.floor * 1.16 : 0;
4690
4909
  const label = text(
4691
4910
  layer.label,
4692
4911
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
@@ -4712,7 +4931,7 @@ var stack = (beat, ctx) => {
4712
4931
  { x: L.labelX, y: mid + labelH / 2 + 3 }
4713
4932
  ),
4714
4933
  {
4715
- size: MIN_FONT,
4934
+ size: L.floor,
4716
4935
  fill: theme.muted,
4717
4936
  anchor: L.inline ? "end" : "start",
4718
4937
  maxWidth: L.inline ? void 0 : L.colW,
@@ -4723,8 +4942,8 @@ var stack = (beat, ctx) => {
4723
4942
  );
4724
4943
  const num2 = text(
4725
4944
  String(i + 1),
4726
- { x: NUM_X, y: mid },
4727
- { size: MIN_FONT, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4945
+ { x: numSpine(L.floor), y: mid },
4946
+ { size: L.floor, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4728
4947
  );
4729
4948
  return group(slab(L.x0, y0, L, tint, lift2, stroke), { id: id(sid, "lay", i), class: "lay" }) + group(num2 + leader + dot + label + note, { id: id(sid, "cap", i), class: "cap" });
4730
4949
  }).join("");
@@ -4746,6 +4965,8 @@ var stack = (beat, ctx) => {
4746
4965
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4747
4966
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4748
4967
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe3)}</div>${noteHtml}`;
4968
+ const centre2 = (count - 1) / 2;
4969
+ const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4749
4970
  const first = 0.9;
4750
4971
  const step = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count));
4751
4972
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
@@ -4756,7 +4977,7 @@ var stack = (beat, ctx) => {
4756
4977
  tl.push(
4757
4978
  tween(
4758
4979
  `#${sid}-lay${i}`,
4759
- { opacity: 0, y: 34 },
4980
+ { opacity: 0, y: enterFrom(i) },
4760
4981
  { opacity: 1, y: 0, duration: 0.55, ease: "power2.out" },
4761
4982
  at
4762
4983
  )
@@ -4799,8 +5020,12 @@ var stack = (beat, ctx) => {
4799
5020
  // The top plane is the focal point — last built, differently toned, and the
4800
5021
  // one the final hold sits on. Its entrance owns `opacity` and `transform`,
4801
5022
  // so the breath takes `filter`, the property nothing else writes.
4802
- ambient(sid, `-lay${last}`, BREATHE)
4803
- ].join("\n")
5023
+ ambient(sid, `-lay${last}`, BREATHE),
5024
+ // Absent unless the beat asked for it, so a flat stack emits the bytes it
5025
+ // always did.
5026
+ // `.stackwrap` and not the scene: the slabs lean, the headline does not.
5027
+ p.tilt ? depthCss(sid, { ...DEFAULT_POSE, rotateX: p.tilt }, ".stackwrap") : ""
5028
+ ].filter(Boolean).join("\n")
4804
5029
  };
4805
5030
  };
4806
5031
 
@@ -4815,6 +5040,7 @@ var emitters = {
4815
5040
  stack,
4816
5041
  "split-compare": splitCompare,
4817
5042
  "equation-walk": equationWalk,
5043
+ "equation-morph": equationMorph,
4818
5044
  "line-chart": lineChart,
4819
5045
  // The ones that describe.
4820
5046
  title,
@@ -5007,6 +5233,7 @@ function round4(n3) {
5007
5233
  // src/emit/composition.ts
5008
5234
  var GSAP_SRC = "./vendor/gsap.min.js";
5009
5235
  var DRAWSVG_SRC = "./vendor/DrawSVGPlugin.min.js";
5236
+ var MORPH_SRC = "./vendor/ds-morph.js";
5010
5237
  var KATEX_JS = "./vendor/katex.min.js";
5011
5238
  var KATEX_CSS = "./katex/katex.min.css";
5012
5239
  function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
@@ -5097,6 +5324,7 @@ function layout(storyboard, source, format, opts = {}) {
5097
5324
  });
5098
5325
  let start = 0;
5099
5326
  let builds = false;
5327
+ const plugins = /* @__PURE__ */ new Set();
5100
5328
  cuts.forEach((cut2, i) => {
5101
5329
  const { beat, sid, dive, inside, duration } = cut2;
5102
5330
  if (cut2.segments?.length) spoken[sid] = cut2.segments;
@@ -5111,6 +5339,7 @@ function layout(storyboard, source, format, opts = {}) {
5111
5339
  }
5112
5340
  if (scene.css) archetypeCss.add(scene.css.trim());
5113
5341
  if (scene.measure?.length) builds = true;
5342
+ for (const p of scene.plugins ?? []) plugins.add(p);
5114
5343
  scenes.push(
5115
5344
  sceneHtml(
5116
5345
  sid,
@@ -5142,7 +5371,8 @@ function layout(storyboard, source, format, opts = {}) {
5142
5371
  spoken,
5143
5372
  total: start,
5144
5373
  cut,
5145
- builds
5374
+ builds,
5375
+ plugins
5146
5376
  };
5147
5377
  }
5148
5378
  function enteredParts(beats) {
@@ -5223,6 +5453,9 @@ function renderComposition(storyboard, format, laid) {
5223
5453
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5224
5454
  const island = format.navigable ? `
5225
5455
  ${emitIsland(slides)}` : "";
5456
+ const morph = laid.plugins.has("dsMorph") ? `
5457
+ <script src="${MORPH_SRC}"></script>
5458
+ <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5226
5459
  return `<!doctype html>
5227
5460
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5228
5461
  <head>
@@ -5231,7 +5464,7 @@ ${emitIsland(slides)}` : "";
5231
5464
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5232
5465
  <script src="${GSAP_SRC}"></script>
5233
5466
  <script src="${DRAWSVG_SRC}"></script>
5234
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>
5467
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
5235
5468
  <link rel="stylesheet" href="${KATEX_CSS}" />
5236
5469
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5237
5470
  <style>
@@ -5942,6 +6175,17 @@ import { tmpdir } from "node:os";
5942
6175
  import { join as join3 } from "node:path";
5943
6176
  import { z as z2 } from "zod";
5944
6177
 
6178
+ // src/plan/arc.ts
6179
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
6180
+ function requiredRoles(beatCount) {
6181
+ if (beatCount >= 8) return ARC_ROLES;
6182
+ if (beatCount >= 5) return ["limitations", "conclusion"];
6183
+ return [];
6184
+ }
6185
+ function paperArcRequested(prefs) {
6186
+ return prefs.genre === "paper";
6187
+ }
6188
+
5945
6189
  // src/plan/duration.ts
5946
6190
  var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
5947
6191
  var LAST_HOLD_SECONDS = 4.2;
@@ -6138,6 +6382,7 @@ var REVEALS = {
6138
6382
  title: "1",
6139
6383
  "claim-figure": "2",
6140
6384
  "equation-walk": "one per term",
6385
+ "equation-morph": "2",
6141
6386
  "data-table": "one per highlighted row, plus 1",
6142
6387
  "line-chart": "1",
6143
6388
  callout: "one per panel",
@@ -6169,9 +6414,9 @@ A beat is one idea, one visual, one hold. It carries:
6169
6414
  beat immediately before it. See RULE 11. Leave it off unless the
6170
6415
  source itself puts one inside the other.
6171
6416
 
6172
- THE TWELVE ARCHETYPES
6417
+ THE THIRTEEN ARCHETYPES
6173
6418
 
6174
- Eight of them DRAW: they build a vector graphic out of the source's own content
6419
+ Nine of them DRAW: they build a vector graphic out of the source's own content
6175
6420
  and reveal it stage by stage, so the viewer watches the idea assemble. Four only
6176
6421
  describe. The drawing ones are the default. The describing ones are what you
6177
6422
  fall back to when a point genuinely has no shape.
@@ -6249,6 +6494,14 @@ DRAWING ARCHETYPES \u2014 reach here first
6249
6494
  An equation quoted to back a claim someone else is making is
6250
6495
  evidence under another archetype, not a beat of its own.
6251
6496
 
6497
+ equation-morph One equation becoming the next, the shared terms carried
6498
+ across. The tell: THE SOURCE DERIVES ONE LINE FROM ANOTHER \u2014 a
6499
+ substitution, a rearrangement, a special case \u2014 and the point
6500
+ is what moved. \`fromId\` and \`toId\` name two equations from
6501
+ the inventory. Each terms[].tex must appear verbatim in BOTH,
6502
+ and travels as one piece; a term in only one of them is
6503
+ dropped. Four terms maximum.
6504
+
6252
6505
  line-chart A trend the source states numerically but does not plot. The
6253
6506
  tell: A QUANTITY MOVING ALONG AN ORDERED AXIS \u2014 over length, over
6254
6507
  scale, over training. Points come from the source's numbers;
@@ -6522,6 +6775,46 @@ ${REVEAL_COUNTS}` : ` - ${n3 === 1 ? "ONE SENTENCE" : `${n3} SENTENCES`} FOR TH
6522
6775
  miss its duration.`;
6523
6776
  return { sentences, length };
6524
6777
  }
6778
+ function paperArc(slides) {
6779
+ const asked = requiredRoles(slides);
6780
+ const full = asked.includes("intro");
6781
+ return `
6782
+
6783
+ PAPER ARC \u2014 this source was declared a research paper.
6784
+
6785
+ Four beats have a structural job, and each one NAMES its job in \`role\`. Every
6786
+ other beat leaves \`role\` off. A role is a job, not a heading: never write
6787
+ "Related work" or "Conclusion" as a headline, because RULE 8 still applies to
6788
+ all four.
6789
+ ${full ? `
6790
+ role: "intro" Near the front. What problem exists and who has it, in
6791
+ the viewer's own terms. This is the opening RULE 6
6792
+ already asks for, named so the deck can be checked.
6793
+ role: "background" In the first three beats. What people did before this
6794
+ work, and where that ran out. Take it from what the
6795
+ source itself says about earlier approaches \u2014 if the
6796
+ source says nothing about them, leave the role off
6797
+ rather than inventing a literature (RULE 3).` : `
6798
+ This deck is short, so only the ENDING is required \u2014 an opening the deck
6799
+ already has is not worth a slide of its own here.`}
6800
+ role: "limitations" THE SECOND-TO-LAST beat. What the work does not do, in
6801
+ the source's own admission. Not a hedge inside another
6802
+ beat's sentence: its own slide.
6803
+ role: "conclusion" THE LAST beat, with nothing after it. What the viewer
6804
+ should carry away.
6805
+
6806
+ - The closing pair is TWO beats and they must not share an archetype (RULE 1).
6807
+ A limitation the source admits to is usually a panel; the conclusion is the
6808
+ claim the deck lands, so draw it where the source states it \u2014 bars, a
6809
+ contrast, the figure that settles it \u2014 and fall back to a panel only when it
6810
+ genuinely has no shape.
6811
+ - Give all four a weight of 0.8 or above. A short cut keeps the
6812
+ highest-weighted beats, and a structural beat below 0.8 is one the deck
6813
+ loses at the first budget.
6814
+ - If the source does not support one of these, LEAVE IT OUT. A slide that
6815
+ admits a limitation the paper never admits is worse than no slide.
6816
+ `;
6817
+ }
6525
6818
  function illustrations(images) {
6526
6819
  return `
6527
6820
 
@@ -6550,7 +6843,7 @@ nothing, so it can never dangle.
6550
6843
  }
6551
6844
  function systemPrompt(prefs) {
6552
6845
  const plan = durationPlan(prefs);
6553
- return `${rules(cadenceFor(prefs, plan))}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6846
+ return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6554
6847
 
6555
6848
  PREFERENCES \u2014 chosen by the person who asked for this deck.
6556
6849
  ${prefs.duration === void 0 ? "" : `
@@ -6647,7 +6940,8 @@ function renderSource(source) {
6647
6940
  out.push("", "== EQUATIONS ==");
6648
6941
  for (const e of source.equations)
6649
6942
  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)");
6943
+ if (!source.equations.length)
6944
+ out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
6651
6945
  out.push("", "== TABLES ==");
6652
6946
  for (const t2 of source.tables) {
6653
6947
  out.push(`[table ${t2.id}] ${t2.caption ?? "(no caption)"}`);
@@ -6719,6 +7013,10 @@ function assertRefsResolve(storyboard, source, opts = {}) {
6719
7013
  case "equation-walk":
6720
7014
  check3(beat, "equation", beat.params.equationId, "params.equationId");
6721
7015
  break;
7016
+ case "equation-morph":
7017
+ check3(beat, "equation", beat.params.fromId, "params.fromId");
7018
+ check3(beat, "equation", beat.params.toId, "params.toId");
7019
+ break;
6722
7020
  case "data-table": {
6723
7021
  check3(beat, "table", beat.params.tableId, "params.tableId");
6724
7022
  const table = source.tables.find((t2) => t2.id === beat.params.tableId);
@@ -6813,14 +7111,45 @@ function stripNulls(node) {
6813
7111
  }
6814
7112
  return out;
6815
7113
  }
6816
- var SCHEMA = forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }));
7114
+ var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
7115
+ function plannerInvisible(prefs) {
7116
+ return paperArcRequested(prefs) ? PLANNER_INVISIBLE : /* @__PURE__ */ new Set([...PLANNER_INVISIBLE, "role"]);
7117
+ }
7118
+ function hideFromPlanner(node, hidden) {
7119
+ if (Array.isArray(node)) return node.map((n3) => hideFromPlanner(n3, hidden));
7120
+ if (node === null || typeof node !== "object") return node;
7121
+ const src = node;
7122
+ const out = {};
7123
+ for (const [key, value] of Object.entries(src)) {
7124
+ if (key === "properties" && value && typeof value === "object") {
7125
+ const kept = {};
7126
+ for (const [prop, sub] of Object.entries(value))
7127
+ if (!hidden.has(prop)) kept[prop] = hideFromPlanner(sub, hidden);
7128
+ out.properties = kept;
7129
+ continue;
7130
+ }
7131
+ if (key === "required" && Array.isArray(value)) {
7132
+ out.required = value.filter((r) => typeof r !== "string" || !hidden.has(r));
7133
+ continue;
7134
+ }
7135
+ out[key] = hideFromPlanner(value, hidden);
7136
+ }
7137
+ return out;
7138
+ }
7139
+ function schemaFor(prefs) {
7140
+ return hideFromPlanner(
7141
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
7142
+ plannerInvisible(prefs)
7143
+ );
7144
+ }
7145
+ var SCHEMA = schemaFor({ genre: "general" });
6817
7146
  async function codexPlanner(source, opts = {}) {
6818
7147
  const prefs = opts.prefs ?? prefsSchema.parse({});
6819
7148
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
6820
7149
  try {
6821
7150
  const schemaPath = join3(dir, "storyboard.schema.json");
6822
7151
  const outPath = join3(dir, "storyboard.json");
6823
- await writeFile3(schemaPath, JSON.stringify(SCHEMA));
7152
+ await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
6824
7153
  await (opts.run ?? runCodex)({
6825
7154
  prompt: buildPrompt(source, prefs),
6826
7155
  schemaPath,
@@ -7943,13 +8272,14 @@ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
7943
8272
  out
7944
8273
  ];
7945
8274
  }
8275
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
7946
8276
  function audioGraph(inputs, seconds, first = 1) {
7947
8277
  const lines = inputs.map(
7948
8278
  (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
7949
8279
  );
7950
8280
  const labels = inputs.map((_, i) => `[d${i}]`).join("");
7951
8281
  lines.push(
7952
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,apad=whole_dur=${seconds.toFixed(3)}[aout]`
8282
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
7953
8283
  );
7954
8284
  return lines.join(";\n");
7955
8285
  }
@@ -8738,6 +9068,7 @@ function parseOptions(fields) {
8738
9068
  if (str(fields.lang) !== void 0) patch.lang = requireLang(str(fields.lang));
8739
9069
  if (str(fields.tone) !== void 0) patch.tone = str(fields.tone);
8740
9070
  if (str(fields.density) !== void 0) patch.density = str(fields.density);
9071
+ if (str(fields.genre) !== void 0) patch.genre = str(fields.genre);
8741
9072
  if (str(fields.duration) !== void 0)
8742
9073
  patch.duration = num("duration", fields.duration);
8743
9074
  if (str(fields.speed) !== void 0) patch.animationSpeed = num("speed", fields.speed);
@@ -9588,6 +9919,9 @@ var settingsSchema = z4.object({
9588
9919
  ),
9589
9920
  tone: z4.enum(["plain", "academic", "conversational", "punchy"]).optional(),
9590
9921
  density: z4.enum(["sparse", "normal", "dense"]).optional().describe("How much text a SLIDE carries. A different axis from narration_density."),
9922
+ genre: z4.enum(["general", "paper"]).optional().describe(
9923
+ "DECLARED, never detected. `paper` asks for a research-talk shape \u2014 an introduction and background at the front, a limitations slide and then a conclusion at the end \u2014 and reports where the deck missed it. There is no detector: the documents this tool ingests are analyses OF papers, rewritten in a way that drops the headings a detector would need, so nothing infers this and absent means `general`."
9924
+ ),
9591
9925
  duration: z4.number().min(10).max(1800).optional().describe(
9592
9926
  "Target seconds. Sets the pace, derives the slide count when you give none, and overrides animation_speed. Call decksmith_estimate_length first \u2014 a short target buys its words by saying less, not by playing faster."
9593
9927
  ),
@@ -9619,6 +9953,7 @@ function fieldsFor(s) {
9619
9953
  put("lang", s.lang);
9620
9954
  put("tone", s.tone);
9621
9955
  put("density", s.density);
9956
+ put("genre", s.genre);
9622
9957
  put("duration", s.duration);
9623
9958
  put("slides", s.slides);
9624
9959
  put("speed", s.animation_speed);