@jokerized/decksmith 0.3.2 → 0.4.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
@@ -1,7 +1,7 @@
1
1
  // src/index.ts
2
2
  import { cp, mkdir as mkdir10, readdir as readdir4, readFile as readFile17, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
3
- import { createRequire as createRequire3 } from "node:module";
4
- import { dirname as dirname4, join as join16, resolve as resolve5 } from "node:path";
3
+ import { createRequire as createRequire4 } from "node:module";
4
+ import { dirname as dirname5, join as join17, resolve as resolve6 } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  // src/pack/media.ts
@@ -414,15 +414,53 @@ var dataTableParamsSchema = z.object({
414
414
  path: ["highlight"],
415
415
  message: "every highlight must name a row that params.rows draws"
416
416
  });
417
+ var chartPointSchema = z.object({ x: z.string(), y: z.number() });
417
418
  var lineChartParamsSchema = z.object({
418
419
  eyebrow: z.string().optional(),
419
420
  headline: z.string(),
420
421
  xLabel: z.string(),
421
422
  yLabel: z.string(),
422
- points: z.array(z.object({ x: z.string(), y: z.number() })).min(2),
423
+ points: z.array(chartPointSchema).min(2),
423
424
  /** Inter-point annotations, e.g. per-step deltas. One fewer than `points`. */
424
425
  deltas: z.array(z.string()).optional(),
425
- readout: z.string().optional()
426
+ readout: z.string().optional(),
427
+ /**
428
+ * THE SAME MEASUREMENT UNDER A SECOND CONDITION — a baseline the main series
429
+ * is to be read against, where the point is the change in the SHAPE of the
430
+ * curve rather than two numbers.
431
+ *
432
+ * Drawn first and alone; then the curve lifts off it and reshapes into
433
+ * `points`, leaving this one behind as a ghost. `label` names the ghost and
434
+ * is drawn wherever the chart has room for it.
435
+ *
436
+ * KEEP IT SHORT — two or three words. A label wider than the plot is refused
437
+ * outright, and that refusal reaches `onBeatError` and costs the whole beat.
438
+ * One that fits but cannot be placed clear of the axis names, the tick and
439
+ * category labels, the values, the deltas and both curves is DROPPED
440
+ * instead, silently: an unnamed ghost is still legibly the fainter, earlier
441
+ * curve, where a name printed through a number is a defect in both of them.
442
+ * Nothing warns about that one — see the placement note in
443
+ * `src/emit/archetypes/line-chart.ts`.
444
+ */
445
+ compare: z.object({ label: z.string(), points: z.array(chartPointSchema).min(2) }).optional()
446
+ }).superRefine((p, ctx) => {
447
+ if (!p.compare) return;
448
+ if (p.compare.points.length !== p.points.length) {
449
+ ctx.addIssue({
450
+ code: z.ZodIssueCode.custom,
451
+ path: ["compare", "points"],
452
+ message: `compare has ${p.compare.points.length} points against ${p.points.length}; a comparison is over the same x values`
453
+ });
454
+ return;
455
+ }
456
+ const off = p.compare.points.findIndex((c, i) => c.x !== p.points[i]?.x);
457
+ if (off >= 0) {
458
+ ctx.addIssue({
459
+ code: z.ZodIssueCode.custom,
460
+ path: ["compare", "points", off, "x"],
461
+ message: `compare point ${off} is "${p.compare.points[off]?.x}" where points has "${p.points[off]?.x}"`
462
+ });
463
+ }
426
464
  });
427
465
  var calloutParamsSchema = z.object({
428
466
  eyebrow: z.string().optional(),
@@ -1205,8 +1243,8 @@ async function bundleFont(lang, glyphs, dir) {
1205
1243
  ${text2}`).digest("hex").slice(0, 16)} */`;
1206
1244
  await mkdir(dir, { recursive: true });
1207
1245
  const cssPath = join(dir, "fonts.css");
1208
- const cached = await readFile2(cssPath, "utf8").catch(() => "");
1209
- if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
1246
+ const cached2 = await readFile2(cssPath, "utf8").catch(() => "");
1247
+ if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1210
1248
  const res = await fetch(
1211
1249
  `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
1212
1250
  { headers: { "User-Agent": UA } }
@@ -1419,6 +1457,21 @@ function nv(v) {
1419
1457
  }
1420
1458
  var DRAW_FROM = { drawSVG: "0%" };
1421
1459
  var DRAW_TO = { drawSVG: "100%" };
1460
+ var SHAPE_INDEX = 0;
1461
+ function reshape(target, to, at, seconds, first) {
1462
+ if (!(seconds > 0)) throw new Error(`reshape ${target}: ${seconds}s is no time to reshape in`);
1463
+ return fromTo(
1464
+ target,
1465
+ { morphSVG: { shape: target, shapeIndex: SHAPE_INDEX } },
1466
+ {
1467
+ morphSVG: { shape: to, shapeIndex: SHAPE_INDEX },
1468
+ duration: sec(seconds),
1469
+ ease: "power2.inOut",
1470
+ ...first ? {} : { immediateRender: false }
1471
+ },
1472
+ sec(at)
1473
+ );
1474
+ }
1422
1475
  function travel(target, route, at, seconds) {
1423
1476
  if (!(seconds > 0)) throw new Error(`travel ${target}: ${seconds}s is no time to travel in`);
1424
1477
  const legs = [];
@@ -4100,6 +4153,12 @@ var CHART_GAP = 60;
4100
4153
  var READOUT_W = 460;
4101
4154
  var READOUT_LH = 1.35;
4102
4155
  var TALL_ASPECT = 1.15;
4156
+ var RESHAPE_SECONDS = 1.2;
4157
+ var GHOST_OPACITY = 0.28;
4158
+ var DRAW_SECONDS = 1.8;
4159
+ var DRAW_FLOOR = 1;
4160
+ var SEPARATE = 0.5;
4161
+ var STEP_FLOOR = 0.15;
4103
4162
  function chartScale(values) {
4104
4163
  const lo = Math.min(...values);
4105
4164
  const hi = Math.max(...values);
@@ -4130,7 +4189,8 @@ var lineChart = (beat, ctx) => {
4130
4189
  const { sid, theme } = ctx;
4131
4190
  const p = beat.params;
4132
4191
  const face = faceOf(theme.fontStack);
4133
- const scale = chartScale(p.points.map((pt) => pt.y));
4192
+ const cmp = p.compare;
4193
+ const scale = chartScale([...p.points, ...cmp?.points ?? []].map((pt) => pt.y));
4134
4194
  const box = contentW(ctx.format);
4135
4195
  const tall = isPortrait(ctx.format);
4136
4196
  const width = tall ? box : box - (p.readout ? CHART_GAP + READOUT_W : 0);
@@ -4173,11 +4233,15 @@ var lineChart = (beat, ctx) => {
4173
4233
  const LABEL_SIZE3 = 40;
4174
4234
  const runW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
4175
4235
  const catW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
4236
+ const nameW = (s) => textWidth(s, LABEL_SIZE3, 500, 0, false, face);
4237
+ const ghostW = (s) => textWidth(s, LABEL_SIZE3, 600, 0, false, face);
4176
4238
  const shownX = fitIndices((i) => catW(p.points[i]?.x ?? ""), false);
4177
4239
  const xLabels = p.points.map(
4178
4240
  (pt, i) => shownX.has(i) ? `<text x="${n2(x(i))}" y="${H - PAD2.b + 56}">${esc(pt.x)}</text>` : ""
4179
4241
  ).join("");
4180
- const path2 = p.points.map((pt, i) => `${i === 0 ? "M" : "L"}${n2(x(i))},${n2(y(pt.y))}`).join(" ");
4242
+ const pathOf = (pts) => pts.map((pt, i) => `${i === 0 ? "M" : "L"}${n2(x(i))},${n2(y(pt.y))}`).join(" ");
4243
+ const path2 = pathOf(p.points);
4244
+ const basePath = cmp ? pathOf(cmp.points) : "";
4181
4245
  const dots = p.points.map(
4182
4246
  (pt, i) => `<circle class="dot" cx="${n2(x(i))}" cy="${n2(y(pt.y))}" r="${i === p.points.length - 1 ? 11 : 9}" fill="${i === p.points.length - 1 ? theme.tones.b : theme.accent}" />`
4183
4247
  ).join("");
@@ -4213,6 +4277,75 @@ var lineChart = (beat, ctx) => {
4213
4277
  }).join("");
4214
4278
  const first = { x: x(0), y: y(p.points[0]?.y ?? 0) };
4215
4279
  const ring = p.points.length > 1 ? `<circle id="${sid}-ring" cx="${n2(first.x)}" cy="${n2(first.y)}" r="20" fill="none" stroke="${theme.tones.b}" stroke-width="5" opacity="0" />` : "";
4280
+ const lineTag = (part, d, extra = "") => `<path class="chartline" id="${sid}-${part}" d="${d}" fill="none" stroke="${theme.accent}"${extra} />`;
4281
+ const chartlines = cmp ? [
4282
+ lineTag("base", basePath),
4283
+ lineTag("line", basePath, ' opacity="0"'),
4284
+ `<path id="${sid}-target" d="${path2}" fill="none" stroke="none" />`
4285
+ ].join("\n ") : lineTag("line", path2);
4286
+ const curveBand = (pts, x0, x1) => {
4287
+ const ys = [];
4288
+ const py = (i) => y(pts[i]?.y ?? 0);
4289
+ for (let i = 0; i < pts.length; i++) {
4290
+ if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
4291
+ }
4292
+ for (let i = 0; i + 1 < pts.length; i++) {
4293
+ const [ax, bx] = [x(i), x(i + 1)];
4294
+ for (const edge of [x0, x1]) {
4295
+ if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
4296
+ ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
4297
+ }
4298
+ }
4299
+ }
4300
+ return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
4301
+ };
4302
+ const fixedBoxes = [
4303
+ // `.axname` is the one run here set at weight 500 rather than 400, and
4304
+ // under-charging a width is the unrecoverable direction (see `padR`).
4305
+ // No `text-anchor` on the y half, so it runs rightwards from x=0.
4306
+ { x: 0, y: 42 - LABEL_SIZE3, w: nameW(p.yLabel), h: LABEL_SIZE3 },
4307
+ {
4308
+ x: PAD2.l + plotW / 2 - nameW(p.xLabel) / 2,
4309
+ y: H - 16 - LABEL_SIZE3,
4310
+ w: nameW(p.xLabel),
4311
+ h: LABEL_SIZE3
4312
+ },
4313
+ ...ticks.map((v) => {
4314
+ const w = runW(v.toFixed(scale.decimals));
4315
+ return { x: PAD2.l - 22 - w, y: y(v) + 13 - LABEL_SIZE3, w, h: LABEL_SIZE3 };
4316
+ }),
4317
+ ...p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => shownX.has(i)).map(({ pt, i }) => labelBox(x(i), H - PAD2.b + 56, pt.x))
4318
+ ];
4319
+ const lastI = p.points.length - 1;
4320
+ const ghost = (() => {
4321
+ if (!cmp) return void 0;
4322
+ const w = ghostW(cmp.label);
4323
+ if (w > plotW) {
4324
+ throw new Error(
4325
+ `line-chart ${beat.id}: the compare label "${cmp.label}" is ${Math.ceil(w)}px against the ${Math.floor(plotW)}px of plot it is set in. Shorten it.`
4326
+ );
4327
+ }
4328
+ const atEnd = (i, start) => {
4329
+ const cy = y(cmp.points[i]?.y ?? 0);
4330
+ const away = Math.max(44, cy - 26);
4331
+ const toward = Math.min(cy + 52, H - PAD2.b + 4);
4332
+ const sides = cy < y(p.points[i]?.y ?? 0) ? [away, toward] : [toward, away];
4333
+ return sides.map((by) => ({ i, start, by }));
4334
+ };
4335
+ const drawnDeltas = deltasFit ? deltaBoxes : [];
4336
+ return [...atEnd(lastI, false), ...atEnd(0, true)].find((c) => {
4337
+ const box2 = { x: c.start ? x(c.i) : x(c.i) - w, y: c.by - LABEL_SIZE3, w, h: LABEL_SIZE3 };
4338
+ if (fixedBoxes.some((f) => overlaps(box2, f))) return false;
4339
+ if (valueBoxes.some((v) => overlaps(box2, v))) return false;
4340
+ if (drawnDeltas.some((d) => d !== null && overlaps(box2, d))) return false;
4341
+ return ![p.points, cmp.points].some((series) => {
4342
+ const [top, bottom] = curveBand(series, box2.x, box2.x + box2.w);
4343
+ return Math.min(box2.y + box2.h, bottom) - Math.max(box2.y, top) > 8;
4344
+ });
4345
+ });
4346
+ })();
4347
+ const ghostLabel = cmp && ghost ? `
4348
+ <text class="ghostlab" id="${sid}-ghostlab" text-anchor="${ghost.start ? "start" : "end"}" x="${n2(x(ghost.i))}" y="${n2(ghost.by)}">${esc(cmp.label)}</text>` : "";
4216
4349
  const readout = p.readout ? `
4217
4350
  <div class="readout" id="${sid}-read">${esc(p.readout)}</div>` : "";
4218
4351
  const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
@@ -4225,7 +4358,7 @@ var lineChart = (beat, ctx) => {
4225
4358
  the svg and the layout gate reports container_overflow. -->
4226
4359
  <text class="axname" x="0" y="42">${esc(p.yLabel)}</text>
4227
4360
  <text class="axname" x="${n2(PAD2.l + plotW / 2)}" y="${H - 16}" text-anchor="middle">${esc(p.xLabel)}</text>
4228
- <path class="chartline" id="${sid}-line" d="${path2}" fill="none" stroke="${theme.accent}" />
4361
+ ${chartlines}${ghostLabel}
4229
4362
  <g>${dots}</g>
4230
4363
  ${ring}
4231
4364
  <g class="ptlab" text-anchor="middle">${values}</g>
@@ -4233,10 +4366,52 @@ var lineChart = (beat, ctx) => {
4233
4366
  </svg>${readout}
4234
4367
  </div>`;
4235
4368
  const draw2 = 0.8;
4236
- const step = Math.min(0.45, 1.8 / p.points.length);
4369
+ const count = p.points.length;
4370
+ const idealStep = Math.min(0.45, DRAW_SECONDS / count);
4371
+ const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
4372
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
4373
+ const tailAfter = (s) => Math.max(
4374
+ s * count + 0.4,
4375
+ // the ring: its walk, then the 0.4s it takes to leave
4376
+ 0.3 + s * (count - 1),
4377
+ // the dots
4378
+ 0.5 + s * (count - 1),
4379
+ ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
4380
+ );
4381
+ const room = beat.seconds - spine;
4382
+ let drawCents = Math.round(DRAW_SECONDS * 100);
4383
+ let stepCents = Math.floor(idealStep * 100);
4384
+ if (cmp) {
4385
+ while (drawCents / 100 + tailAfter(stepCents / 100) > room) {
4386
+ if (drawCents > Math.round(DRAW_FLOOR * 100)) drawCents--;
4387
+ else if (stepCents > Math.round(STEP_FLOOR * 100)) stepCents--;
4388
+ else break;
4389
+ }
4390
+ }
4391
+ const drawFor = cmp ? drawCents / 100 : DRAW_SECONDS;
4392
+ const step = cmp ? stepCents / 100 : idealStep;
4393
+ const lift2 = sec(draw2 + drawFor);
4394
+ const settled = cmp ? sec(lift2 + SEPARATE + RESHAPE_SECONDS) : draw2;
4237
4395
  const tl = [
4238
4396
  ...chromeIn(sid, p.eyebrow !== void 0),
4239
- tween(`#${sid}-line`, DRAW_FROM, { ...DRAW_TO, duration: 1.8, ease: "none" }, draw2),
4397
+ tween(
4398
+ `#${sid}-${cmp ? "base" : "line"}`,
4399
+ DRAW_FROM,
4400
+ { ...DRAW_TO, duration: drawFor, ease: "none" },
4401
+ draw2
4402
+ )
4403
+ ];
4404
+ if (cmp) {
4405
+ tl.push(
4406
+ ...ghost ? [tween(`#${sid}-ghostlab`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, draw2 + 0.4)] : [],
4407
+ // The copy lifts off — same geometry, so the half-second reads as one line
4408
+ // separating from itself rather than as a second line arriving.
4409
+ tween(`#${sid}-line`, { opacity: 0 }, { opacity: 1, duration: SEPARATE }, lift2),
4410
+ tween(`#${sid}-base`, { opacity: 1 }, { opacity: GHOST_OPACITY, duration: SEPARATE }, lift2),
4411
+ reshape(`#${sid}-line`, `#${sid}-target`, lift2 + SEPARATE, RESHAPE_SECONDS, true)
4412
+ );
4413
+ }
4414
+ tl.push(
4240
4415
  tween(
4241
4416
  `#${sid} .dot`,
4242
4417
  // Origin in both halves, or GSAP's smoothOrigin compensates the change with
@@ -4244,37 +4419,43 @@ var lineChart = (beat, ctx) => {
4244
4419
  // polyline they mark, inside the frame and so invisible to every gate.
4245
4420
  { opacity: 0, scale: 0, transformOrigin: "center" },
4246
4421
  { opacity: 1, scale: 1, transformOrigin: "center", duration: 0.3, stagger: step },
4247
- draw2
4422
+ settled
4248
4423
  ),
4249
- tween(`#${sid} .pv`, { opacity: 0 }, { opacity: 1, duration: 0.3, stagger: step }, draw2 + 0.2)
4250
- ];
4424
+ tween(
4425
+ `#${sid} .pv`,
4426
+ { opacity: 0 },
4427
+ { opacity: 1, duration: 0.3, stagger: step },
4428
+ settled + 0.2
4429
+ )
4430
+ );
4251
4431
  if (deltas) {
4252
4432
  tl.push(
4253
4433
  tween(
4254
4434
  `#${sid} .dv`,
4255
4435
  { opacity: 0, y: -10 },
4256
4436
  { opacity: 1, y: 0, duration: 0.35, stagger: step },
4257
- draw2 + 0.6
4437
+ settled + 0.6
4258
4438
  )
4259
4439
  );
4260
4440
  }
4261
- if (p.points.length > 1) {
4441
+ const walk = cmp ? sec(step * count) : DRAW_SECONDS;
4442
+ if (count > 1) {
4262
4443
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
4263
4444
  tl.push(
4264
- tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, draw2),
4265
- ...travel(`#${sid}-ring`, route, draw2, 1.8),
4445
+ tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
4446
+ ...travel(`#${sid}-ring`, route, settled, walk),
4266
4447
  // And it leaves once the curve is whole: a marker parked on the last
4267
4448
  // point for the rest of the beat reads as a defect, not as emphasis.
4268
4449
  tween(
4269
4450
  `#${sid}-ring`,
4270
4451
  { opacity: 1 },
4271
4452
  { opacity: 0, duration: 0.4, immediateRender: false },
4272
- draw2 + 1.8
4453
+ settled + walk
4273
4454
  )
4274
4455
  );
4275
4456
  }
4276
- const drawn = draw2 + step * p.points.length + 0.4;
4277
- const holds = [drawn];
4457
+ const drawn = settled + (cmp ? tailAfter(step) : step * count + 0.4);
4458
+ const holds = cmp ? [lift2, drawn] : [drawn];
4278
4459
  if (p.readout) {
4279
4460
  tl.push(
4280
4461
  // It enters from wherever it sits: from the right when it is beside the
@@ -4283,9 +4464,22 @@ var lineChart = (beat, ctx) => {
4283
4464
  );
4284
4465
  holds.push(drawn + 0.8);
4285
4466
  }
4467
+ const end = holds[holds.length - 1] ?? 0;
4468
+ const need = sec(end + 0.15);
4469
+ if (cmp && need > beat.seconds + 1e-9) {
4470
+ return {
4471
+ ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
4472
+ warnings: [
4473
+ `line-chart ${beat.id}: a comparison against "${cmp.label}" over ${count} points needs ${need}s and the beat is ${beat.seconds}s, so the chart was drawn without it. Lengthen the beat to keep the comparison.`
4474
+ ]
4475
+ };
4476
+ }
4286
4477
  return {
4287
4478
  html,
4288
4479
  tl,
4480
+ // Named only when a beat actually reshapes, which is what keeps MorphSVG's
4481
+ // 21,195 bytes off every deck that does not. See `PLUGINS` in composition.ts.
4482
+ ...cmp ? { plugins: ["morphSVG"] } : {},
4289
4483
  holds: holdsWithin(holds, beat.seconds),
4290
4484
  css: [
4291
4485
  chromeCss(theme),
@@ -4310,6 +4504,14 @@ var lineChart = (beat, ctx) => {
4310
4504
  `.axname{font-size:40px;fill:${theme.muted};font-weight:500}`,
4311
4505
  `.ptlab{font-size:40px;fill:${theme.fg};font-weight:600}`,
4312
4506
  `.delta{font-size:40px;fill:${theme.tones.b};font-weight:600}`,
4507
+ // Only when a ghost was actually named — not merely when there is a ghost,
4508
+ // since the label is dropped where neither side of the baseline is clear.
4509
+ // An unconditional rule would move the stylesheet bytes of every line chart
4510
+ // ever built for a part they do not draw, which is the whole thing
4511
+ // `Scene.plugins` is careful about one level up. `muted`, not `dim`: it
4512
+ // names a series, so it is read, and the curve it names is the thing that
4513
+ // has been faded, not its label.
4514
+ ...ghost ? [`.ghostlab{font-size:40px;fill:${theme.muted};font-weight:600}`] : [],
4313
4515
  // 1.7 set the two lines of a wrapped readout 68px apart, which reads as two
4314
4516
  // unrelated fragments rather than one sentence. 1.35 keeps it a paragraph.
4315
4517
  `.readout{font-size:${BODY_SIZE}px;line-height:${READOUT_LH};color:${theme.muted};max-width:${READOUT_W}px}`,
@@ -5555,7 +5757,10 @@ function round4(n3) {
5555
5757
  // src/emit/composition.ts
5556
5758
  var GSAP_SRC = "./vendor/gsap.min.js";
5557
5759
  var DRAWSVG_SRC = "./vendor/DrawSVGPlugin.min.js";
5558
- var MORPH_SRC = "./vendor/ds-morph.js";
5760
+ var PLUGINS = {
5761
+ dsMorph: { src: "./vendor/ds-morph.js", global: "DSMorphPlugin" },
5762
+ morphSVG: { src: "./vendor/MorphSVGPlugin.min.js", global: "MorphSVGPlugin" }
5763
+ };
5559
5764
  var KATEX_JS = "./vendor/katex.min.js";
5560
5765
  var KATEX_CSS = "./katex/katex.min.css";
5561
5766
  function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
@@ -5663,7 +5868,15 @@ function layout(storyboard, source, format, opts = {}) {
5663
5868
  }
5664
5869
  if (scene.css) archetypeCss.add(scene.css.trim());
5665
5870
  if (scene.measure?.length) builds = true;
5666
- for (const p of scene.plugins ?? []) plugins.add(p);
5871
+ for (const w of cut2.scene.warnings ?? []) opts.onBeatWarning?.(beat.id, w);
5872
+ for (const p of scene.plugins ?? []) {
5873
+ if (!Object.hasOwn(PLUGINS, p)) {
5874
+ throw new Error(
5875
+ `${beat.archetype} ${beat.id}: no vendored plugin named "${p}" \u2014 Scene.plugins takes ${Object.keys(PLUGINS).join(" or ")}`
5876
+ );
5877
+ }
5878
+ plugins.add(p);
5879
+ }
5667
5880
  scenes.push(
5668
5881
  sceneHtml(
5669
5882
  sid,
@@ -5783,9 +5996,11 @@ function renderComposition(storyboard, format, laid) {
5783
5996
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5784
5997
  const island = format.navigable ? `
5785
5998
  ${emitIsland(slides)}` : "";
5786
- const morph = laid.plugins.has("dsMorph") ? `
5787
- <script src="${MORPH_SRC}"></script>
5788
- <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5999
+ const plugins = Object.entries(PLUGINS).filter(([name]) => laid.plugins.has(name)).map(
6000
+ ([, p]) => `
6001
+ <script src="${p.src}"></script>
6002
+ <script>gsap.registerPlugin(${p.global});</script>`
6003
+ ).join("");
5789
6004
  return `<!doctype html>
5790
6005
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5791
6006
  <head>
@@ -5794,7 +6009,7 @@ ${emitIsland(slides)}` : "";
5794
6009
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5795
6010
  <script src="${GSAP_SRC}"></script>
5796
6011
  <script src="${DRAWSVG_SRC}"></script>
5797
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
6012
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${plugins}
5798
6013
  <link rel="stylesheet" href="${KATEX_CSS}" />
5799
6014
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5800
6015
  <style>
@@ -6669,8 +6884,8 @@ async function send(url, target, headers, signal, opts) {
6669
6884
  ...secure && isIP(host) === 0 ? { servername: host } : {}
6670
6885
  };
6671
6886
  try {
6672
- return await new Promise((resolve6, reject) => {
6673
- const req = (secure ? httpsRequest : httpRequest)(options, resolve6);
6887
+ return await new Promise((resolve7, reject) => {
6888
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve7);
6674
6889
  req.on("error", reject);
6675
6890
  req.end();
6676
6891
  });
@@ -6839,7 +7054,7 @@ import { z as z2 } from "zod";
6839
7054
  async function fetchFigures(source, dir, warnings) {
6840
7055
  const drops = warnings ?? [];
6841
7056
  await mkdir3(dir, { recursive: true });
6842
- const cached = await readdir(dir).catch(() => []);
7057
+ const cached2 = await readdir(dir).catch(() => []);
6843
7058
  const figures = [];
6844
7059
  for (const figure of source.figures) {
6845
7060
  if (figure.kind === "clip") {
@@ -6847,7 +7062,7 @@ async function fetchFigures(source, dir, warnings) {
6847
7062
  continue;
6848
7063
  }
6849
7064
  try {
6850
- figures.push(figureSchema.parse(await localize(figure, dir, cached)));
7065
+ figures.push(figureSchema.parse(await localize(figure, dir, cached2)));
6851
7066
  } catch (err) {
6852
7067
  drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
6853
7068
  }
@@ -6861,15 +7076,15 @@ async function fetchFigures(source, dir, warnings) {
6861
7076
  }
6862
7077
  return parsed.data;
6863
7078
  }
6864
- async function localize(figure, dir, cached) {
7079
+ async function localize(figure, dir, cached2) {
6865
7080
  const stem = assetStem(figure.id, figure.src);
6866
- const hit = cached.find((name) => name.startsWith(`${stem}.`));
7081
+ const hit = cached2.find((name) => name.startsWith(`${stem}.`));
6867
7082
  const bytes = hit ? await readFile4(join3(dir, hit)) : await load(figure.src);
6868
7083
  const size2 = imageSize(bytes);
6869
7084
  if (hit) return { ...figure, src: hit, ...size2 };
6870
7085
  const src = `${stem}${assetExt(bytes, figure.src)}`;
6871
7086
  await writeFile3(join3(dir, src), bytes);
6872
- cached.push(src);
7087
+ cached2.push(src);
6873
7088
  return { ...figure, src, ...size2 };
6874
7089
  }
6875
7090
  function assetStem(id2, src) {
@@ -7455,7 +7670,7 @@ ${tail3}`);
7455
7670
  }
7456
7671
  }
7457
7672
  function runLive(file, args) {
7458
- return new Promise((resolve6, reject) => {
7673
+ return new Promise((resolve7, reject) => {
7459
7674
  const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
7460
7675
  child.on("error", (err) => {
7461
7676
  reject(
@@ -7463,7 +7678,7 @@ function runLive(file, args) {
7463
7678
  );
7464
7679
  });
7465
7680
  child.on("close", (code, signal) => {
7466
- if (code === 0) resolve6();
7681
+ if (code === 0) resolve7();
7467
7682
  else if (signal) {
7468
7683
  reject(
7469
7684
  new Error(
@@ -8842,7 +9057,10 @@ var REVEALS = {
8842
9057
  "equation-walk": "one per term",
8843
9058
  "equation-morph": "2",
8844
9059
  "data-table": "one per highlighted row, plus 1",
8845
- "line-chart": "1",
9060
+ // "long enough" is not a hedge: below the emitter's own floor the comparison
9061
+ // is dropped and the chart stops twice becoming a chart that stops once. See
9062
+ // the line-chart entry under THE THIRTEEN ARCHETYPES for what that costs.
9063
+ "line-chart": "1, or 2 when compare is given and the beat is long enough for it",
8846
9064
  callout: "one per panel",
8847
9065
  pipeline: "one per stage",
8848
9066
  "annotated-figure": "one per note, plus 1",
@@ -8965,6 +9183,29 @@ DRAWING ARCHETYPES \u2014 reach here first
8965
9183
  scale, over training. Points come from the source's numbers;
8966
9184
  deltas, if given, are the steps between consecutive points, so
8967
9185
  there is always one fewer.
9186
+ \`compare\` adds a SECOND CONDITION over the same axis \u2014 the tell
9187
+ there is THE SAME QUANTITY MEASURED TWO WAYS, a baseline and the
9188
+ result, where what the source is claiming is the change in the
9189
+ SHAPE of the curve and not two numbers. Its \`label\` names the
9190
+ baseline ("without pretraining", "FP32") and is drawn on the
9191
+ slide where the chart has room for it. Keep it SHORT \u2014 two or
9192
+ three words. A label wider than the plot is refused and the
9193
+ whole slide goes with it, and one that cannot be placed clear of
9194
+ the axis names, the values and both curves is dropped without a
9195
+ word, leaving the baseline unnamed. A \`readout\` takes the plot
9196
+ down to about two thirds of its width, so a beat that has one
9197
+ has room for a shorter label still.
9198
+ \`compare.points\` must carry the SAME x values, in the
9199
+ same order, as \`points\`; the baseline is drawn first, then the
9200
+ curve reshapes into \`points\` and leaves the baseline behind. Do
9201
+ not use it for two unrelated series that happen to share an
9202
+ axis \u2014 nothing reshapes into something it is not a version of.
9203
+ A compare beat also costs TIME \u2014 the baseline draws, is held,
9204
+ then reshapes \u2014 so give it \`seconds\` of 7, or 8 with a
9205
+ \`readout\`. Give it too few and the emitter draws the chart
9206
+ without the comparison rather than stopping on a half-drawn one:
9207
+ the slide is still there, but the point about the baseline is
9208
+ gone.
8968
9209
 
8969
9210
  DESCRIBING ARCHETYPES \u2014 the fallbacks
8970
9211
 
@@ -9749,7 +9990,7 @@ function codexCommand(args) {
9749
9990
  }
9750
9991
  function runCodex(args) {
9751
9992
  const { argv, env } = codexCommand(args);
9752
- return new Promise((resolve6, reject) => {
9993
+ return new Promise((resolve7, reject) => {
9753
9994
  const child = spawn2("codex", argv, {
9754
9995
  stdio: ["pipe", "ignore", "pipe"],
9755
9996
  ...env === void 0 ? {} : { env }
@@ -9772,7 +10013,7 @@ function runCodex(args) {
9772
10013
  });
9773
10014
  child.on("close", (code) => {
9774
10015
  clearTimeout(timer);
9775
- if (code === 0) return resolve6();
10016
+ if (code === 0) return resolve7();
9776
10017
  reject(
9777
10018
  new Error(`codex exec exited ${code}.
9778
10019
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -10243,7 +10484,7 @@ async function find2(can) {
10243
10484
  throw new Error(MISSING);
10244
10485
  }
10245
10486
  function runArgv(cmd, args) {
10246
- return new Promise((resolve6) => {
10487
+ return new Promise((resolve7) => {
10247
10488
  const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
10248
10489
  let stdout = "";
10249
10490
  let stderr = "";
@@ -10253,8 +10494,8 @@ function runArgv(cmd, args) {
10253
10494
  child.stderr.on("data", (b) => {
10254
10495
  stderr += b.toString();
10255
10496
  });
10256
- child.on("error", (e) => resolve6({ code: -1, stderr: String(e), stdout }));
10257
- child.on("close", (code) => resolve6({ code: code ?? -1, stderr, stdout }));
10497
+ child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
10498
+ child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
10258
10499
  });
10259
10500
  }
10260
10501
  var edgeTts = {
@@ -10353,8 +10594,8 @@ async function synthesize(text2, opts) {
10353
10594
  const file = `${key}.mp3`;
10354
10595
  const audio = join8(opts.dir, file);
10355
10596
  const sidecar = join8(opts.dir, `${key}.json`);
10356
- const cached = await readSidecar(sidecar);
10357
- if (cached) return { audio, file, seconds: cached.seconds, cues: cached.cues };
10597
+ const cached2 = await readSidecar(sidecar);
10598
+ if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
10358
10599
  await mkdir6(opts.dir, { recursive: true });
10359
10600
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
10360
10601
  const { cues, seconds } = spoken;
@@ -12489,13 +12730,61 @@ ${issues}`);
12489
12730
  return parsed.data;
12490
12731
  }
12491
12732
 
12733
+ // src/tmpdir.ts
12734
+ import { realpathSync } from "node:fs";
12735
+ import { createRequire as createRequire3 } from "node:module";
12736
+ import { tmpdir as tmpdir4 } from "node:os";
12737
+ import { basename as basename4, dirname as dirname4, join as join16, resolve as resolve5, sep as sep2 } from "node:path";
12738
+ var cached;
12739
+ function real(dir) {
12740
+ let at = resolve5(dir);
12741
+ const tail3 = [];
12742
+ for (; ; ) {
12743
+ try {
12744
+ return join16(realpathSync(at), ...tail3);
12745
+ } catch {
12746
+ const up = dirname4(at);
12747
+ if (up === at) return resolve5(dir);
12748
+ tail3.unshift(basename4(at));
12749
+ at = up;
12750
+ }
12751
+ }
12752
+ }
12753
+ function packageRoot() {
12754
+ cached ??= real(dirname4(createRequire3(import.meta.url).resolve("../package.json")));
12755
+ return cached;
12756
+ }
12757
+ function insideRoot(dir) {
12758
+ const root = packageRoot();
12759
+ const at = real(dir);
12760
+ return at === root || at.startsWith(root + sep2);
12761
+ }
12762
+ function guardTmpdir() {
12763
+ const before = tmpdir4();
12764
+ if (!insideRoot(before)) return;
12765
+ delete process.env.TMPDIR;
12766
+ const after = tmpdir4();
12767
+ if (insideRoot(after)) {
12768
+ throw new Error(
12769
+ `tmpdir: ${after} is still inside ${packageRoot()}. Set TMPDIR to a real temp directory.`
12770
+ );
12771
+ }
12772
+ const worker = process.env.VITEST_WORKER_ID;
12773
+ if (worker === void 0 || worker === "1") {
12774
+ process.stderr.write(
12775
+ `tmpdir: TMPDIR pointed at ${before}, inside the source tree, so every mkdtemp would land in the repo. Using ${after}. Fix the environment \u2014 this only stops the mess.
12776
+ `
12777
+ );
12778
+ }
12779
+ }
12780
+
12492
12781
  // src/index.ts
12493
12782
  async function buildDeck(storyboard, source, outDir, opts = {}) {
12494
12783
  const format = opts.format ?? FORMATS["deck-16x9"];
12495
12784
  if (!format) throw new Error("no deck-16x9 format");
12496
12785
  const step = opts.onStep ?? (() => {
12497
12786
  });
12498
- const out = resolve5(outDir);
12787
+ const out = resolve6(outDir);
12499
12788
  await mkdir10(out, { recursive: true });
12500
12789
  const speed = opts.speed ?? 1;
12501
12790
  const fontCss = await refreshFont(storyboard, source, out, step);
@@ -12504,12 +12793,13 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
12504
12793
  ...opts.theme ? { theme: opts.theme } : {},
12505
12794
  ...opts.narration ? { narration: opts.narration } : {},
12506
12795
  ...opts.onBeatError ? { onBeatError: opts.onBeatError } : {},
12796
+ ...opts.onBeatWarning ? { onBeatWarning: opts.onBeatWarning } : {},
12507
12797
  ...fontCss ? { fontCss } : {}
12508
12798
  });
12509
12799
  const files = [];
12510
12800
  const write = async (name, text2) => {
12511
- await writeFile12(join16(out, name), text2);
12512
- files.push(join16(out, name));
12801
+ await writeFile12(join17(out, name), text2);
12802
+ files.push(join17(out, name));
12513
12803
  };
12514
12804
  await write("index.html", deck.composition);
12515
12805
  await write("hyperframes.json", HYPERFRAMES_JSON);
@@ -12538,8 +12828,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
12538
12828
  }
12539
12829
  if (deck.page) {
12540
12830
  await write(DECK_PAGE, deck.page);
12541
- await cp(playerBundle(), join16(out, PLAYER_FILE));
12542
- files.push(join16(out, PLAYER_FILE));
12831
+ await cp(playerBundle(), join17(out, PLAYER_FILE));
12832
+ files.push(join17(out, PLAYER_FILE));
12543
12833
  }
12544
12834
  files.push(...await vendorKatex(out));
12545
12835
  if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
@@ -12548,7 +12838,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
12548
12838
  }
12549
12839
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
12550
12840
  step(
12551
- `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join16(out, "index.html")}`
12841
+ `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join17(out, "index.html")}`
12552
12842
  );
12553
12843
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
12554
12844
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -12572,40 +12862,40 @@ async function deckRuntime() {
12572
12862
  }
12573
12863
  }
12574
12864
  function playerBundle() {
12575
- const require2 = createRequire3(import.meta.url);
12865
+ const require2 = createRequire4(import.meta.url);
12576
12866
  try {
12577
- return join16(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12867
+ return join17(dirname5(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12578
12868
  } catch {
12579
12869
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
12580
12870
  }
12581
12871
  }
12582
12872
  async function vendorKatex(out) {
12583
- const require2 = createRequire3(import.meta.url);
12584
- const dist = join16(dirname4(require2.resolve("katex/package.json")), "dist");
12585
- const css = await readFile17(join16(dist, "katex.min.css"), "utf8");
12873
+ const require2 = createRequire4(import.meta.url);
12874
+ const dist = join17(dirname5(require2.resolve("katex/package.json")), "dist");
12875
+ const css = await readFile17(join17(dist, "katex.min.css"), "utf8");
12586
12876
  const written = [];
12587
- await mkdir10(join16(out, "katex/fonts"), { recursive: true });
12588
- for (const file of await readdir4(join16(dist, "fonts"))) {
12877
+ await mkdir10(join17(out, "katex/fonts"), { recursive: true });
12878
+ for (const file of await readdir4(join17(dist, "fonts"))) {
12589
12879
  if (!file.endsWith(".woff2")) continue;
12590
- await cp(join16(dist, "fonts", file), join16(out, "katex/fonts", file));
12591
- written.push(join16(out, "katex/fonts", file));
12880
+ await cp(join17(dist, "fonts", file), join17(out, "katex/fonts", file));
12881
+ written.push(join17(out, "katex/fonts", file));
12592
12882
  }
12593
12883
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
12594
12884
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
12595
12885
  return kept ? `src:${kept}` : whole;
12596
12886
  });
12597
- await writeFile12(join16(out, "katex/katex.min.css"), woff2Only);
12598
- written.push(join16(out, "katex/katex.min.css"));
12887
+ await writeFile12(join17(out, "katex/katex.min.css"), woff2Only);
12888
+ written.push(join17(out, "katex/katex.min.css"));
12599
12889
  return written;
12600
12890
  }
12601
12891
  async function copyAssets(sourceDir, out) {
12602
- const from = join16(resolve5(sourceDir), "assets");
12892
+ const from = join17(resolve6(sourceDir), "assets");
12603
12893
  if (!await stat2(from).catch(() => null)) return [];
12604
- await cp(from, join16(out, "assets"), { recursive: true });
12605
- return [join16(out, "assets")];
12894
+ await cp(from, join17(out, "assets"), { recursive: true });
12895
+ return [join17(out, "assets")];
12606
12896
  }
12607
12897
  async function copyAudio(from, narration, out) {
12608
- const dir = join16(out, narration.dir);
12898
+ const dir = join17(out, narration.dir);
12609
12899
  await mkdir10(dir, { recursive: true });
12610
12900
  const names = [
12611
12901
  ...new Set(
@@ -12613,18 +12903,18 @@ async function copyAudio(from, narration, out) {
12613
12903
  )
12614
12904
  ].sort();
12615
12905
  for (const name of names) {
12616
- await cp(join16(resolve5(from), name), join16(dir, name)).catch(() => {
12906
+ await cp(join17(resolve6(from), name), join17(dir, name)).catch(() => {
12617
12907
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
12618
12908
  });
12619
12909
  }
12620
- return names.map((n3) => join16(dir, n3));
12910
+ return names.map((n3) => join17(dir, n3));
12621
12911
  }
12622
12912
  async function refreshFont(storyboard, source, out, step) {
12623
12913
  try {
12624
12914
  const bundle = await bundleFont(
12625
12915
  storyboard.lang,
12626
12916
  JSON.stringify(source) + JSON.stringify(storyboard),
12627
- join16(out, "assets", "fonts")
12917
+ join17(out, "assets", "fonts")
12628
12918
  );
12629
12919
  if (bundle) step(`build: font bundle covers ${bundle.family}`);
12630
12920
  return bundle?.css;
@@ -12694,6 +12984,7 @@ export {
12694
12984
  framePlan,
12695
12985
  gradeOverprint,
12696
12986
  gridParamsSchema,
12987
+ guardTmpdir,
12697
12988
  harvest,
12698
12989
  hasIllustrations,
12699
12990
  illustrate,