@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/cli.js CHANGED
@@ -110,6 +110,13 @@ var equationWalkParamsSchema = z.object({
110
110
  /** Walked in order, one hold-point each. */
111
111
  terms: z.array(termSchema).min(1).max(4)
112
112
  });
113
+ var equationMorphParamsSchema = z.object({
114
+ eyebrow: z.string().optional(),
115
+ headline: z.string(),
116
+ fromId: z.string(),
117
+ toId: z.string(),
118
+ terms: z.array(termSchema).min(1).max(4)
119
+ });
113
120
  var dataTableParamsSchema = z.object({
114
121
  eyebrow: z.string().optional(),
115
122
  headline: z.string(),
@@ -238,7 +245,22 @@ var stackParamsSchema = z.object({
238
245
  headline: z.string(),
239
246
  /** Drawn bottom-up as offset planes, revealed in order. */
240
247
  layers: z.array(z.object({ label: z.string(), note: z.string().optional() })).min(2).max(7),
241
- note: z.string().optional()
248
+ note: z.string().optional(),
249
+ /**
250
+ * Tilt the slab stack away from the viewer, so the planes read as stacked in
251
+ * depth rather than merely offset up the page.
252
+ *
253
+ * OPTIONAL, and absent means flat — every storyboard written before this
254
+ * existed still parses, and every deck that does not ask for it emits exactly
255
+ * the bytes it did before.
256
+ *
257
+ * Degrees, and bounded at 18 rather than by taste: the tilt is paid for in
258
+ * declared type, because perspective shrinks the far half of the plane and
259
+ * invariant 5 is about what the audience SEES. At 18 degrees a 40px floor
260
+ * already needs 53.8px declared (`src/emit/depth.ts`), and past that a
261
+ * headline cannot spend enough and still fit its own line.
262
+ */
263
+ tilt: z.number().min(0).max(18).optional()
242
264
  });
243
265
  var splitSideSchema = z.object({
244
266
  label: z.string(),
@@ -303,6 +325,12 @@ var beatSchema = z.discriminatedUnion("archetype", [
303
325
  params: equationWalkParamsSchema,
304
326
  ...beatTail
305
327
  }),
328
+ z.object({
329
+ ...beatCore,
330
+ archetype: z.literal("equation-morph"),
331
+ params: equationMorphParamsSchema,
332
+ ...beatTail
333
+ }),
306
334
  z.object({
307
335
  ...beatCore,
308
336
  archetype: z.literal("data-table"),
@@ -357,6 +385,7 @@ var DIAGRAMMATIC = /* @__PURE__ */ new Set([
357
385
  "stack",
358
386
  "split-compare",
359
387
  "equation-walk",
388
+ "equation-morph",
360
389
  "line-chart"
361
390
  ]);
362
391
  var ARCHETYPE_FAMILY = {
@@ -371,7 +400,8 @@ var ARCHETYPE_FAMILY = {
371
400
  "bar-compare": "quantity",
372
401
  "line-chart": "quantity",
373
402
  "data-table": "quantity",
374
- "equation-walk": "formal"
403
+ "equation-walk": "formal",
404
+ "equation-morph": "formal"
375
405
  };
376
406
  var storyboardSchema = z.object({
377
407
  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 size3 = 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:${size3}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 step2 = 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) {
@@ -5220,6 +5410,9 @@ function renderComposition(storyboard, format, laid) {
5220
5410
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5221
5411
  const island = format.navigable ? `
5222
5412
  ${emitIsland(slides)}` : "";
5413
+ const morph = laid.plugins.has("dsMorph") ? `
5414
+ <script src="${MORPH_SRC}"></script>
5415
+ <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5223
5416
  return `<!doctype html>
5224
5417
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5225
5418
  <head>
@@ -5228,7 +5421,7 @@ ${emitIsland(slides)}` : "";
5228
5421
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5229
5422
  <script src="${GSAP_SRC}"></script>
5230
5423
  <script src="${DRAWSVG_SRC}"></script>
5231
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>
5424
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
5232
5425
  <link rel="stylesheet" href="${KATEX_CSS}" />
5233
5426
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5234
5427
  <style>
@@ -5672,6 +5865,7 @@ var REVEALS = {
5672
5865
  title: "1",
5673
5866
  "claim-figure": "2",
5674
5867
  "equation-walk": "one per term",
5868
+ "equation-morph": "2",
5675
5869
  "data-table": "one per highlighted row, plus 1",
5676
5870
  "line-chart": "1",
5677
5871
  callout: "one per panel",
@@ -5703,9 +5897,9 @@ A beat is one idea, one visual, one hold. It carries:
5703
5897
  beat immediately before it. See RULE 11. Leave it off unless the
5704
5898
  source itself puts one inside the other.
5705
5899
 
5706
- THE TWELVE ARCHETYPES
5900
+ THE THIRTEEN ARCHETYPES
5707
5901
 
5708
- Eight of them DRAW: they build a vector graphic out of the source's own content
5902
+ Nine of them DRAW: they build a vector graphic out of the source's own content
5709
5903
  and reveal it stage by stage, so the viewer watches the idea assemble. Four only
5710
5904
  describe. The drawing ones are the default. The describing ones are what you
5711
5905
  fall back to when a point genuinely has no shape.
@@ -5783,6 +5977,14 @@ DRAWING ARCHETYPES \u2014 reach here first
5783
5977
  An equation quoted to back a claim someone else is making is
5784
5978
  evidence under another archetype, not a beat of its own.
5785
5979
 
5980
+ equation-morph One equation becoming the next, the shared terms carried
5981
+ across. The tell: THE SOURCE DERIVES ONE LINE FROM ANOTHER \u2014 a
5982
+ substitution, a rearrangement, a special case \u2014 and the point
5983
+ is what moved. \`fromId\` and \`toId\` name two equations from
5984
+ the inventory. Each terms[].tex must appear verbatim in BOTH,
5985
+ and travels as one piece; a term in only one of them is
5986
+ dropped. Four terms maximum.
5987
+
5786
5988
  line-chart A trend the source states numerically but does not plot. The
5787
5989
  tell: A QUANTITY MOVING ALONG AN ORDERED AXIS \u2014 over length, over
5788
5990
  scale, over training. Points come from the source's numbers;
@@ -6181,7 +6383,8 @@ function renderSource(source) {
6181
6383
  out.push("", "== EQUATIONS ==");
6182
6384
  for (const e of source.equations)
6183
6385
  out.push(`[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${e.tex}`);
6184
- if (!source.equations.length) out.push("(none \u2014 no equation-walk beat is possible)");
6386
+ if (!source.equations.length)
6387
+ out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
6185
6388
  out.push("", "== TABLES ==");
6186
6389
  for (const t2 of source.tables) {
6187
6390
  out.push(`[table ${t2.id}] ${t2.caption ?? "(no caption)"}`);
@@ -6253,6 +6456,10 @@ function assertRefsResolve(storyboard, source, opts = {}) {
6253
6456
  case "equation-walk":
6254
6457
  check3(beat, "equation", beat.params.equationId, "params.equationId");
6255
6458
  break;
6459
+ case "equation-morph":
6460
+ check3(beat, "equation", beat.params.fromId, "params.fromId");
6461
+ check3(beat, "equation", beat.params.toId, "params.toId");
6462
+ break;
6256
6463
  case "data-table": {
6257
6464
  check3(beat, "table", beat.params.tableId, "params.tableId");
6258
6465
  const table = source.tables.find((t2) => t2.id === beat.params.tableId);
@@ -6389,7 +6596,31 @@ function stripNulls(node) {
6389
6596
  }
6390
6597
  return out;
6391
6598
  }
6392
- var SCHEMA = forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }));
6599
+ var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
6600
+ function hideFromPlanner(node) {
6601
+ if (Array.isArray(node)) return node.map(hideFromPlanner);
6602
+ if (node === null || typeof node !== "object") return node;
6603
+ const src = node;
6604
+ const out = {};
6605
+ for (const [key, value] of Object.entries(src)) {
6606
+ if (key === "properties" && value && typeof value === "object") {
6607
+ const kept = {};
6608
+ for (const [prop, sub] of Object.entries(value))
6609
+ if (!PLANNER_INVISIBLE.has(prop)) kept[prop] = hideFromPlanner(sub);
6610
+ out.properties = kept;
6611
+ continue;
6612
+ }
6613
+ if (key === "required" && Array.isArray(value)) {
6614
+ out.required = value.filter((r) => typeof r !== "string" || !PLANNER_INVISIBLE.has(r));
6615
+ continue;
6616
+ }
6617
+ out[key] = hideFromPlanner(value);
6618
+ }
6619
+ return out;
6620
+ }
6621
+ var SCHEMA = hideFromPlanner(
6622
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }))
6623
+ );
6393
6624
  async function codexPlanner(source, opts = {}) {
6394
6625
  const prefs = opts.prefs ?? prefsSchema.parse({});
6395
6626
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
@@ -8932,6 +9163,178 @@ function tail(s) {
8932
9163
  import { readFile as readFile13 } from "node:fs/promises";
8933
9164
  import { join as join12 } from "node:path";
8934
9165
 
9166
+ // src/verify/typefloor.ts
9167
+ var TYPE_FLOOR_PX = 40;
9168
+ var CSS_PX = /font-size\s*:\s*([0-9.]+)px/g;
9169
+ var SVG_ATTR = /\bfont-size="([0-9.]+)"/g;
9170
+ var RELATIVE = /font-size\s*:\s*[0-9.]+(em|rem|%|vw|vh|ch|ex|pt)\b/g;
9171
+ function scanTypeFloor(html, file, floorPx = TYPE_FLOOR_PX) {
9172
+ const findings = [];
9173
+ const zones = svgZones(html);
9174
+ const small = [];
9175
+ for (const pattern of [CSS_PX, SVG_ATTR]) {
9176
+ pattern.lastIndex = 0;
9177
+ for (const m of html.matchAll(pattern)) {
9178
+ const px = Number(m[1]) * userUnit(zones, m.index);
9179
+ if (px < floorPx) small.push({ px, where: where(html, m.index) });
9180
+ }
9181
+ }
9182
+ if (small.length > 0) {
9183
+ const worst = [...small].sort((a, b) => a.px - b.px);
9184
+ const named = worst.slice(0, 6).map((s) => `${round6(s.px)}px on ${s.where}`).join(", ");
9185
+ findings.push({
9186
+ severity: "error",
9187
+ gate: "typography",
9188
+ rule: "type_below_floor",
9189
+ 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.`
9190
+ });
9191
+ }
9192
+ const units = [...new Set([...html.matchAll(RELATIVE)].map((m) => m[1]))];
9193
+ if (units.length > 0) {
9194
+ findings.push({
9195
+ severity: "warning",
9196
+ gate: "typography",
9197
+ rule: "type_unmeasurable",
9198
+ 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.`
9199
+ });
9200
+ }
9201
+ return findings;
9202
+ }
9203
+ function svgZones(html) {
9204
+ const zones = [];
9205
+ for (const m of html.matchAll(/<svg\b([^>]*)>/g)) {
9206
+ const width = Number(/\bwidth="([0-9.]+)"/.exec(m[1])?.[1]);
9207
+ const box = Number(/\bviewBox="[0-9.-]+\s+[0-9.-]+\s+([0-9.]+)/.exec(m[1])?.[1]);
9208
+ const close = html.indexOf("</svg>", m.index);
9209
+ zones.push({
9210
+ start: m.index,
9211
+ end: close < 0 ? html.length : close,
9212
+ unit: width > 0 && box > 0 ? width / box : 1
9213
+ });
9214
+ }
9215
+ return zones;
9216
+ }
9217
+ function userUnit(zones, at) {
9218
+ return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
9219
+ }
9220
+ function where(html, at) {
9221
+ const open = html.lastIndexOf("<", at);
9222
+ const closed = html.lastIndexOf(">", at);
9223
+ if (open > closed) {
9224
+ const end = html.indexOf(">", at);
9225
+ const tag = html.slice(open, end < 0 ? void 0 : end + 1);
9226
+ const id2 = /\bid="([^"]+)"/.exec(tag)?.[1];
9227
+ if (id2) return `#${id2}`;
9228
+ const name = /^<([a-zA-Z][\w-]*)/.exec(tag)?.[1] ?? "an element";
9229
+ const cls = /\bclass="([^"]+)"/.exec(tag)?.[1]?.split(/\s+/)[0];
9230
+ return cls ? `${name}.${cls}` : name;
9231
+ }
9232
+ const brace = html.lastIndexOf("{", at);
9233
+ if (brace < 0) return "the stylesheet";
9234
+ const from = Math.max(html.lastIndexOf("}", brace), html.lastIndexOf(">", brace));
9235
+ const selector = html.slice(from + 1, brace).trim().split("\n").pop()?.trim();
9236
+ return selector ? selector : "the stylesheet";
9237
+ }
9238
+ function round6(px) {
9239
+ return Math.round(px * 100) / 100;
9240
+ }
9241
+
9242
+ // src/verify/apparent.ts
9243
+ function collectApparent(sid) {
9244
+ const scene = document.querySelector(`[data-composition-id="${CSS.escape(sid)}"]`);
9245
+ if (!scene) return { runs: [], stage: 1 };
9246
+ const declaredW = Number(scene.dataset.width ?? 0);
9247
+ const sceneRect = scene.getBoundingClientRect();
9248
+ const stage = declaredW > 0 && sceneRect.width > 0 ? sceneRect.width / declaredW : 1;
9249
+ const runs = [];
9250
+ const walk = (el, inSvg) => {
9251
+ const style = getComputedStyle(el);
9252
+ if (style.display === "none" || style.visibility === "hidden") return;
9253
+ if (Number(style.opacity) === 0) return;
9254
+ const within = inSvg || el.tagName.toLowerCase() === "svg";
9255
+ if (within && typeof el.getBBox === "function") {
9256
+ let hasText = false;
9257
+ for (const node of Array.from(el.childNodes))
9258
+ if (node.nodeType === 3 && node.textContent?.trim()) hasText = true;
9259
+ if (hasText) {
9260
+ let box = null;
9261
+ try {
9262
+ box = el.getBBox();
9263
+ } catch {
9264
+ box = null;
9265
+ }
9266
+ const painted = el.getBoundingClientRect().height;
9267
+ if (box && box.height > 0 && painted > 0) {
9268
+ let opacity = 1;
9269
+ for (let node = el; node; node = node.parentElement) {
9270
+ const own = Number(getComputedStyle(node).opacity);
9271
+ if (Number.isFinite(own)) opacity *= own;
9272
+ if (node === scene) break;
9273
+ }
9274
+ runs.push({
9275
+ text: (el.textContent ?? "").trim().slice(0, 40),
9276
+ declared: Number.parseFloat(getComputedStyle(el).fontSize) || 0,
9277
+ ratio: painted / box.height,
9278
+ opacity
9279
+ });
9280
+ }
9281
+ }
9282
+ }
9283
+ for (const child of Array.from(el.children)) walk(child, within);
9284
+ };
9285
+ walk(scene, false);
9286
+ return { runs, stage };
9287
+ }
9288
+ function apparentPx(run4, stage) {
9289
+ if (stage <= 0) return run4.declared;
9290
+ return run4.declared * run4.ratio / stage;
9291
+ }
9292
+ var SETTLED_OPACITY = 0.95;
9293
+ function midpoints(stops) {
9294
+ const out = [];
9295
+ for (let i = 1; i < stops.length; i++) {
9296
+ const a = stops[i - 1];
9297
+ const b = stops[i];
9298
+ if (a.sid !== b.sid || b.t <= a.t) continue;
9299
+ out.push({ sid: b.sid, t: Math.round((a.t + b.t) / 2 * 1e3) / 1e3 });
9300
+ }
9301
+ return out;
9302
+ }
9303
+ function gradeApparent(stops, floor = TYPE_FLOOR_PX) {
9304
+ const worst = /* @__PURE__ */ new Map();
9305
+ for (const stop of stops) {
9306
+ for (const run4 of stop.runs) {
9307
+ if (!stop.settled && run4.opacity < SETTLED_OPACITY) continue;
9308
+ const px = apparentPx(run4, stop.stage);
9309
+ if (px >= floor - 0.1) continue;
9310
+ const seen = worst.get(run4.text);
9311
+ if (!seen || px < seen.px)
9312
+ worst.set(run4.text, { sid: stop.sid, t: stop.t, text: run4.text, px });
9313
+ }
9314
+ }
9315
+ if (worst.size === 0) return [];
9316
+ const byScene = /* @__PURE__ */ new Map();
9317
+ for (const row of worst.values()) {
9318
+ const list = byScene.get(row.sid) ?? [];
9319
+ list.push(row);
9320
+ byScene.set(row.sid, list);
9321
+ }
9322
+ return [...byScene].map(([sid, rows]) => {
9323
+ rows.sort((a, b) => a.px - b.px);
9324
+ const smallest = rows[0];
9325
+ const named = rows.slice(0, 3).map((r) => `"${r.text}" at ${Math.round(r.px * 10) / 10}px`).join(", ");
9326
+ return {
9327
+ severity: "error",
9328
+ gate: "apparent",
9329
+ rule: "apparent_type_floor",
9330
+ // `#${sid}` as a selector, like every other finding here: `scripts/sweep.mjs`
9331
+ // reads the selector back to decide which beat a finding belongs to, and a
9332
+ // bare id was once filed as a deck-level orphan while the beat reported clean.
9333
+ 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.`
9334
+ };
9335
+ });
9336
+ }
9337
+
8935
9338
  // src/verify/overprint.ts
8936
9339
  var MIN_OVERLAP = 8;
8937
9340
  function collectSvgTextRuns(sid) {
@@ -9215,6 +9618,7 @@ async function fidelity(dir, opts = {}) {
9215
9618
  const { page, height } = deck;
9216
9619
  const measured = [];
9217
9620
  const collided = [];
9621
+ const apparent = [];
9218
9622
  for (const stop of stops) {
9219
9623
  await deck.seek(stop.t);
9220
9624
  const bandTopPx = await page.evaluate(
@@ -9227,6 +9631,11 @@ async function fidelity(dir, opts = {}) {
9227
9631
  ...stop,
9228
9632
  pairs: overprints(await page.evaluate(collectSvgTextRuns, stop.sid))
9229
9633
  });
9634
+ apparent.push({
9635
+ ...stop,
9636
+ settled: true,
9637
+ ...await page.evaluate(collectApparent, stop.sid)
9638
+ });
9230
9639
  const frame = await decodePng(await deck.shoot());
9231
9640
  measured.push({
9232
9641
  ...stop,
@@ -9234,9 +9643,17 @@ async function fidelity(dir, opts = {}) {
9234
9643
  bandTop: Math.round(1e3 * bandTopPx / height) / 1e3
9235
9644
  });
9236
9645
  }
9646
+ for (const mid of midpoints(stops)) {
9647
+ await deck.seek(mid.t);
9648
+ apparent.push({ ...mid, settled: false, ...await page.evaluate(collectApparent, mid.sid) });
9649
+ }
9237
9650
  return {
9238
9651
  stops: measured,
9239
- findings: [...gradeFidelity(measured, floor), ...gradeOverprint(collided)],
9652
+ findings: [
9653
+ ...gradeFidelity(measured, floor),
9654
+ ...gradeOverprint(collided),
9655
+ ...gradeApparent(apparent)
9656
+ ],
9240
9657
  elapsedMs: Date.now() - started
9241
9658
  };
9242
9659
  } catch (err) {
@@ -9247,82 +9664,6 @@ async function fidelity(dir, opts = {}) {
9247
9664
  }
9248
9665
  }
9249
9666
 
9250
- // src/verify/typefloor.ts
9251
- var TYPE_FLOOR_PX = 40;
9252
- var CSS_PX = /font-size\s*:\s*([0-9.]+)px/g;
9253
- var SVG_ATTR = /\bfont-size="([0-9.]+)"/g;
9254
- var RELATIVE = /font-size\s*:\s*[0-9.]+(em|rem|%|vw|vh|ch|ex|pt)\b/g;
9255
- function scanTypeFloor(html, file, floorPx = TYPE_FLOOR_PX) {
9256
- const findings = [];
9257
- const zones = svgZones(html);
9258
- const small = [];
9259
- for (const pattern of [CSS_PX, SVG_ATTR]) {
9260
- pattern.lastIndex = 0;
9261
- for (const m of html.matchAll(pattern)) {
9262
- const px = Number(m[1]) * userUnit(zones, m.index);
9263
- if (px < floorPx) small.push({ px, where: where(html, m.index) });
9264
- }
9265
- }
9266
- if (small.length > 0) {
9267
- const worst = [...small].sort((a, b) => a.px - b.px);
9268
- const named = worst.slice(0, 6).map((s) => `${round6(s.px)}px on ${s.where}`).join(", ");
9269
- findings.push({
9270
- severity: "error",
9271
- gate: "typography",
9272
- rule: "type_below_floor",
9273
- message: `${file} declares ${small.length} text size(s) under the ${floorPx}px floor: ${named}${worst.length > 6 ? ", \u2026" : ""}. Sizes are in reference space, so this is the floor as authored and not an artefact of the format's zoom. Raise the size, or give the archetype less to say \u2014 a beat that only fits below the floor is a beat that has to be cut in two.`
9274
- });
9275
- }
9276
- const units = [...new Set([...html.matchAll(RELATIVE)].map((m) => m[1]))];
9277
- if (units.length > 0) {
9278
- findings.push({
9279
- severity: "warning",
9280
- gate: "typography",
9281
- rule: "type_unmeasurable",
9282
- message: `${file} sizes some text in ${units.join("/")}, which resolves against an inherited size this scan cannot follow, so the ${floorPx}px floor was not checked there. Declare it in px, or measure it in a browser.`
9283
- });
9284
- }
9285
- return findings;
9286
- }
9287
- function svgZones(html) {
9288
- const zones = [];
9289
- for (const m of html.matchAll(/<svg\b([^>]*)>/g)) {
9290
- const width = Number(/\bwidth="([0-9.]+)"/.exec(m[1])?.[1]);
9291
- const box = Number(/\bviewBox="[0-9.-]+\s+[0-9.-]+\s+([0-9.]+)/.exec(m[1])?.[1]);
9292
- const close = html.indexOf("</svg>", m.index);
9293
- zones.push({
9294
- start: m.index,
9295
- end: close < 0 ? html.length : close,
9296
- unit: width > 0 && box > 0 ? width / box : 1
9297
- });
9298
- }
9299
- return zones;
9300
- }
9301
- function userUnit(zones, at) {
9302
- return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
9303
- }
9304
- function where(html, at) {
9305
- const open = html.lastIndexOf("<", at);
9306
- const closed = html.lastIndexOf(">", at);
9307
- if (open > closed) {
9308
- const end = html.indexOf(">", at);
9309
- const tag = html.slice(open, end < 0 ? void 0 : end + 1);
9310
- const id2 = /\bid="([^"]+)"/.exec(tag)?.[1];
9311
- if (id2) return `#${id2}`;
9312
- const name = /^<([a-zA-Z][\w-]*)/.exec(tag)?.[1] ?? "an element";
9313
- const cls = /\bclass="([^"]+)"/.exec(tag)?.[1]?.split(/\s+/)[0];
9314
- return cls ? `${name}.${cls}` : name;
9315
- }
9316
- const brace = html.lastIndexOf("{", at);
9317
- if (brace < 0) return "the stylesheet";
9318
- const from = Math.max(html.lastIndexOf("}", brace), html.lastIndexOf(">", brace));
9319
- const selector = html.slice(from + 1, brace).trim().split("\n").pop()?.trim();
9320
- return selector ? selector : "the stylesheet";
9321
- }
9322
- function round6(px) {
9323
- return Math.round(px * 100) / 100;
9324
- }
9325
-
9326
9667
  // src/verify/drift.ts
9327
9668
  import { execFile as execFile3 } from "node:child_process";
9328
9669
  import { createHash as createHash8 } from "node:crypto";
@@ -10023,6 +10364,10 @@ async function vendorScripts(out) {
10023
10364
  const from = join15(dirname4(require2.resolve(pkg)), rel);
10024
10365
  await cp(from, join15(out, "vendor", name));
10025
10366
  }
10367
+ await cp(
10368
+ fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
10369
+ join15(out, "vendor", "ds-morph.js")
10370
+ );
10026
10371
  }
10027
10372
  var HYPERFRAMES_JSON = `${JSON.stringify(
10028
10373
  {