@jokerized/decksmith 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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(),
@@ -320,6 +342,12 @@ var beatSchema = z.discriminatedUnion("archetype", [
320
342
  params: equationWalkParamsSchema,
321
343
  ...beatTail
322
344
  }),
345
+ z.object({
346
+ ...beatCore,
347
+ archetype: z.literal("equation-morph"),
348
+ params: equationMorphParamsSchema,
349
+ ...beatTail
350
+ }),
323
351
  z.object({
324
352
  ...beatCore,
325
353
  archetype: z.literal("data-table"),
@@ -377,7 +405,8 @@ var ARCHETYPE_FAMILY = {
377
405
  "bar-compare": "quantity",
378
406
  "line-chart": "quantity",
379
407
  "data-table": "quantity",
380
- "equation-walk": "formal"
408
+ "equation-walk": "formal",
409
+ "equation-morph": "formal"
381
410
  };
382
411
  var storyboardSchema = z.object({
383
412
  sourceId: z.string(),
@@ -3082,7 +3111,7 @@ function locate(tex, term) {
3082
3111
  end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
3083
3112
  return { start, end: Math.min(end, tex.length) };
3084
3113
  }
3085
- function wrapTerms(tex, terms, beatId) {
3114
+ function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
3086
3115
  let parts = [{ text: tex, raw: true }];
3087
3116
  const used = [];
3088
3117
  const missing = [];
@@ -3098,7 +3127,7 @@ function wrapTerms(tex, terms, beatId) {
3098
3127
  1,
3099
3128
  { text: part.text.slice(0, at.start), raw: true },
3100
3129
  {
3101
- text: `\\htmlClass{term t-${term.tone}}{${part.text.slice(at.start, at.end)}}`,
3130
+ text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
3102
3131
  raw: false
3103
3132
  },
3104
3133
  { text: part.text.slice(at.end), raw: true }
@@ -3131,6 +3160,24 @@ function statements(tex, stacked) {
3131
3160
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
3132
3161
  return parts.length > 0 ? parts : [tex];
3133
3162
  }
3163
+ function legendRows(sid, terms, theme) {
3164
+ return terms.map(
3165
+ (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>`
3166
+ ).join("\n ");
3167
+ }
3168
+ function legendCss(theme) {
3169
+ return [
3170
+ // `width:fit-content` + auto margins, not `align-items:center`: centring
3171
+ // each row individually gave the legend a ragged left edge, because a short
3172
+ // label indented its own chip further than a long one did. The column is
3173
+ // centred as one block and the rows start on a shared spine.
3174
+ ".legend{display:flex;flex-direction:column;gap:30px;width:fit-content;margin-inline:auto}",
3175
+ `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
3176
+ // A common chip width, so the labels share a spine too — the glyphs inside
3177
+ // are one symbol each and their natural widths differ by a few pixels.
3178
+ `.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}`
3179
+ ].join("\n");
3180
+ }
3134
3181
  var equationWalk = (beat, ctx) => {
3135
3182
  const { sid, theme } = ctx;
3136
3183
  const p = beat.params;
@@ -3142,9 +3189,7 @@ var equationWalk = (beat, ctx) => {
3142
3189
  }
3143
3190
  const walk = wrapTerms(eq.tex, p.terms, beat.id);
3144
3191
  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 ");
3192
+ const legend = legendRows(sid, terms, theme);
3148
3193
  const stacked = isPortrait(ctx.format);
3149
3194
  const raw2 = statements(eq.tex, stacked);
3150
3195
  const shown = statements(walk.tex, stacked);
@@ -3243,15 +3288,7 @@ var equationWalk = (beat, ctx) => {
3243
3288
  ".eqstack{display:flex;flex-direction:column;gap:32px}",
3244
3289
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
3245
3290
  ".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}`,
3291
+ legendCss(theme),
3255
3292
  // The block, not the term under discussion: which term that is, is a fact
3256
3293
  // about the paused timeline, and CSS cannot see it. The terms are also the
3257
3294
  // one thing here GSAP tints and swells, so a rule on them would win the
@@ -3261,6 +3298,117 @@ var equationWalk = (beat, ctx) => {
3261
3298
  };
3262
3299
  };
3263
3300
 
3301
+ // src/emit/archetypes/equation-morph.ts
3302
+ var MORPH_SECONDS = 1.6;
3303
+ var equationMorph = (beat, ctx) => {
3304
+ const { sid, theme } = ctx;
3305
+ const p = beat.params;
3306
+ const find2 = (id2) => {
3307
+ const eq = ctx.source.equations.find((e) => e.id === id2);
3308
+ if (!eq)
3309
+ throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3310
+ return eq;
3311
+ };
3312
+ const a = find2(p.fromId);
3313
+ const b = find2(p.toId);
3314
+ const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3315
+ if (both.length === 0) {
3316
+ throw new Error(
3317
+ `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)}`
3318
+ );
3319
+ }
3320
+ const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
3321
+ const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
3322
+ const wb = wrapTerms(b.tex, both, beat.id, cls).tex;
3323
+ const size = Math.max(
3324
+ MIN_FONT,
3325
+ Math.min(
3326
+ equationSize(a.tex.length > b.tex.length ? a.tex : b.tex),
3327
+ Math.floor(contentW(ctx.format) / Math.max(texUnits(a.tex), texUnits(b.tex)))
3328
+ )
3329
+ );
3330
+ const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
3331
+ <div class="eqslide">
3332
+ <div class="morph" id="${sid}-morph" style="font-size:${size}px">
3333
+ <div class="side" data-morph="a" id="${sid}-eqa"></div>
3334
+ <div class="side" data-morph="b" id="${sid}-eqb"></div>
3335
+ </div>
3336
+ <div class="legend">
3337
+ ${legendRows(sid, both, theme)}
3338
+ </div>
3339
+ </div>`;
3340
+ const setup = [
3341
+ `var OPTS = ${OPTS};`,
3342
+ `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
3343
+ `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
3344
+ ...both.map(
3345
+ (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
3346
+ )
3347
+ ];
3348
+ const first = 1.8;
3349
+ const at = Math.round(
3350
+ Math.max(2.6, Math.min(beat.seconds - MORPH_SECONDS - 0.9, beat.seconds * 0.45)) * 100
3351
+ ) / 100;
3352
+ const tl = [
3353
+ ...chromeIn(sid, p.eyebrow !== void 0),
3354
+ tween(`#${sid}-morph`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8),
3355
+ ...both.map(
3356
+ (t2, i) => tween(
3357
+ `#${sid}-leg-${t2.tone}`,
3358
+ { opacity: 0, x: -18 },
3359
+ { opacity: 1, x: 0, duration: 0.5 },
3360
+ 1 + i * 0.15
3361
+ )
3362
+ ),
3363
+ // ONE tween, on the host, driving the plugin. Its ease is "none" because the
3364
+ // plan carries its own eases per segment; `pace` scales this duration and
3365
+ // the plan, being in fractions of it, scales with it.
3366
+ tween(
3367
+ `#${sid}-morph`,
3368
+ { dsMorph: 0 },
3369
+ { dsMorph: 1, duration: MORPH_SECONDS, ease: "none" },
3370
+ at
3371
+ )
3372
+ ];
3373
+ return {
3374
+ html,
3375
+ tl,
3376
+ setup,
3377
+ // SEAM B: the plan is browser geometry after fonts, so it is built inside
3378
+ // the ready gate, and the plugin tween above finds it on the host.
3379
+ measure: [`DSMorph.build(document.getElementById("${sid}-morph"));`],
3380
+ plugins: ["dsMorph"],
3381
+ holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
3382
+ css: [
3383
+ chromeCss(theme),
3384
+ ".eqslide{display:flex;flex-direction:column;justify-content:space-evenly;gap:64px;flex:1;min-height:0}",
3385
+ ".katex-display{margin:0 !important}",
3386
+ // Both sides in one grid cell, so the host is as tall as the taller line
3387
+ // and neither needs a guessed height; the overlay is absolute over it.
3388
+ // Padded by the room an arc needs, so a bowing glyph stays inside its
3389
+ // offset parent and the layout gate's `escaped_container` stays quiet; the
3390
+ // bow is capped to the same 0.8em in `plan`.
3391
+ `.morph{position:relative;display:grid;place-items:center;text-align:center;padding:0.8em 0.5em;color:${theme.fg}}`,
3392
+ ".side{grid-area:1/1}",
3393
+ // B is measured, never seen: the runtime lifts its glyphs into the overlay
3394
+ // and drives them from there. Hidden by the sheet so nothing is captured
3395
+ // before the gate has built the plan.
3396
+ '.side[data-morph="b"]{visibility:hidden}',
3397
+ ".ds-morph-layer{position:absolute;inset:0}",
3398
+ ".term{display:inline-block}",
3399
+ // Keys are tinted from the start, on BOTH lines — the colour is what lets
3400
+ // a viewer follow a body across the move. Scoped under `.morph` because
3401
+ // `equation-walk` tweens `.t-<tone>` from the foreground colour, and a
3402
+ // bare rule on the class would win that cascade and cancel its walk.
3403
+ ...["a", "b", "c", "d"].map(
3404
+ (tone2) => `.morph .t-${tone2}{color:${theme.tones[tone2]}}`
3405
+ ),
3406
+ legendCss(theme),
3407
+ ambient(sid, "-morph", BREATHE)
3408
+ ].join("\n")
3409
+ };
3410
+ };
3411
+
3264
3412
  // src/emit/archetypes/grid.ts
3265
3413
  var LABEL = 42;
3266
3414
  var LH = 1.25;
@@ -4544,6 +4692,30 @@ var splitCompare = (beat, ctx) => {
4544
4692
  };
4545
4693
  };
4546
4694
 
4695
+ // src/emit/depth.ts
4696
+ var DEFAULT_POSE = { rotateX: 12, perspective: 1400 };
4697
+ function scaleAt(pose, dy) {
4698
+ const t2 = pose.rotateX * Math.PI / 180;
4699
+ const denom = pose.perspective - dy * Math.sin(t2);
4700
+ if (denom <= 0) return 0;
4701
+ return Math.cos(t2) * (pose.perspective / denom) ** 2;
4702
+ }
4703
+ var MODEL_SLACK = 0.98;
4704
+ function worstScale(pose, height) {
4705
+ if (scaleAt(pose, height / 2) <= 0) return 0;
4706
+ return scaleAt(pose, -height / 2) * MODEL_SLACK;
4707
+ }
4708
+ function tiltedFloor(pose, height, floor) {
4709
+ const s = worstScale(pose, height);
4710
+ return s > 0 ? floor / s : Number.POSITIVE_INFINITY;
4711
+ }
4712
+ function depthCss(sid, pose, part) {
4713
+ return [
4714
+ `#${sid} { perspective: ${pose.perspective}px; }`,
4715
+ `#${sid} ${part} { transform: rotateX(${pose.rotateX}deg); transform-origin: 50% 50%; }`
4716
+ ].join("\n");
4717
+ }
4718
+
4547
4719
  // src/emit/archetypes/stack.ts
4548
4720
  var GAP3 = 44;
4549
4721
  var NUM_X = 48;
@@ -4567,15 +4739,20 @@ function stackLayout(p, format, face = "latin") {
4567
4739
  function labelWeight(i, count) {
4568
4740
  return i === count - 1 ? 700 : 600;
4569
4741
  }
4742
+ function floorFor(p, format) {
4743
+ if (!p.tilt) return MIN_FONT;
4744
+ return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
4745
+ }
4570
4746
  function solve2(p, format, inline, face) {
4571
4747
  const width = contentW(format);
4572
4748
  const boxH = contentH(format);
4749
+ const floor = floorFor(p, format);
4573
4750
  const count = p.layers.length;
4574
4751
  const k = isPortrait(format) ? "tall" : "wide";
4575
4752
  const riseMax = RISE_MAX[k];
4576
4753
  const syMax = SY_MAX[k];
4577
4754
  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;
4755
+ const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4579
4756
  const want = Math.max(
4580
4757
  ...p.layers.map(
4581
4758
  (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
@@ -4590,7 +4767,7 @@ function solve2(p, format, inline, face) {
4590
4767
  (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count), 0, false, face))
4591
4768
  )
4592
4769
  );
4593
- const labelSize = Math.max(MIN_FONT, Math.min(LABEL_SIZE2, labelRoom));
4770
+ const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
4594
4771
  const lines = p.layers.map((l, i) => {
4595
4772
  const nw = noteW(l);
4596
4773
  const labelMaxW = Math.max(labelSize, colW - nw);
@@ -4598,14 +4775,14 @@ function solve2(p, format, inline, face) {
4598
4775
  label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4599
4776
  // Inline notes stay on one line by contract — the schema calls a note "one
4600
4777
  // 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),
4778
+ note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
4602
4779
  noteW: nw,
4603
4780
  labelMaxW
4604
4781
  };
4605
4782
  });
4606
4783
  const blockH = Math.max(
4607
4784
  ...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)
4785
+ (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
4786
  )
4610
4787
  );
4611
4788
  const pad = Math.max(EDGE2, blockH / 2);
@@ -4625,6 +4802,7 @@ function solve2(p, format, inline, face) {
4625
4802
  // BOTH directions. A layout that fits the height and not the width is not a
4626
4803
  // layout that fits; it is one whose overflow is in the axis nothing measured.
4627
4804
  fits: room >= blockH + 10 && height <= free && wide,
4805
+ floor,
4628
4806
  wide,
4629
4807
  inline,
4630
4808
  width,
@@ -4664,7 +4842,7 @@ var stack = (beat, ctx) => {
4664
4842
  const last = count - 1;
4665
4843
  if (!L.fits) {
4666
4844
  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.`
4845
+ `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
4846
  );
4669
4847
  }
4670
4848
  const parts = {};
@@ -4686,7 +4864,7 @@ var stack = (beat, ctx) => {
4686
4864
  const dot = circle({ x: L.x0 + L.w + L.sx / 2 + 8, y: mid }, 6, { fill: tint });
4687
4865
  const block = L.lines[i] ?? { label: [], note: [], noteW: 0, labelMaxW: L.colW };
4688
4866
  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;
4867
+ const noteH = block.note.length > 0 ? 6 + block.note.length * L.floor * 1.16 : 0;
4690
4868
  const label = text(
4691
4869
  layer.label,
4692
4870
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
@@ -4712,7 +4890,7 @@ var stack = (beat, ctx) => {
4712
4890
  { x: L.labelX, y: mid + labelH / 2 + 3 }
4713
4891
  ),
4714
4892
  {
4715
- size: MIN_FONT,
4893
+ size: L.floor,
4716
4894
  fill: theme.muted,
4717
4895
  anchor: L.inline ? "end" : "start",
4718
4896
  maxWidth: L.inline ? void 0 : L.colW,
@@ -4724,7 +4902,7 @@ var stack = (beat, ctx) => {
4724
4902
  const num2 = text(
4725
4903
  String(i + 1),
4726
4904
  { x: NUM_X, y: mid },
4727
- { size: MIN_FONT, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4905
+ { size: L.floor, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4728
4906
  );
4729
4907
  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
4908
  }).join("");
@@ -4746,6 +4924,8 @@ var stack = (beat, ctx) => {
4746
4924
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4747
4925
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4748
4926
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe3)}</div>${noteHtml}`;
4927
+ const centre2 = (count - 1) / 2;
4928
+ const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4749
4929
  const first = 0.9;
4750
4930
  const step = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count));
4751
4931
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
@@ -4756,7 +4936,7 @@ var stack = (beat, ctx) => {
4756
4936
  tl.push(
4757
4937
  tween(
4758
4938
  `#${sid}-lay${i}`,
4759
- { opacity: 0, y: 34 },
4939
+ { opacity: 0, y: enterFrom(i) },
4760
4940
  { opacity: 1, y: 0, duration: 0.55, ease: "power2.out" },
4761
4941
  at
4762
4942
  )
@@ -4799,8 +4979,12 @@ var stack = (beat, ctx) => {
4799
4979
  // The top plane is the focal point — last built, differently toned, and the
4800
4980
  // one the final hold sits on. Its entrance owns `opacity` and `transform`,
4801
4981
  // so the breath takes `filter`, the property nothing else writes.
4802
- ambient(sid, `-lay${last}`, BREATHE)
4803
- ].join("\n")
4982
+ ambient(sid, `-lay${last}`, BREATHE),
4983
+ // Absent unless the beat asked for it, so a flat stack emits the bytes it
4984
+ // always did.
4985
+ // `.stackwrap` and not the scene: the slabs lean, the headline does not.
4986
+ p.tilt ? depthCss(sid, { ...DEFAULT_POSE, rotateX: p.tilt }, ".stackwrap") : ""
4987
+ ].filter(Boolean).join("\n")
4804
4988
  };
4805
4989
  };
4806
4990
 
@@ -4815,6 +4999,7 @@ var emitters = {
4815
4999
  stack,
4816
5000
  "split-compare": splitCompare,
4817
5001
  "equation-walk": equationWalk,
5002
+ "equation-morph": equationMorph,
4818
5003
  "line-chart": lineChart,
4819
5004
  // The ones that describe.
4820
5005
  title,
@@ -5007,6 +5192,7 @@ function round4(n3) {
5007
5192
  // src/emit/composition.ts
5008
5193
  var GSAP_SRC = "./vendor/gsap.min.js";
5009
5194
  var DRAWSVG_SRC = "./vendor/DrawSVGPlugin.min.js";
5195
+ var MORPH_SRC = "./vendor/ds-morph.js";
5010
5196
  var KATEX_JS = "./vendor/katex.min.js";
5011
5197
  var KATEX_CSS = "./katex/katex.min.css";
5012
5198
  function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
@@ -5097,6 +5283,7 @@ function layout(storyboard, source, format, opts = {}) {
5097
5283
  });
5098
5284
  let start = 0;
5099
5285
  let builds = false;
5286
+ const plugins = /* @__PURE__ */ new Set();
5100
5287
  cuts.forEach((cut2, i) => {
5101
5288
  const { beat, sid, dive, inside, duration } = cut2;
5102
5289
  if (cut2.segments?.length) spoken[sid] = cut2.segments;
@@ -5111,6 +5298,7 @@ function layout(storyboard, source, format, opts = {}) {
5111
5298
  }
5112
5299
  if (scene.css) archetypeCss.add(scene.css.trim());
5113
5300
  if (scene.measure?.length) builds = true;
5301
+ for (const p of scene.plugins ?? []) plugins.add(p);
5114
5302
  scenes.push(
5115
5303
  sceneHtml(
5116
5304
  sid,
@@ -5142,7 +5330,8 @@ function layout(storyboard, source, format, opts = {}) {
5142
5330
  spoken,
5143
5331
  total: start,
5144
5332
  cut,
5145
- builds
5333
+ builds,
5334
+ plugins
5146
5335
  };
5147
5336
  }
5148
5337
  function enteredParts(beats) {
@@ -5223,6 +5412,9 @@ function renderComposition(storyboard, format, laid) {
5223
5412
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5224
5413
  const island = format.navigable ? `
5225
5414
  ${emitIsland(slides)}` : "";
5415
+ const morph = laid.plugins.has("dsMorph") ? `
5416
+ <script src="${MORPH_SRC}"></script>
5417
+ <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5226
5418
  return `<!doctype html>
5227
5419
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5228
5420
  <head>
@@ -5231,7 +5423,7 @@ ${emitIsland(slides)}` : "";
5231
5423
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5232
5424
  <script src="${GSAP_SRC}"></script>
5233
5425
  <script src="${DRAWSVG_SRC}"></script>
5234
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>
5426
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
5235
5427
  <link rel="stylesheet" href="${KATEX_CSS}" />
5236
5428
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5237
5429
  <style>
@@ -6138,6 +6330,7 @@ var REVEALS = {
6138
6330
  title: "1",
6139
6331
  "claim-figure": "2",
6140
6332
  "equation-walk": "one per term",
6333
+ "equation-morph": "2",
6141
6334
  "data-table": "one per highlighted row, plus 1",
6142
6335
  "line-chart": "1",
6143
6336
  callout: "one per panel",
@@ -6169,9 +6362,9 @@ A beat is one idea, one visual, one hold. It carries:
6169
6362
  beat immediately before it. See RULE 11. Leave it off unless the
6170
6363
  source itself puts one inside the other.
6171
6364
 
6172
- THE TWELVE ARCHETYPES
6365
+ THE THIRTEEN ARCHETYPES
6173
6366
 
6174
- Eight of them DRAW: they build a vector graphic out of the source's own content
6367
+ Nine of them DRAW: they build a vector graphic out of the source's own content
6175
6368
  and reveal it stage by stage, so the viewer watches the idea assemble. Four only
6176
6369
  describe. The drawing ones are the default. The describing ones are what you
6177
6370
  fall back to when a point genuinely has no shape.
@@ -6249,6 +6442,14 @@ DRAWING ARCHETYPES \u2014 reach here first
6249
6442
  An equation quoted to back a claim someone else is making is
6250
6443
  evidence under another archetype, not a beat of its own.
6251
6444
 
6445
+ equation-morph One equation becoming the next, the shared terms carried
6446
+ across. The tell: THE SOURCE DERIVES ONE LINE FROM ANOTHER \u2014 a
6447
+ substitution, a rearrangement, a special case \u2014 and the point
6448
+ is what moved. \`fromId\` and \`toId\` name two equations from
6449
+ the inventory. Each terms[].tex must appear verbatim in BOTH,
6450
+ and travels as one piece; a term in only one of them is
6451
+ dropped. Four terms maximum.
6452
+
6252
6453
  line-chart A trend the source states numerically but does not plot. The
6253
6454
  tell: A QUANTITY MOVING ALONG AN ORDERED AXIS \u2014 over length, over
6254
6455
  scale, over training. Points come from the source's numbers;
@@ -6647,7 +6848,8 @@ function renderSource(source) {
6647
6848
  out.push("", "== EQUATIONS ==");
6648
6849
  for (const e of source.equations)
6649
6850
  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)");
6851
+ if (!source.equations.length)
6852
+ out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
6651
6853
  out.push("", "== TABLES ==");
6652
6854
  for (const t2 of source.tables) {
6653
6855
  out.push(`[table ${t2.id}] ${t2.caption ?? "(no caption)"}`);
@@ -6719,6 +6921,10 @@ function assertRefsResolve(storyboard, source, opts = {}) {
6719
6921
  case "equation-walk":
6720
6922
  check3(beat, "equation", beat.params.equationId, "params.equationId");
6721
6923
  break;
6924
+ case "equation-morph":
6925
+ check3(beat, "equation", beat.params.fromId, "params.fromId");
6926
+ check3(beat, "equation", beat.params.toId, "params.toId");
6927
+ break;
6722
6928
  case "data-table": {
6723
6929
  check3(beat, "table", beat.params.tableId, "params.tableId");
6724
6930
  const table = source.tables.find((t2) => t2.id === beat.params.tableId);
@@ -6813,7 +7019,31 @@ function stripNulls(node) {
6813
7019
  }
6814
7020
  return out;
6815
7021
  }
6816
- var SCHEMA = forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }));
7022
+ var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
7023
+ function hideFromPlanner(node) {
7024
+ if (Array.isArray(node)) return node.map(hideFromPlanner);
7025
+ if (node === null || typeof node !== "object") return node;
7026
+ const src = node;
7027
+ const out = {};
7028
+ for (const [key, value] of Object.entries(src)) {
7029
+ if (key === "properties" && value && typeof value === "object") {
7030
+ const kept = {};
7031
+ for (const [prop, sub] of Object.entries(value))
7032
+ if (!PLANNER_INVISIBLE.has(prop)) kept[prop] = hideFromPlanner(sub);
7033
+ out.properties = kept;
7034
+ continue;
7035
+ }
7036
+ if (key === "required" && Array.isArray(value)) {
7037
+ out.required = value.filter((r) => typeof r !== "string" || !PLANNER_INVISIBLE.has(r));
7038
+ continue;
7039
+ }
7040
+ out[key] = hideFromPlanner(value);
7041
+ }
7042
+ return out;
7043
+ }
7044
+ var SCHEMA = hideFromPlanner(
7045
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }))
7046
+ );
6817
7047
  async function codexPlanner(source, opts = {}) {
6818
7048
  const prefs = opts.prefs ?? prefsSchema.parse({});
6819
7049
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
@@ -0,0 +1,2 @@
1
+ import type { Emitter } from "../kit.js";
2
+ export declare const equationMorph: Emitter<"equation-morph">;
@@ -1,2 +1,64 @@
1
- import type { Emitter } from "../kit.js";
1
+ /**
2
+ * An equation, walked one symbol at a time.
3
+ *
4
+ * This is the most valuable thing the vocabulary does: the reader is never shown
5
+ * a wall of TeX and left to find the symbol being discussed. Each term is wrapped
6
+ * in `\htmlClass{term t-<tone>}{...}` so KaTeX emits a real element for it, which
7
+ * GSAP then tints and swells in step with its legend row.
8
+ */
9
+ import type { Term } from "../../types.js";
10
+ import type { Emitter, Theme } from "../kit.js";
11
+ export declare const OPTS = "{ displayMode: true, trust: function (c) { return c.command === \"\\\\htmlClass\"; }, strict: false, output: \"html\" }";
12
+ export declare const INLINE_OPTS = "{ displayMode: false, trust: function (c) { return c.command === \"\\\\htmlClass\"; }, strict: false, output: \"html\" }";
13
+ /** Where `term` sits in `tex`, comparing normal forms. Null when it is absent. */
14
+ export declare function locate(tex: string, term: string): {
15
+ start: number;
16
+ end: number;
17
+ } | null;
18
+ /**
19
+ * Wrap each term where it first occurs, and report which ones were wrapped.
20
+ *
21
+ * Segments are tracked as raw/wrapped so a later term cannot match inside an
22
+ * earlier term's `\htmlClass{...}` and produce nested markup that highlights the
23
+ * wrong span.
24
+ *
25
+ * A term that cannot be found is DROPPED rather than thrown on, and the caller
26
+ * drops its legend row with it — the two go together, which is what keeps this
27
+ * honest. Silently highlighting nothing is the failure this archetype exists to
28
+ * avoid, and a legend line pointing at an unhighlighted symbol is that failure;
29
+ * a shorter legend is not. Losing an entire deck to one mis-spelled term is a
30
+ * worse answer than either. If NOTHING matches, the beat has no work to do and
31
+ * that is still an error.
32
+ */
33
+ export declare function wrapTerms(tex: string, terms: Term[], beatId: string,
34
+ /** The class the wrapper carries; the morph adds its key to the walk's tint. */
35
+ cls?: (t: Term) => string): {
36
+ tex: string;
37
+ used: Term[];
38
+ missing: Term[];
39
+ };
40
+ /**
41
+ * Display equations want to live between 68px and 108px.
42
+ *
43
+ * The equation is the whole argument of this archetype and it was the smallest
44
+ * thing on the slide — 72px of TeX centred in a 1700px box, measured at 46% fill
45
+ * with a 1920x316 band under it. It is sized off the source length rather than
46
+ * off `textWidth` because TeX is not the string that gets set: `\mathcal{W}(F)`
47
+ * is fourteen characters and three glyphs.
48
+ *
49
+ * This is the *wanted* size, not the final one. The old comment here claimed
50
+ * "KaTeX's own `\displaystyle` box will shrink to the container if the estimate
51
+ * runs wide". It does not — it overflows, silently, because the composition is
52
+ * the size it says it is and no gate reads past the canvas edge. At 9:16 that
53
+ * truncated `X = \mathcal{W}(F)` to "X =" and then left the legend explaining a
54
+ * symbol the viewer could not see, which is worse than a clip: the slide
55
+ * asserted something false. `statements` and the `size` cap below are what make
56
+ * the claim true.
57
+ */
58
+ export declare function equationSize(tex: string): number;
59
+ /** Estimated rendered width of a display, in ems. */
60
+ export declare function texUnits(tex: string): number;
61
+ /** One row per term: a chip KaTeX fills in `setup`, and the label. Shared with the morph. */
62
+ export declare function legendRows(sid: string, terms: Term[], theme: Theme): string;
63
+ export declare function legendCss(theme: Theme): string;
2
64
  export declare const equationWalk: Emitter<"equation-walk">;
@@ -12,7 +12,7 @@ export declare const emitters: {
12
12
  };
13
13
  /**
14
14
  * Dispatch a beat to its emitter. The cast is the one place the pairing is taken
15
- * on trust: `emitters[beat.archetype]` is a union of twelve emitters and TypeScript
15
+ * on trust: `emitters[beat.archetype]` is a union of thirteen emitters and TypeScript
16
16
  * will not narrow the key and the beat together. The table above already proves
17
17
  * every archetype has exactly one emitter of the right shape.
18
18
  */
@@ -26,6 +26,13 @@ import type { Emitter } from "../kit.js";
26
26
  import { type Face } from "../svg.js";
27
27
  type Params = BeatOf<"stack">["params"];
28
28
  export interface StackLayout {
29
+ /**
30
+ * The type floor this layout solved against, which is `MIN_FONT` for a flat
31
+ * beat and larger for a tilted one. The emitter reads it back so the notes it
32
+ * draws are set at the same floor the solver reserved room for — they were
33
+ * pinned to `MIN_FONT` directly, which under a tilt drew them below it.
34
+ */
35
+ floor: number;
29
36
  /**
30
37
  * False when the rise the canvas allows is shorter than the tallest label
31
38
  * block — i.e. adjacent labels would overlap. `stackLayout` re-composes rather