@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/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(),
@@ -300,6 +322,12 @@ var beatSchema = z.discriminatedUnion("archetype", [
300
322
  params: equationWalkParamsSchema,
301
323
  ...beatTail
302
324
  }),
325
+ z.object({
326
+ ...beatCore,
327
+ archetype: z.literal("equation-morph"),
328
+ params: equationMorphParamsSchema,
329
+ ...beatTail
330
+ }),
303
331
  z.object({
304
332
  ...beatCore,
305
333
  archetype: z.literal("data-table"),
@@ -354,6 +382,7 @@ var DIAGRAMMATIC = /* @__PURE__ */ new Set([
354
382
  "stack",
355
383
  "split-compare",
356
384
  "equation-walk",
385
+ "equation-morph",
357
386
  "line-chart"
358
387
  ]);
359
388
  var ARCHETYPE_FAMILY = {
@@ -368,7 +397,8 @@ var ARCHETYPE_FAMILY = {
368
397
  "bar-compare": "quantity",
369
398
  "line-chart": "quantity",
370
399
  "data-table": "quantity",
371
- "equation-walk": "formal"
400
+ "equation-walk": "formal",
401
+ "equation-morph": "formal"
372
402
  };
373
403
  var storyboardSchema = z.object({
374
404
  sourceId: z.string(),
@@ -3079,7 +3109,7 @@ function locate(tex, term) {
3079
3109
  end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
3080
3110
  return { start, end: Math.min(end, tex.length) };
3081
3111
  }
3082
- function wrapTerms(tex, terms, beatId) {
3112
+ function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
3083
3113
  let parts = [{ text: tex, raw: true }];
3084
3114
  const used = [];
3085
3115
  const missing = [];
@@ -3095,7 +3125,7 @@ function wrapTerms(tex, terms, beatId) {
3095
3125
  1,
3096
3126
  { text: part.text.slice(0, at.start), raw: true },
3097
3127
  {
3098
- text: `\\htmlClass{term t-${term.tone}}{${part.text.slice(at.start, at.end)}}`,
3128
+ text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
3099
3129
  raw: false
3100
3130
  },
3101
3131
  { text: part.text.slice(at.end), raw: true }
@@ -3128,6 +3158,24 @@ function statements(tex, stacked) {
3128
3158
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
3129
3159
  return parts.length > 0 ? parts : [tex];
3130
3160
  }
3161
+ function legendRows(sid, terms, theme) {
3162
+ return terms.map(
3163
+ (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>`
3164
+ ).join("\n ");
3165
+ }
3166
+ function legendCss(theme) {
3167
+ return [
3168
+ // `width:fit-content` + auto margins, not `align-items:center`: centring
3169
+ // each row individually gave the legend a ragged left edge, because a short
3170
+ // label indented its own chip further than a long one did. The column is
3171
+ // centred as one block and the rows start on a shared spine.
3172
+ ".legend{display:flex;flex-direction:column;gap:30px;width:fit-content;margin-inline:auto}",
3173
+ `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
3174
+ // A common chip width, so the labels share a spine too — the glyphs inside
3175
+ // are one symbol each and their natural widths differ by a few pixels.
3176
+ `.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}`
3177
+ ].join("\n");
3178
+ }
3131
3179
  var equationWalk = (beat, ctx) => {
3132
3180
  const { sid, theme } = ctx;
3133
3181
  const p = beat.params;
@@ -3139,9 +3187,7 @@ var equationWalk = (beat, ctx) => {
3139
3187
  }
3140
3188
  const walk = wrapTerms(eq.tex, p.terms, beat.id);
3141
3189
  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 ");
3190
+ const legend = legendRows(sid, terms, theme);
3145
3191
  const stacked = isPortrait(ctx.format);
3146
3192
  const raw2 = statements(eq.tex, stacked);
3147
3193
  const shown = statements(walk.tex, stacked);
@@ -3240,15 +3286,7 @@ var equationWalk = (beat, ctx) => {
3240
3286
  ".eqstack{display:flex;flex-direction:column;gap:32px}",
3241
3287
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
3242
3288
  ".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}`,
3289
+ legendCss(theme),
3252
3290
  // The block, not the term under discussion: which term that is, is a fact
3253
3291
  // about the paused timeline, and CSS cannot see it. The terms are also the
3254
3292
  // one thing here GSAP tints and swells, so a rule on them would win the
@@ -3258,6 +3296,117 @@ var equationWalk = (beat, ctx) => {
3258
3296
  };
3259
3297
  };
3260
3298
 
3299
+ // src/emit/archetypes/equation-morph.ts
3300
+ var MORPH_SECONDS = 1.6;
3301
+ var equationMorph = (beat, ctx) => {
3302
+ const { sid, theme } = ctx;
3303
+ const p = beat.params;
3304
+ const find2 = (id2) => {
3305
+ const eq = ctx.source.equations.find((e) => e.id === id2);
3306
+ if (!eq)
3307
+ throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3308
+ return eq;
3309
+ };
3310
+ const a = find2(p.fromId);
3311
+ const b = find2(p.toId);
3312
+ const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3313
+ if (both.length === 0) {
3314
+ throw new Error(
3315
+ `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)}`
3316
+ );
3317
+ }
3318
+ const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
3319
+ const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
3320
+ const wb = wrapTerms(b.tex, both, beat.id, cls).tex;
3321
+ const size2 = Math.max(
3322
+ MIN_FONT,
3323
+ Math.min(
3324
+ equationSize(a.tex.length > b.tex.length ? a.tex : b.tex),
3325
+ Math.floor(contentW(ctx.format) / Math.max(texUnits(a.tex), texUnits(b.tex)))
3326
+ )
3327
+ );
3328
+ const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
3329
+ <div class="eqslide">
3330
+ <div class="morph" id="${sid}-morph" style="font-size:${size2}px">
3331
+ <div class="side" data-morph="a" id="${sid}-eqa"></div>
3332
+ <div class="side" data-morph="b" id="${sid}-eqb"></div>
3333
+ </div>
3334
+ <div class="legend">
3335
+ ${legendRows(sid, both, theme)}
3336
+ </div>
3337
+ </div>`;
3338
+ const setup = [
3339
+ `var OPTS = ${OPTS};`,
3340
+ `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
3341
+ `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
3342
+ ...both.map(
3343
+ (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
3344
+ )
3345
+ ];
3346
+ const first = 1.8;
3347
+ const at = Math.round(
3348
+ Math.max(2.6, Math.min(beat.seconds - MORPH_SECONDS - 0.9, beat.seconds * 0.45)) * 100
3349
+ ) / 100;
3350
+ const tl = [
3351
+ ...chromeIn(sid, p.eyebrow !== void 0),
3352
+ tween(`#${sid}-morph`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8),
3353
+ ...both.map(
3354
+ (t2, i) => tween(
3355
+ `#${sid}-leg-${t2.tone}`,
3356
+ { opacity: 0, x: -18 },
3357
+ { opacity: 1, x: 0, duration: 0.5 },
3358
+ 1 + i * 0.15
3359
+ )
3360
+ ),
3361
+ // ONE tween, on the host, driving the plugin. Its ease is "none" because the
3362
+ // plan carries its own eases per segment; `pace` scales this duration and
3363
+ // the plan, being in fractions of it, scales with it.
3364
+ tween(
3365
+ `#${sid}-morph`,
3366
+ { dsMorph: 0 },
3367
+ { dsMorph: 1, duration: MORPH_SECONDS, ease: "none" },
3368
+ at
3369
+ )
3370
+ ];
3371
+ return {
3372
+ html,
3373
+ tl,
3374
+ setup,
3375
+ // SEAM B: the plan is browser geometry after fonts, so it is built inside
3376
+ // the ready gate, and the plugin tween above finds it on the host.
3377
+ measure: [`DSMorph.build(document.getElementById("${sid}-morph"));`],
3378
+ plugins: ["dsMorph"],
3379
+ holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
3380
+ css: [
3381
+ chromeCss(theme),
3382
+ ".eqslide{display:flex;flex-direction:column;justify-content:space-evenly;gap:64px;flex:1;min-height:0}",
3383
+ ".katex-display{margin:0 !important}",
3384
+ // Both sides in one grid cell, so the host is as tall as the taller line
3385
+ // and neither needs a guessed height; the overlay is absolute over it.
3386
+ // Padded by the room an arc needs, so a bowing glyph stays inside its
3387
+ // offset parent and the layout gate's `escaped_container` stays quiet; the
3388
+ // bow is capped to the same 0.8em in `plan`.
3389
+ `.morph{position:relative;display:grid;place-items:center;text-align:center;padding:0.8em 0.5em;color:${theme.fg}}`,
3390
+ ".side{grid-area:1/1}",
3391
+ // B is measured, never seen: the runtime lifts its glyphs into the overlay
3392
+ // and drives them from there. Hidden by the sheet so nothing is captured
3393
+ // before the gate has built the plan.
3394
+ '.side[data-morph="b"]{visibility:hidden}',
3395
+ ".ds-morph-layer{position:absolute;inset:0}",
3396
+ ".term{display:inline-block}",
3397
+ // Keys are tinted from the start, on BOTH lines — the colour is what lets
3398
+ // a viewer follow a body across the move. Scoped under `.morph` because
3399
+ // `equation-walk` tweens `.t-<tone>` from the foreground colour, and a
3400
+ // bare rule on the class would win that cascade and cancel its walk.
3401
+ ...["a", "b", "c", "d"].map(
3402
+ (tone2) => `.morph .t-${tone2}{color:${theme.tones[tone2]}}`
3403
+ ),
3404
+ legendCss(theme),
3405
+ ambient(sid, "-morph", BREATHE)
3406
+ ].join("\n")
3407
+ };
3408
+ };
3409
+
3261
3410
  // src/emit/archetypes/grid.ts
3262
3411
  var LABEL = 42;
3263
3412
  var LH = 1.25;
@@ -4541,6 +4690,30 @@ var splitCompare = (beat, ctx) => {
4541
4690
  };
4542
4691
  };
4543
4692
 
4693
+ // src/emit/depth.ts
4694
+ var DEFAULT_POSE = { rotateX: 12, perspective: 1400 };
4695
+ function scaleAt(pose, dy) {
4696
+ const t2 = pose.rotateX * Math.PI / 180;
4697
+ const denom = pose.perspective - dy * Math.sin(t2);
4698
+ if (denom <= 0) return 0;
4699
+ return Math.cos(t2) * (pose.perspective / denom) ** 2;
4700
+ }
4701
+ var MODEL_SLACK = 0.98;
4702
+ function worstScale(pose, height) {
4703
+ if (scaleAt(pose, height / 2) <= 0) return 0;
4704
+ return scaleAt(pose, -height / 2) * MODEL_SLACK;
4705
+ }
4706
+ function tiltedFloor(pose, height, floor) {
4707
+ const s = worstScale(pose, height);
4708
+ return s > 0 ? floor / s : Number.POSITIVE_INFINITY;
4709
+ }
4710
+ function depthCss(sid, pose, part) {
4711
+ return [
4712
+ `#${sid} { perspective: ${pose.perspective}px; }`,
4713
+ `#${sid} ${part} { transform: rotateX(${pose.rotateX}deg); transform-origin: 50% 50%; }`
4714
+ ].join("\n");
4715
+ }
4716
+
4544
4717
  // src/emit/archetypes/stack.ts
4545
4718
  var GAP3 = 44;
4546
4719
  var NUM_X = 48;
@@ -4564,15 +4737,20 @@ function stackLayout(p, format, face = "latin") {
4564
4737
  function labelWeight(i, count) {
4565
4738
  return i === count - 1 ? 700 : 600;
4566
4739
  }
4740
+ function floorFor(p, format) {
4741
+ if (!p.tilt) return MIN_FONT;
4742
+ return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
4743
+ }
4567
4744
  function solve2(p, format, inline, face) {
4568
4745
  const width = contentW(format);
4569
4746
  const boxH = contentH(format);
4747
+ const floor = floorFor(p, format);
4570
4748
  const count = p.layers.length;
4571
4749
  const k = isPortrait(format) ? "tall" : "wide";
4572
4750
  const riseMax = RISE_MAX[k];
4573
4751
  const syMax = SY_MAX[k];
4574
4752
  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;
4753
+ const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4576
4754
  const want = Math.max(
4577
4755
  ...p.layers.map(
4578
4756
  (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
@@ -4587,7 +4765,7 @@ function solve2(p, format, inline, face) {
4587
4765
  (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count), 0, false, face))
4588
4766
  )
4589
4767
  );
4590
- const labelSize = Math.max(MIN_FONT, Math.min(LABEL_SIZE2, labelRoom));
4768
+ const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
4591
4769
  const lines = p.layers.map((l, i) => {
4592
4770
  const nw = noteW(l);
4593
4771
  const labelMaxW = Math.max(labelSize, colW - nw);
@@ -4595,14 +4773,14 @@ function solve2(p, format, inline, face) {
4595
4773
  label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4596
4774
  // Inline notes stay on one line by contract — the schema calls a note "one
4597
4775
  // 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),
4776
+ note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
4599
4777
  noteW: nw,
4600
4778
  labelMaxW
4601
4779
  };
4602
4780
  });
4603
4781
  const blockH = Math.max(
4604
4782
  ...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)
4783
+ (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
4784
  )
4607
4785
  );
4608
4786
  const pad = Math.max(EDGE2, blockH / 2);
@@ -4622,6 +4800,7 @@ function solve2(p, format, inline, face) {
4622
4800
  // BOTH directions. A layout that fits the height and not the width is not a
4623
4801
  // layout that fits; it is one whose overflow is in the axis nothing measured.
4624
4802
  fits: room >= blockH + 10 && height <= free && wide,
4803
+ floor,
4625
4804
  wide,
4626
4805
  inline,
4627
4806
  width,
@@ -4661,7 +4840,7 @@ var stack = (beat, ctx) => {
4661
4840
  const last = count - 1;
4662
4841
  if (!L.fits) {
4663
4842
  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.`
4843
+ `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
4844
  );
4666
4845
  }
4667
4846
  const parts = {};
@@ -4683,7 +4862,7 @@ var stack = (beat, ctx) => {
4683
4862
  const dot = circle({ x: L.x0 + L.w + L.sx / 2 + 8, y: mid }, 6, { fill: tint });
4684
4863
  const block = L.lines[i] ?? { label: [], note: [], noteW: 0, labelMaxW: L.colW };
4685
4864
  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;
4865
+ const noteH = block.note.length > 0 ? 6 + block.note.length * L.floor * 1.16 : 0;
4687
4866
  const label = text(
4688
4867
  layer.label,
4689
4868
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
@@ -4709,7 +4888,7 @@ var stack = (beat, ctx) => {
4709
4888
  { x: L.labelX, y: mid + labelH / 2 + 3 }
4710
4889
  ),
4711
4890
  {
4712
- size: MIN_FONT,
4891
+ size: L.floor,
4713
4892
  fill: theme.muted,
4714
4893
  anchor: L.inline ? "end" : "start",
4715
4894
  maxWidth: L.inline ? void 0 : L.colW,
@@ -4721,7 +4900,7 @@ var stack = (beat, ctx) => {
4721
4900
  const num = text(
4722
4901
  String(i + 1),
4723
4902
  { x: NUM_X, y: mid },
4724
- { size: MIN_FONT, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4903
+ { size: L.floor, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4725
4904
  );
4726
4905
  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
4906
  }).join("");
@@ -4743,6 +4922,8 @@ var stack = (beat, ctx) => {
4743
4922
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4744
4923
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4745
4924
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe2)}</div>${noteHtml}`;
4925
+ const centre2 = (count - 1) / 2;
4926
+ const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4746
4927
  const first = 0.9;
4747
4928
  const step = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count));
4748
4929
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
@@ -4753,7 +4934,7 @@ var stack = (beat, ctx) => {
4753
4934
  tl.push(
4754
4935
  tween(
4755
4936
  `#${sid}-lay${i}`,
4756
- { opacity: 0, y: 34 },
4937
+ { opacity: 0, y: enterFrom(i) },
4757
4938
  { opacity: 1, y: 0, duration: 0.55, ease: "power2.out" },
4758
4939
  at
4759
4940
  )
@@ -4796,8 +4977,12 @@ var stack = (beat, ctx) => {
4796
4977
  // The top plane is the focal point — last built, differently toned, and the
4797
4978
  // one the final hold sits on. Its entrance owns `opacity` and `transform`,
4798
4979
  // so the breath takes `filter`, the property nothing else writes.
4799
- ambient(sid, `-lay${last}`, BREATHE)
4800
- ].join("\n")
4980
+ ambient(sid, `-lay${last}`, BREATHE),
4981
+ // Absent unless the beat asked for it, so a flat stack emits the bytes it
4982
+ // always did.
4983
+ // `.stackwrap` and not the scene: the slabs lean, the headline does not.
4984
+ p.tilt ? depthCss(sid, { ...DEFAULT_POSE, rotateX: p.tilt }, ".stackwrap") : ""
4985
+ ].filter(Boolean).join("\n")
4801
4986
  };
4802
4987
  };
4803
4988
 
@@ -4812,6 +4997,7 @@ var emitters = {
4812
4997
  stack,
4813
4998
  "split-compare": splitCompare,
4814
4999
  "equation-walk": equationWalk,
5000
+ "equation-morph": equationMorph,
4815
5001
  "line-chart": lineChart,
4816
5002
  // The ones that describe.
4817
5003
  title,
@@ -5004,6 +5190,7 @@ function round4(n3) {
5004
5190
  // src/emit/composition.ts
5005
5191
  var GSAP_SRC = "./vendor/gsap.min.js";
5006
5192
  var DRAWSVG_SRC = "./vendor/DrawSVGPlugin.min.js";
5193
+ var MORPH_SRC = "./vendor/ds-morph.js";
5007
5194
  var KATEX_JS = "./vendor/katex.min.js";
5008
5195
  var KATEX_CSS = "./katex/katex.min.css";
5009
5196
  function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
@@ -5094,6 +5281,7 @@ function layout(storyboard, source, format, opts = {}) {
5094
5281
  });
5095
5282
  let start = 0;
5096
5283
  let builds = false;
5284
+ const plugins = /* @__PURE__ */ new Set();
5097
5285
  cuts.forEach((cut2, i) => {
5098
5286
  const { beat, sid, dive, inside, duration } = cut2;
5099
5287
  if (cut2.segments?.length) spoken[sid] = cut2.segments;
@@ -5108,6 +5296,7 @@ function layout(storyboard, source, format, opts = {}) {
5108
5296
  }
5109
5297
  if (scene.css) archetypeCss.add(scene.css.trim());
5110
5298
  if (scene.measure?.length) builds = true;
5299
+ for (const p of scene.plugins ?? []) plugins.add(p);
5111
5300
  scenes.push(
5112
5301
  sceneHtml(
5113
5302
  sid,
@@ -5139,7 +5328,8 @@ function layout(storyboard, source, format, opts = {}) {
5139
5328
  spoken,
5140
5329
  total: start,
5141
5330
  cut,
5142
- builds
5331
+ builds,
5332
+ plugins
5143
5333
  };
5144
5334
  }
5145
5335
  function enteredParts(beats) {
@@ -5223,6 +5413,9 @@ function renderComposition(storyboard, format, laid) {
5223
5413
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5224
5414
  const island = format.navigable ? `
5225
5415
  ${emitIsland(slides)}` : "";
5416
+ const morph = laid.plugins.has("dsMorph") ? `
5417
+ <script src="${MORPH_SRC}"></script>
5418
+ <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5226
5419
  return `<!doctype html>
5227
5420
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5228
5421
  <head>
@@ -5231,7 +5424,7 @@ ${emitIsland(slides)}` : "";
5231
5424
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5232
5425
  <script src="${GSAP_SRC}"></script>
5233
5426
  <script src="${DRAWSVG_SRC}"></script>
5234
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>
5427
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
5235
5428
  <link rel="stylesheet" href="${KATEX_CSS}" />
5236
5429
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5237
5430
  <style>
@@ -6138,6 +6331,7 @@ var REVEALS = {
6138
6331
  title: "1",
6139
6332
  "claim-figure": "2",
6140
6333
  "equation-walk": "one per term",
6334
+ "equation-morph": "2",
6141
6335
  "data-table": "one per highlighted row, plus 1",
6142
6336
  "line-chart": "1",
6143
6337
  callout: "one per panel",
@@ -6169,9 +6363,9 @@ A beat is one idea, one visual, one hold. It carries:
6169
6363
  beat immediately before it. See RULE 11. Leave it off unless the
6170
6364
  source itself puts one inside the other.
6171
6365
 
6172
- THE TWELVE ARCHETYPES
6366
+ THE THIRTEEN ARCHETYPES
6173
6367
 
6174
- Eight of them DRAW: they build a vector graphic out of the source's own content
6368
+ Nine of them DRAW: they build a vector graphic out of the source's own content
6175
6369
  and reveal it stage by stage, so the viewer watches the idea assemble. Four only
6176
6370
  describe. The drawing ones are the default. The describing ones are what you
6177
6371
  fall back to when a point genuinely has no shape.
@@ -6249,6 +6443,14 @@ DRAWING ARCHETYPES \u2014 reach here first
6249
6443
  An equation quoted to back a claim someone else is making is
6250
6444
  evidence under another archetype, not a beat of its own.
6251
6445
 
6446
+ equation-morph One equation becoming the next, the shared terms carried
6447
+ across. The tell: THE SOURCE DERIVES ONE LINE FROM ANOTHER \u2014 a
6448
+ substitution, a rearrangement, a special case \u2014 and the point
6449
+ is what moved. \`fromId\` and \`toId\` name two equations from
6450
+ the inventory. Each terms[].tex must appear verbatim in BOTH,
6451
+ and travels as one piece; a term in only one of them is
6452
+ dropped. Four terms maximum.
6453
+
6252
6454
  line-chart A trend the source states numerically but does not plot. The
6253
6455
  tell: A QUANTITY MOVING ALONG AN ORDERED AXIS \u2014 over length, over
6254
6456
  scale, over training. Points come from the source's numbers;
@@ -6647,7 +6849,8 @@ function renderSource(source) {
6647
6849
  out.push("", "== EQUATIONS ==");
6648
6850
  for (const e of source.equations)
6649
6851
  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)");
6852
+ if (!source.equations.length)
6853
+ out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
6651
6854
  out.push("", "== TABLES ==");
6652
6855
  for (const t2 of source.tables) {
6653
6856
  out.push(`[table ${t2.id}] ${t2.caption ?? "(no caption)"}`);
@@ -6719,6 +6922,10 @@ function assertRefsResolve(storyboard, source, opts = {}) {
6719
6922
  case "equation-walk":
6720
6923
  check3(beat, "equation", beat.params.equationId, "params.equationId");
6721
6924
  break;
6925
+ case "equation-morph":
6926
+ check3(beat, "equation", beat.params.fromId, "params.fromId");
6927
+ check3(beat, "equation", beat.params.toId, "params.toId");
6928
+ break;
6722
6929
  case "data-table": {
6723
6930
  check3(beat, "table", beat.params.tableId, "params.tableId");
6724
6931
  const table = source.tables.find((t2) => t2.id === beat.params.tableId);
@@ -6855,7 +7062,31 @@ function stripNulls(node) {
6855
7062
  }
6856
7063
  return out;
6857
7064
  }
6858
- var SCHEMA = forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }));
7065
+ var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
7066
+ function hideFromPlanner(node) {
7067
+ if (Array.isArray(node)) return node.map(hideFromPlanner);
7068
+ if (node === null || typeof node !== "object") return node;
7069
+ const src = node;
7070
+ const out = {};
7071
+ for (const [key, value] of Object.entries(src)) {
7072
+ if (key === "properties" && value && typeof value === "object") {
7073
+ const kept = {};
7074
+ for (const [prop, sub] of Object.entries(value))
7075
+ if (!PLANNER_INVISIBLE.has(prop)) kept[prop] = hideFromPlanner(sub);
7076
+ out.properties = kept;
7077
+ continue;
7078
+ }
7079
+ if (key === "required" && Array.isArray(value)) {
7080
+ out.required = value.filter((r) => typeof r !== "string" || !PLANNER_INVISIBLE.has(r));
7081
+ continue;
7082
+ }
7083
+ out[key] = hideFromPlanner(value);
7084
+ }
7085
+ return out;
7086
+ }
7087
+ var SCHEMA = hideFromPlanner(
7088
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }))
7089
+ );
6859
7090
  async function codexPlanner(source, opts = {}) {
6860
7091
  const prefs = opts.prefs ?? prefsSchema.parse({});
6861
7092
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
@@ -8061,6 +8292,178 @@ async function openDeck(dir, opts = {}) {
8061
8292
  }
8062
8293
  }
8063
8294
 
8295
+ // src/verify/typefloor.ts
8296
+ var TYPE_FLOOR_PX = 40;
8297
+ var CSS_PX = /font-size\s*:\s*([0-9.]+)px/g;
8298
+ var SVG_ATTR = /\bfont-size="([0-9.]+)"/g;
8299
+ var RELATIVE = /font-size\s*:\s*[0-9.]+(em|rem|%|vw|vh|ch|ex|pt)\b/g;
8300
+ function scanTypeFloor(html, file, floorPx = TYPE_FLOOR_PX) {
8301
+ const findings = [];
8302
+ const zones = svgZones(html);
8303
+ const small = [];
8304
+ for (const pattern of [CSS_PX, SVG_ATTR]) {
8305
+ pattern.lastIndex = 0;
8306
+ for (const m of html.matchAll(pattern)) {
8307
+ const px = Number(m[1]) * userUnit(zones, m.index);
8308
+ if (px < floorPx) small.push({ px, where: where(html, m.index) });
8309
+ }
8310
+ }
8311
+ if (small.length > 0) {
8312
+ const worst = [...small].sort((a, b) => a.px - b.px);
8313
+ const named = worst.slice(0, 6).map((s) => `${round6(s.px)}px on ${s.where}`).join(", ");
8314
+ findings.push({
8315
+ severity: "error",
8316
+ gate: "typography",
8317
+ rule: "type_below_floor",
8318
+ 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.`
8319
+ });
8320
+ }
8321
+ const units = [...new Set([...html.matchAll(RELATIVE)].map((m) => m[1]))];
8322
+ if (units.length > 0) {
8323
+ findings.push({
8324
+ severity: "warning",
8325
+ gate: "typography",
8326
+ rule: "type_unmeasurable",
8327
+ 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.`
8328
+ });
8329
+ }
8330
+ return findings;
8331
+ }
8332
+ function svgZones(html) {
8333
+ const zones = [];
8334
+ for (const m of html.matchAll(/<svg\b([^>]*)>/g)) {
8335
+ const width = Number(/\bwidth="([0-9.]+)"/.exec(m[1])?.[1]);
8336
+ const box = Number(/\bviewBox="[0-9.-]+\s+[0-9.-]+\s+([0-9.]+)/.exec(m[1])?.[1]);
8337
+ const close = html.indexOf("</svg>", m.index);
8338
+ zones.push({
8339
+ start: m.index,
8340
+ end: close < 0 ? html.length : close,
8341
+ unit: width > 0 && box > 0 ? width / box : 1
8342
+ });
8343
+ }
8344
+ return zones;
8345
+ }
8346
+ function userUnit(zones, at) {
8347
+ return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
8348
+ }
8349
+ function where(html, at) {
8350
+ const open = html.lastIndexOf("<", at);
8351
+ const closed = html.lastIndexOf(">", at);
8352
+ if (open > closed) {
8353
+ const end = html.indexOf(">", at);
8354
+ const tag = html.slice(open, end < 0 ? void 0 : end + 1);
8355
+ const id2 = /\bid="([^"]+)"/.exec(tag)?.[1];
8356
+ if (id2) return `#${id2}`;
8357
+ const name = /^<([a-zA-Z][\w-]*)/.exec(tag)?.[1] ?? "an element";
8358
+ const cls = /\bclass="([^"]+)"/.exec(tag)?.[1]?.split(/\s+/)[0];
8359
+ return cls ? `${name}.${cls}` : name;
8360
+ }
8361
+ const brace = html.lastIndexOf("{", at);
8362
+ if (brace < 0) return "the stylesheet";
8363
+ const from = Math.max(html.lastIndexOf("}", brace), html.lastIndexOf(">", brace));
8364
+ const selector = html.slice(from + 1, brace).trim().split("\n").pop()?.trim();
8365
+ return selector ? selector : "the stylesheet";
8366
+ }
8367
+ function round6(px) {
8368
+ return Math.round(px * 100) / 100;
8369
+ }
8370
+
8371
+ // src/verify/apparent.ts
8372
+ function collectApparent(sid) {
8373
+ const scene = document.querySelector(`[data-composition-id="${CSS.escape(sid)}"]`);
8374
+ if (!scene) return { runs: [], stage: 1 };
8375
+ const declaredW = Number(scene.dataset.width ?? 0);
8376
+ const sceneRect = scene.getBoundingClientRect();
8377
+ const stage = declaredW > 0 && sceneRect.width > 0 ? sceneRect.width / declaredW : 1;
8378
+ const runs = [];
8379
+ const walk = (el, inSvg) => {
8380
+ const style = getComputedStyle(el);
8381
+ if (style.display === "none" || style.visibility === "hidden") return;
8382
+ if (Number(style.opacity) === 0) return;
8383
+ const within = inSvg || el.tagName.toLowerCase() === "svg";
8384
+ if (within && typeof el.getBBox === "function") {
8385
+ let hasText = false;
8386
+ for (const node of Array.from(el.childNodes))
8387
+ if (node.nodeType === 3 && node.textContent?.trim()) hasText = true;
8388
+ if (hasText) {
8389
+ let box = null;
8390
+ try {
8391
+ box = el.getBBox();
8392
+ } catch {
8393
+ box = null;
8394
+ }
8395
+ const painted = el.getBoundingClientRect().height;
8396
+ if (box && box.height > 0 && painted > 0) {
8397
+ let opacity = 1;
8398
+ for (let node = el; node; node = node.parentElement) {
8399
+ const own = Number(getComputedStyle(node).opacity);
8400
+ if (Number.isFinite(own)) opacity *= own;
8401
+ if (node === scene) break;
8402
+ }
8403
+ runs.push({
8404
+ text: (el.textContent ?? "").trim().slice(0, 40),
8405
+ declared: Number.parseFloat(getComputedStyle(el).fontSize) || 0,
8406
+ ratio: painted / box.height,
8407
+ opacity
8408
+ });
8409
+ }
8410
+ }
8411
+ }
8412
+ for (const child of Array.from(el.children)) walk(child, within);
8413
+ };
8414
+ walk(scene, false);
8415
+ return { runs, stage };
8416
+ }
8417
+ function apparentPx(run4, stage) {
8418
+ if (stage <= 0) return run4.declared;
8419
+ return run4.declared * run4.ratio / stage;
8420
+ }
8421
+ var SETTLED_OPACITY = 0.95;
8422
+ function midpoints(stops) {
8423
+ const out = [];
8424
+ for (let i = 1; i < stops.length; i++) {
8425
+ const a = stops[i - 1];
8426
+ const b = stops[i];
8427
+ if (a.sid !== b.sid || b.t <= a.t) continue;
8428
+ out.push({ sid: b.sid, t: Math.round((a.t + b.t) / 2 * 1e3) / 1e3 });
8429
+ }
8430
+ return out;
8431
+ }
8432
+ function gradeApparent(stops, floor = TYPE_FLOOR_PX) {
8433
+ const worst = /* @__PURE__ */ new Map();
8434
+ for (const stop of stops) {
8435
+ for (const run4 of stop.runs) {
8436
+ if (!stop.settled && run4.opacity < SETTLED_OPACITY) continue;
8437
+ const px = apparentPx(run4, stop.stage);
8438
+ if (px >= floor - 0.1) continue;
8439
+ const seen = worst.get(run4.text);
8440
+ if (!seen || px < seen.px)
8441
+ worst.set(run4.text, { sid: stop.sid, t: stop.t, text: run4.text, px });
8442
+ }
8443
+ }
8444
+ if (worst.size === 0) return [];
8445
+ const byScene = /* @__PURE__ */ new Map();
8446
+ for (const row of worst.values()) {
8447
+ const list = byScene.get(row.sid) ?? [];
8448
+ list.push(row);
8449
+ byScene.set(row.sid, list);
8450
+ }
8451
+ return [...byScene].map(([sid, rows]) => {
8452
+ rows.sort((a, b) => a.px - b.px);
8453
+ const smallest = rows[0];
8454
+ const named = rows.slice(0, 3).map((r) => `"${r.text}" at ${Math.round(r.px * 10) / 10}px`).join(", ");
8455
+ return {
8456
+ severity: "error",
8457
+ gate: "apparent",
8458
+ rule: "apparent_type_floor",
8459
+ // `#${sid}` as a selector, like every other finding here: `scripts/sweep.mjs`
8460
+ // reads the selector back to decide which beat a finding belongs to, and a
8461
+ // bare id was once filed as a deck-level orphan while the beat reported clean.
8462
+ 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.`
8463
+ };
8464
+ });
8465
+ }
8466
+
8064
8467
  // src/verify/overprint.ts
8065
8468
  var MIN_OVERLAP = 8;
8066
8469
  function collectSvgTextRuns(sid) {
@@ -8344,6 +8747,7 @@ async function fidelity(dir, opts = {}) {
8344
8747
  const { page, height } = deck;
8345
8748
  const measured = [];
8346
8749
  const collided = [];
8750
+ const apparent = [];
8347
8751
  for (const stop of stops) {
8348
8752
  await deck.seek(stop.t);
8349
8753
  const bandTopPx = await page.evaluate(
@@ -8356,6 +8760,11 @@ async function fidelity(dir, opts = {}) {
8356
8760
  ...stop,
8357
8761
  pairs: overprints(await page.evaluate(collectSvgTextRuns, stop.sid))
8358
8762
  });
8763
+ apparent.push({
8764
+ ...stop,
8765
+ settled: true,
8766
+ ...await page.evaluate(collectApparent, stop.sid)
8767
+ });
8359
8768
  const frame = await decodePng(await deck.shoot());
8360
8769
  measured.push({
8361
8770
  ...stop,
@@ -8363,9 +8772,17 @@ async function fidelity(dir, opts = {}) {
8363
8772
  bandTop: Math.round(1e3 * bandTopPx / height) / 1e3
8364
8773
  });
8365
8774
  }
8775
+ for (const mid of midpoints(stops)) {
8776
+ await deck.seek(mid.t);
8777
+ apparent.push({ ...mid, settled: false, ...await page.evaluate(collectApparent, mid.sid) });
8778
+ }
8366
8779
  return {
8367
8780
  stops: measured,
8368
- findings: [...gradeFidelity(measured, floor), ...gradeOverprint(collided)],
8781
+ findings: [
8782
+ ...gradeFidelity(measured, floor),
8783
+ ...gradeOverprint(collided),
8784
+ ...gradeApparent(apparent)
8785
+ ],
8369
8786
  elapsedMs: Date.now() - started
8370
8787
  };
8371
8788
  } catch (err) {
@@ -8376,82 +8793,6 @@ async function fidelity(dir, opts = {}) {
8376
8793
  }
8377
8794
  }
8378
8795
 
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
8796
  // src/verify/drift.ts
8456
8797
  import { execFile as execFile2 } from "node:child_process";
8457
8798
  import { createHash as createHash7 } from "node:crypto";
@@ -10127,6 +10468,7 @@ export {
10127
10468
  edgeProvider,
10128
10469
  emitComposition,
10129
10470
  emitDeck,
10471
+ equationMorphParamsSchema,
10130
10472
  equationSchema,
10131
10473
  equationWalkParamsSchema,
10132
10474
  fetchFigures,