@jokerized/decksmith 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -850,27 +850,229 @@ function drawFrom(length) {
850
850
  function id(sid, part, i) {
851
851
  return i === void 0 ? `${sid}-${part}` : `${sid}-${part}${i}`;
852
852
  }
853
- function charUnits(c) {
854
- if (c === " ") return 0.3;
855
- const code = c.codePointAt(0) ?? 0;
856
- if (code > 11903) return 1.02;
857
- if (c >= "0" && c <= "9" || c === "." || c === ",") return 0.6;
858
- if ("MW@%".includes(c)) return 0.95;
859
- if ("mw".includes(c)) return 0.88;
860
- if ("ijltI:;'`|!()[]".includes(c)) return 0.33;
861
- if (c >= "A" && c <= "Z") return 0.72;
862
- if (code >= 192 && code <= 223) return 0.72;
863
- return 0.56;
853
+ var ADVANCE = {
854
+ /* digits, PROPORTIONAL see `TABULAR_FIGURE` for a table's */
855
+ "0": 0.646,
856
+ "1": 0.413,
857
+ "2": 0.61,
858
+ "3": 0.618,
859
+ "4": 0.648,
860
+ "5": 0.596,
861
+ "6": 0.622,
862
+ "7": 0.566,
863
+ "8": 0.624,
864
+ "9": 0.622,
865
+ ".": 0.32,
866
+ ",": 0.32,
867
+ /* capitals */
868
+ A: 0.715,
869
+ B: 0.655,
870
+ C: 0.731,
871
+ D: 0.722,
872
+ E: 0.602,
873
+ F: 0.591,
874
+ G: 0.747,
875
+ H: 0.744,
876
+ I: 0.269,
877
+ J: 0.571,
878
+ K: 0.689,
879
+ L: 0.566,
880
+ M: 0.904,
881
+ N: 0.754,
882
+ O: 0.765,
883
+ P: 0.639,
884
+ Q: 0.765,
885
+ R: 0.644,
886
+ S: 0.642,
887
+ T: 0.646,
888
+ U: 0.745,
889
+ // biome-ignore lint/suspicious/noApproximativeNumericConstant: X's advance in Inter, measured. It is near 1/sqrt(2) by coincidence and means nothing of the sort.
890
+ V: 0.715,
891
+ W: 0.993,
892
+ X: 0.707,
893
+ Y: 0.7,
894
+ Z: 0.636,
895
+ /* lowercase */
896
+ a: 0.562,
897
+ b: 0.613,
898
+ c: 0.572,
899
+ d: 0.613,
900
+ e: 0.584,
901
+ f: 0.381,
902
+ g: 0.614,
903
+ h: 0.596,
904
+ i: 0.26,
905
+ j: 0.26,
906
+ k: 0.555,
907
+ l: 0.26,
908
+ m: 0.876,
909
+ n: 0.596,
910
+ o: 0.6,
911
+ p: 0.613,
912
+ q: 0.613,
913
+ r: 0.39,
914
+ s: 0.537,
915
+ t: 0.351,
916
+ u: 0.596,
917
+ v: 0.574,
918
+ w: 0.819,
919
+ x: 0.556,
920
+ y: 0.576,
921
+ z: 0.553,
922
+ /* ASCII punctuation */
923
+ "!": 0.324,
924
+ '"': 0.528,
925
+ "#": 0.634,
926
+ $: 0.642,
927
+ "%": 0.982,
928
+ "&": 0.645,
929
+ "'": 0.325,
930
+ "(": 0.391,
931
+ ")": 0.391,
932
+ "*": 0.649,
933
+ "+": 0.662,
934
+ "-": 0.649,
935
+ "/": 0.372,
936
+ ":": 0.32,
937
+ ";": 0.329,
938
+ "<": 0.662,
939
+ "=": 0.662,
940
+ ">": 0.662,
941
+ "?": 0.536,
942
+ "@": 0.973,
943
+ "[": 0.391,
944
+ "\\": 0.372,
945
+ "]": 0.391,
946
+ "^": 0.472,
947
+ _: 0.457,
948
+ "`": 0.35,
949
+ "{": 0.449,
950
+ "|": 0.356,
951
+ "}": 0.449,
952
+ "~": 0.662,
953
+ " ": 0.282,
954
+ /* dashes, arrows, marks */
955
+ "\xB7": 0.32,
956
+ "\u2014": 1,
957
+ "\u2013": 0.5,
958
+ "\u2026": 0.959,
959
+ "\xD7": 0.662,
960
+ "\xF7": 0.662,
961
+ "\xB0": 0.456,
962
+ "\xB1": 0.662,
963
+ "\u2248": 0.662,
964
+ "\u2264": 0.662,
965
+ "\u2265": 0.662,
966
+ "\u2192": 0.955,
967
+ "\u2190": 0.955,
968
+ "\u2191": 0.867,
969
+ "\u2193": 0.867,
970
+ "\u2194": 1.341,
971
+ "\u27F6": 1.341,
972
+ "\u201C": 0.517,
973
+ "\u201D": 0.51,
974
+ "\u2018": 0.298,
975
+ "\u2019": 0.298,
976
+ "\xAB": 0.632,
977
+ "\xBB": 0.632,
978
+ "\u20AC": 0.667,
979
+ "\xA3": 0.612,
980
+ "\xA5": 0.55,
981
+ "\xA7": 0.569,
982
+ "\xB6": 0.603,
983
+ "\u2020": 0.558,
984
+ "\u2021": 0.558,
985
+ "\u2022": 0.563,
986
+ "\u2030": 1.336,
987
+ "\u2032": 0.263,
988
+ "\u2033": 0.524,
989
+ /* Latin-1 letters — the accent rides above, so ï advances exactly as i does */
990
+ \u00C0: 0.715,
991
+ \u00C1: 0.715,
992
+ \u00C2: 0.715,
993
+ \u00C3: 0.715,
994
+ \u00C4: 0.715,
995
+ \u00C5: 0.715,
996
+ \u00C6: 0.994,
997
+ \u00C7: 0.731,
998
+ \u00C8: 0.602,
999
+ \u00C9: 0.602,
1000
+ \u00CA: 0.602,
1001
+ \u00CB: 0.602,
1002
+ \u00CC: 0.269,
1003
+ \u00CD: 0.269,
1004
+ \u00CE: 0.269,
1005
+ \u00CF: 0.269,
1006
+ \u00D0: 0.735,
1007
+ \u00D1: 0.754,
1008
+ \u00D2: 0.765,
1009
+ \u00D3: 0.765,
1010
+ \u00D4: 0.765,
1011
+ \u00D5: 0.765,
1012
+ \u00D6: 0.765,
1013
+ \u00D8: 0.765,
1014
+ \u00D9: 0.745,
1015
+ \u00DA: 0.745,
1016
+ \u00DB: 0.745,
1017
+ \u00DC: 0.745,
1018
+ \u00DD: 0.7,
1019
+ \u00DE: 0.641,
1020
+ \u00DF: 0.63,
1021
+ \u00E0: 0.562,
1022
+ \u00E1: 0.562,
1023
+ \u00E2: 0.562,
1024
+ \u00E3: 0.562,
1025
+ \u00E4: 0.562,
1026
+ \u00E5: 0.562,
1027
+ \u00E6: 0.918,
1028
+ \u00E7: 0.572,
1029
+ \u00E8: 0.584,
1030
+ \u00E9: 0.584,
1031
+ \u00EA: 0.584,
1032
+ \u00EB: 0.584,
1033
+ \u00EC: 0.26,
1034
+ \u00ED: 0.26,
1035
+ \u00EE: 0.26,
1036
+ \u00EF: 0.26,
1037
+ \u00F0: 0.583,
1038
+ \u00F1: 0.596,
1039
+ \u00F2: 0.6,
1040
+ \u00F3: 0.6,
1041
+ \u00F4: 0.6,
1042
+ \u00F5: 0.6,
1043
+ \u00F6: 0.6,
1044
+ \u00F8: 0.6,
1045
+ \u00F9: 0.596,
1046
+ \u00FA: 0.596,
1047
+ \u00FB: 0.596,
1048
+ \u00FC: 0.596,
1049
+ \u00FD: 0.576,
1050
+ \u00FE: 0.613,
1051
+ \u00FF: 0.576
1052
+ };
1053
+ var KERN_SLACK = 1.007;
1054
+ var TABULAR_FIGURE = 0.649;
1055
+ var TABULAR_SEPARATOR = 0.269;
1056
+ function charUnits(c, tabular) {
1057
+ if (tabular) {
1058
+ if (c >= "0" && c <= "9") return TABULAR_FIGURE;
1059
+ if (c === "." || c === ",") return TABULAR_SEPARATOR;
1060
+ }
1061
+ return ADVANCE[c] ?? 1.02;
864
1062
  }
865
1063
  function weightFactor(weight) {
866
- return weight >= 700 ? 1.07 : weight >= 600 ? 1.04 : 1;
1064
+ return weight >= 700 ? 1.045 : weight >= 600 ? 1.03 : weight >= 500 ? 1.015 : 1;
867
1065
  }
868
- function textWidth(text2, fontSize, weight = 400) {
1066
+ function textWidth(text2, fontSize, weight = 400, tracking = 0, tabular = false) {
869
1067
  let units = 0;
870
- for (const c of text2) units += charUnits(c);
871
- return units * fontSize * weightFactor(weight);
1068
+ let chars = 0;
1069
+ for (const c of text2) {
1070
+ units += charUnits(c, tabular);
1071
+ chars++;
1072
+ }
1073
+ return units * KERN_SLACK * fontSize * weightFactor(weight) + tracking * fontSize * chars;
872
1074
  }
873
- function wrap(text2, fontSize, maxWidth, weight = 400) {
1075
+ function wrap(text2, fontSize, maxWidth, weight = 400, tracking = 0) {
874
1076
  if (maxWidth <= 0) return [text2];
875
1077
  const lines = [];
876
1078
  let line2 = "";
@@ -880,17 +1082,17 @@ function wrap(text2, fontSize, maxWidth, weight = 400) {
880
1082
  };
881
1083
  for (const word of text2.split(/\s+/).filter(Boolean)) {
882
1084
  const candidate = line2 ? `${line2} ${word}` : word;
883
- if (textWidth(candidate, fontSize, weight) <= maxWidth) {
1085
+ if (textWidth(candidate, fontSize, weight, tracking) <= maxWidth) {
884
1086
  line2 = candidate;
885
1087
  continue;
886
1088
  }
887
1089
  push();
888
- if (textWidth(word, fontSize, weight) <= maxWidth) {
1090
+ if (textWidth(word, fontSize, weight, tracking) <= maxWidth) {
889
1091
  line2 = word;
890
1092
  continue;
891
1093
  }
892
1094
  for (const c of word) {
893
- if (line2 && textWidth(line2 + c, fontSize, weight) > maxWidth) push();
1095
+ if (line2 && textWidth(line2 + c, fontSize, weight, tracking) > maxWidth) push();
894
1096
  line2 += c;
895
1097
  }
896
1098
  }
@@ -1161,7 +1363,11 @@ function isPortrait(format) {
1161
1363
  }
1162
1364
  var EYEBROW_SIZE = 42;
1163
1365
  var EYEBROW_LH = 1.2;
1164
- var EYEBROW_H = Math.round(EYEBROW_SIZE * EYEBROW_LH) + 22;
1366
+ var EYEBROW_LINE = Math.round(EYEBROW_SIZE * EYEBROW_LH);
1367
+ var EYEBROW_GAP = 22;
1368
+ var EYEBROW_H = EYEBROW_LINE + EYEBROW_GAP;
1369
+ var EYEBROW_TRACKING = 0.14;
1370
+ var HEADLINE_TRACKING = -0.015;
1165
1371
  var HEADLINE_SIZE = 76;
1166
1372
  var HEADLINE_LH = 1.15;
1167
1373
  var HEADLINE_H = Math.round(HEADLINE_SIZE * HEADLINE_LH);
@@ -1201,12 +1407,14 @@ function wrapTokens(tokens, size, width, weight) {
1201
1407
  return lines;
1202
1408
  }
1203
1409
  function chromeHeight(eyebrow, headline, width) {
1204
- const lines = wrap(headline, HEADLINE_SIZE, width, 700).length;
1205
- return (eyebrow ? EYEBROW_H : 0) + lines * HEADLINE_H;
1410
+ const lines = wrap(headline, HEADLINE_SIZE, width, 700, HEADLINE_TRACKING).length;
1411
+ const brow = eyebrow ? wrap(eyebrow.toUpperCase(), EYEBROW_SIZE, width, 500, EYEBROW_TRACKING).length * EYEBROW_LINE + EYEBROW_GAP : 0;
1412
+ return brow + lines * HEADLINE_H;
1206
1413
  }
1207
- function bodyBudget(format, eyebrow, headline, below = 0, top = 34) {
1414
+ var BODY_FLOOR = 320;
1415
+ function bodyBudget(format, eyebrow, headline, below = 0, top = 34, floor = BODY_FLOOR) {
1208
1416
  const width = contentW(format);
1209
- return Math.max(320, contentH(format) - chromeHeight(eyebrow, headline, width) - top - below);
1417
+ return Math.max(floor, contentH(format) - chromeHeight(eyebrow, headline, width) - top - below);
1210
1418
  }
1211
1419
  function fitText(text2, width, maxLines, lo, hi, weight = 700) {
1212
1420
  const units = textWidth(text2, 1, weight);
@@ -1304,8 +1512,6 @@ var title = (beat, ctx) => {
1304
1512
  };
1305
1513
 
1306
1514
  // src/emit/archetypes/annotated-figure.ts
1307
- var HEAD_H = HEADLINE_H;
1308
- var BROW_H = EYEBROW_H;
1309
1515
  var STAGE_GAP = 30;
1310
1516
  var CAP_GAP = 26;
1311
1517
  var CAP_LH = 1.35;
@@ -1331,7 +1537,17 @@ function lineBudget(count, stageH) {
1331
1537
  const lines = Math.floor((room - ASCENT - RULE_LIFT - RULE_W) / (LAB * LAB_LH)) + 1;
1332
1538
  return Math.min(LINE_CEIL, Math.max(1, lines));
1333
1539
  }
1334
- var COLS = [320, 380, 470, 560];
1540
+ var STEPS = 6;
1541
+ function columns(notes, stageW) {
1542
+ const words = notes.flatMap((n3) => n3.text.split(/\s+/).filter(Boolean));
1543
+ if (words.length === 0) return [stageW / 3];
1544
+ const floor = Math.max(...words.map((w) => textWidth(w, LAB, LAB_WEIGHT)));
1545
+ const ceiling = Math.max(...notes.map((n3) => textWidth(n3.text, LAB, LAB_WEIGHT)));
1546
+ const hi = Math.max(floor, ceiling);
1547
+ if (hi <= floor) return [floor];
1548
+ const ratio = (hi / floor) ** (1 / (STEPS - 1));
1549
+ return Array.from({ length: STEPS }, (_, k) => floor * ratio ** k);
1550
+ }
1335
1551
  var MAX_UPSCALE = 1.5;
1336
1552
  var DOT_R = 9;
1337
1553
  var HALO_R = 18;
@@ -1351,13 +1567,19 @@ function measure(content, col, maxLines) {
1351
1567
  clipped
1352
1568
  };
1353
1569
  }
1354
- function attempt(stageW, notes, fig, stageH, want, tall) {
1570
+ function wantedSides(notes) {
1571
+ const want = { l: false, r: false };
1572
+ for (const note of notes) want[(note.x ?? 0) < 0.5 ? "l" : "r"] = true;
1573
+ return want.l || want.r ? want : { l: true, r: true };
1574
+ }
1575
+ function attempt(stageW, notes, fig, stageH, want, tall, sides = { l: true, r: true }) {
1355
1576
  const band = tall ? tracks(stageW, notes.length, COL_GAP) : [];
1356
1577
  const row = tall && (band[0]?.w ?? 0) >= MIN_COL;
1357
1578
  const stacks = row ? 1 : Math.max(1, Math.ceil(notes.length / 2));
1358
1579
  const bandMin = tall ? stacks * (ASCENT + RULE_LIFT + RULE_W) + (stacks - 1) * LAB_GAP + BAND_GAP : 0;
1359
1580
  const asked = Math.min(want, stageW / 3);
1360
- const plateMax = tall ? stageW - 2 * EDGE : stageW - 2 * (asked + LEAD + EDGE);
1581
+ const margin = (s) => tall || !sides[s] ? EDGE : asked + LEAD + EDGE;
1582
+ const plateMax = tall ? stageW - 2 * EDGE : stageW - margin("l") - margin("r");
1361
1583
  const scale = Math.min(
1362
1584
  (plateMax - 2 * PLATE) / fig.width,
1363
1585
  (stageH - bandMin - 2 * PLATE) / fig.height,
@@ -1366,12 +1588,15 @@ function attempt(stageW, notes, fig, stageH, want, tall) {
1366
1588
  const img = {
1367
1589
  w: fig.width * scale,
1368
1590
  h: fig.height * scale,
1369
- x: (stageW - fig.width * scale) / 2,
1591
+ // Centred between the MARGINS, not on the stage. With both reserved the two
1592
+ // are the same number; with one, centring on the stage walks the figure into
1593
+ // the margin its own labels are standing in.
1594
+ x: tall ? (stageW - fig.width * scale) / 2 : margin("l") + (plateMax - fig.width * scale) / 2,
1370
1595
  // Portrait pins the figure to the top of the stage — the labels take the
1371
1596
  // band under it, so centring it would open a gap the labels then hang off.
1372
1597
  y: tall ? PLATE : (stageH - fig.height * scale) / 2
1373
1598
  };
1374
- const col = row ? band[0]?.w ?? stageW : tall ? (stageW - MID_GAP) / 2 : img.x - PLATE - LEAD - EDGE;
1599
+ const col = row ? band[0]?.w ?? stageW : tall ? (stageW - MID_GAP) / 2 : sides.l ? img.x - PLATE - LEAD - EDGE : stageW - (img.x + img.w) - PLATE - LEAD - EDGE;
1375
1600
  const stackTop = tall ? img.y + img.h + PLATE + BAND_GAP : 0;
1376
1601
  const room = stageH - stackTop;
1377
1602
  const innerOf = (side) => tall ? side === "l" ? col : stageW - col : side === "l" ? img.x - PLATE - LEAD : img.x + img.w + PLATE + LEAD;
@@ -1398,7 +1623,8 @@ function attempt(stageW, notes, fig, stageH, want, tall) {
1398
1623
  const cost = (s, h) => used[s] + (used[s] > 0 ? LAB_GAP : 0) + h;
1399
1624
  for (const w of order) {
1400
1625
  const pref = (notes[w.i]?.x ?? 0) < 0.5 ? "l" : "r";
1401
- const other = pref === "l" ? "r" : "l";
1626
+ const across = pref === "l" ? "r" : "l";
1627
+ const other = sides[across] ? across : pref;
1402
1628
  w.side = cost(pref, w.h) <= room ? pref : cost(other, w.h) <= room ? other : used[pref] <= used[other] ? pref : other;
1403
1629
  used[w.side] = cost(w.side, w.h);
1404
1630
  w.inner = innerOf(w.side);
@@ -1431,7 +1657,12 @@ function attempt(stageW, notes, fig, stageH, want, tall) {
1431
1657
  img: { ...img, y: img.y - top },
1432
1658
  col,
1433
1659
  height: bottom - top,
1434
- ok: (row ? bottom - stackTop <= room : used.l <= room && used.r <= room) && !work2.some((w) => w.clipped),
1660
+ ok: (row ? bottom - stackTop <= room : used.l <= room && used.r <= room) && // THE COLUMN HOLDS WHAT WAS MEASURED AGAINST IT. `measure` wraps to `col`,
1661
+ // so a label wider than `col` means the column shrank AFTER the wrap — the
1662
+ // one-margin arrangement widening the figure into it. Without this the
1663
+ // search calls that a fit and never falls back, and the label runs off the
1664
+ // stage: measured at 30px of text in 29.1px of column.
1665
+ !work2.some((w) => w.w > col) && !work2.some((w) => w.clipped),
1435
1666
  boxes: work2.map((w) => ({
1436
1667
  side: w.side,
1437
1668
  lines: w.lines,
@@ -1447,19 +1678,24 @@ function attempt(stageW, notes, fig, stageH, want, tall) {
1447
1678
  }
1448
1679
  function planFigure(stageW, notes, fig, stageH, tall = false) {
1449
1680
  if (tall) return attempt(stageW, notes, fig, stageH, 0, true);
1450
- let plan = attempt(stageW, notes, fig, stageH, COLS[0] ?? 380, false);
1451
- for (let k = 1; k < COLS.length && !plan.ok; k++) {
1452
- plan = attempt(stageW, notes, fig, stageH, COLS[k] ?? 560, false);
1681
+ const wanted = wantedSides(notes);
1682
+ const both = { l: true, r: true };
1683
+ const tries = wanted.l && wanted.r ? [both] : [wanted, both];
1684
+ const cols = columns(notes, stageW);
1685
+ let plan;
1686
+ for (const sides of tries) {
1687
+ for (const col of cols) {
1688
+ plan = attempt(stageW, notes, fig, stageH, col, false, sides);
1689
+ if (plan.ok) return plan;
1690
+ }
1453
1691
  }
1454
1692
  return plan;
1455
1693
  }
1456
- function stageBudget(format, eyebrow, caption) {
1694
+ var MIN_STAGE = 2 * PLATE + 2 * Math.round(LAB * CAP_LH);
1695
+ function stageBudget(format, eyebrow, headline, caption) {
1457
1696
  const lines = Math.min(CAP_LINES, wrap(caption, LAB, contentW(format)).length);
1458
1697
  const capH = lines * LAB * CAP_LH;
1459
- return Math.max(
1460
- 360,
1461
- contentH(format) - (eyebrow ? BROW_H : 0) - HEAD_H - STAGE_GAP - CAP_GAP - capH
1462
- );
1698
+ return contentH(format) - chromeHeight(eyebrow, headline, contentW(format)) - STAGE_GAP - CAP_GAP - capH;
1463
1699
  }
1464
1700
  function offDot(from, to) {
1465
1701
  const d = Math.hypot(to.x - from.x, to.y - from.y);
@@ -1488,13 +1724,13 @@ var annotatedFigure = (beat, ctx) => {
1488
1724
  y: clamp3((no.y - crop.y) / crop.h)
1489
1725
  })) : p.notes;
1490
1726
  const STAGE_W = contentW(ctx.format);
1491
- const plan = planFigure(
1492
- STAGE_W,
1493
- notes,
1494
- view,
1495
- stageBudget(ctx.format, p.eyebrow !== void 0, fig.caption),
1496
- isPortrait(ctx.format)
1497
- );
1727
+ const budget2 = stageBudget(ctx.format, p.eyebrow, p.headline, fig.caption);
1728
+ if (budget2 < MIN_STAGE) {
1729
+ throw new Error(
1730
+ `annotated-figure ${beat.id}: the headline and caption leave ${Math.round(budget2)}px for the figure, under the ${MIN_STAGE}px floor \u2014 shorten the headline or split the beat`
1731
+ );
1732
+ }
1733
+ const plan = planFigure(STAGE_W, notes, view, budget2, isPortrait(ctx.format));
1498
1734
  const stageH = plan.height;
1499
1735
  const plate = {
1500
1736
  x: plan.img.x - PLATE,
@@ -1721,7 +1957,9 @@ var barCompare = (beat, ctx) => {
1721
1957
  }
1722
1958
  const gutter = tall ? 0 : Math.min(
1723
1959
  gutterMax,
1724
- Math.max(...lines.flat().map((l) => textWidth(l, labelSize, LABEL_WEIGHT))) + GUTTER_PAD
1960
+ Math.ceil(
1961
+ Math.max(...lines.flat().map((l) => textWidth(l, labelSize, LABEL_WEIGHT))) + GUTTER_PAD
1962
+ )
1725
1963
  );
1726
1964
  const metrics = p.bars.map((b) => {
1727
1965
  const printed = String(b.value);
@@ -1973,7 +2211,19 @@ var callout = (beat, ctx) => {
1973
2211
  return 2 * PANEL_PAD_Y + label + LABEL_GAP + body;
1974
2212
  });
1975
2213
  const stackedH = heights.reduce((a, b) => a + b, 0) + PANEL_GAP * (heights.length - 1);
1976
- const cap = Math.round(cols === 1 ? stackedH * 1.1 : Math.max(...heights) * 1.22);
2214
+ const need = cols === 1 ? stackedH : Math.max(...heights);
2215
+ const budget2 = bodyBudget(
2216
+ ctx.format,
2217
+ p.eyebrow,
2218
+ p.headline,
2219
+ noteHeight(p.note, noteWidth(ctx.format))
2220
+ );
2221
+ if (need > budget2) {
2222
+ throw new Error(
2223
+ `callout ${beat.id}: ${Math.round(need)}px of panel in a ${Math.round(budget2)}px box \u2014 shorten the lines or split the beat`
2224
+ );
2225
+ }
2226
+ const cap = Math.min(budget2, Math.round(need * (cols === 1 ? 1.1 : 1.22)));
1977
2227
  const note = p.note ? `
1978
2228
  <div class="conote" id="${sid}-note">${esc(p.note)}</div>` : "";
1979
2229
  const html = `${chrome(sid, p.eyebrow, p.headline, box)}
@@ -2032,6 +2282,8 @@ var FULL_WIDTH_ASPECT = 3;
2032
2282
  var CLAIM_SIZE = 50;
2033
2283
  var CLAIM_LH = 1.5;
2034
2284
  var CLAIM_RULE = 6 + 32;
2285
+ var BESIDE_COL = 560;
2286
+ var MIN_PLATE = 2 * Math.round(BODY_SIZE * BODY_LH);
2035
2287
  var claimFigure = (beat, ctx) => {
2036
2288
  const { sid, theme } = ctx;
2037
2289
  const p = beat.params;
@@ -2042,12 +2294,20 @@ var claimFigure = (beat, ctx) => {
2042
2294
  );
2043
2295
  }
2044
2296
  const box = contentW(ctx.format);
2045
- const tall = isPortrait(ctx.format);
2046
- const wide = !tall && fig.width / fig.height >= FULL_WIDTH_ASPECT;
2047
- const claimBand = tall ? wrap(p.claim, CLAIM_SIZE, box - CLAIM_RULE).length * Math.round(CLAIM_SIZE * CLAIM_LH) + 34 : 0;
2297
+ const portrait = isPortrait(ctx.format);
2298
+ const wide = !portrait && fig.width / fig.height >= FULL_WIDTH_ASPECT;
2299
+ const bandFor = (width) => wrap(p.claim, CLAIM_SIZE, width - CLAIM_RULE).length * Math.round(CLAIM_SIZE * CLAIM_LH) + 34;
2300
+ const rowBudget = bodyBudget(ctx.format, p.eyebrow, p.headline, CAP_BAND, 26);
2301
+ const tall = portrait || !wide && bandFor(BESIDE_COL) - 34 > rowBudget;
2302
+ const claimBand = tall ? bandFor(box) : 0;
2048
2303
  const figMax = Math.round(
2049
- bodyBudget(ctx.format, p.eyebrow, p.headline, CAP_BAND + claimBand, 26) - 32
2050
- );
2304
+ bodyBudget(ctx.format, p.eyebrow, p.headline, CAP_BAND + claimBand, 26, tall ? 0 : void 0)
2305
+ ) - 32;
2306
+ if (figMax < MIN_PLATE) {
2307
+ throw new Error(
2308
+ `claim-figure ${beat.id}: the claim takes ${claimBand}px and leaves ${figMax}px for the figure, under the ${MIN_PLATE}px floor \u2014 shorten the claim or split the beat`
2309
+ );
2310
+ }
2051
2311
  const claim = `<div class="claim" id="${sid}-c">${esc(p.claim)}</div>`;
2052
2312
  const figure = `<div class="figwrap" id="${sid}-f"><img src="assets/${esc(fig.src)}" alt="${esc(fig.caption)}" /></div>`;
2053
2313
  const caption = `<div class="caption" id="${sid}-cap">${esc(fig.caption)}</div>`;
@@ -2074,7 +2334,7 @@ ${body}`,
2074
2334
  // 560, not 640: the claim was set in a column narrow enough to break a
2075
2335
  // sentence over four lines while the figure beside it was capped short, so
2076
2336
  // both halves were smaller than the slide could carry.
2077
- ".cf-beside{display:grid;grid-template-columns:560px 1fr;gap:56px;align-items:center;margin-top:34px}",
2337
+ `.cf-beside{display:grid;grid-template-columns:${BESIDE_COL}px 1fr;gap:56px;align-items:center;margin-top:34px}`,
2078
2338
  ".cf-under{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:start;margin-top:26px}",
2079
2339
  // PORTRAIT. Two children, not three: the caption is wrapped with the plate
2080
2340
  // it captions. `space-between` across claim/figure/caption separately put
@@ -2105,7 +2365,11 @@ ${body}`,
2105
2365
  // src/emit/archetypes/data-table.ts
2106
2366
  var CELL_PAD = 14;
2107
2367
  var CELL_MAX = 52;
2368
+ var TH_TRACKING = 0.04;
2108
2369
  var PAD_Y_MAX = { wide: 40, tall: 68 };
2370
+ var PAD_Y_MIN = 12;
2371
+ var RULE_HEAD = 2;
2372
+ var RULE_ROW = 1;
2109
2373
  var dataTable = (beat, ctx) => {
2110
2374
  const { sid, theme } = ctx;
2111
2375
  const p = beat.params;
@@ -2115,15 +2379,31 @@ var dataTable = (beat, ctx) => {
2115
2379
  }
2116
2380
  const box = contentW(ctx.format);
2117
2381
  const units = table.columns.reduce((total, col, i) => {
2118
- const cells = [col, ...table.rows.map((r) => r[i] ?? "")];
2119
- return total + Math.max(...cells.map((c) => textWidth(c, 1, 600)));
2382
+ const head2 = textWidth(col, 1, 600, TH_TRACKING, true);
2383
+ const body2 = table.rows.map((r) => textWidth(r[i] ?? "", 1, 600, 0, true));
2384
+ return total + Math.max(head2, ...body2);
2120
2385
  }, 0);
2121
2386
  const channels = 2 * CELL_PAD * table.columns.length;
2122
2387
  const cell = Math.max(MIN_FONT, Math.min(CELL_MAX, Math.floor((box - channels) / units)));
2123
2388
  const rows = table.rows.length + 1;
2124
- const spare = bodyBudget(ctx.format, p.eyebrow, p.headline, noteHeight(p.note, noteWidth(ctx.format), 26)) - rows * cell * 1.2;
2389
+ const budget2 = bodyBudget(
2390
+ ctx.format,
2391
+ p.eyebrow,
2392
+ p.headline,
2393
+ noteHeight(p.note, noteWidth(ctx.format), 26),
2394
+ 34,
2395
+ 0
2396
+ );
2397
+ const spare = budget2 - rows * cell * 1.2;
2125
2398
  const roomiest = isPortrait(ctx.format) ? PAD_Y_MAX.tall : PAD_Y_MAX.wide;
2126
- const padY = Math.round(Math.max(12, Math.min(roomiest, spare / (2 * rows))));
2399
+ const padY = Math.round(Math.max(PAD_Y_MIN, Math.min(roomiest, spare / (2 * rows))));
2400
+ const drawn = rows * (cell * 1.2 + 2 * padY);
2401
+ const canvas = budget2 + PAD_Y;
2402
+ if (drawn > canvas) {
2403
+ throw new Error(
2404
+ `data-table ${beat.id}: table "${table.id}" has ${table.rows.length} rows, which with the header stand ${Math.round(drawn)}px tall at ${cell}px type on ${padY}px of row padding, and this slide has ${Math.round(canvas)}px. Every row of a cited table is drawn and none is set below ${MIN_FONT}px, so the lever is upstream of this beat: a shorter table in the source, or the one column that carries the argument in bar-compare.`
2405
+ );
2406
+ }
2127
2407
  const head = table.columns.map((c) => `<th>${mathy(c)}</th>`).join("");
2128
2408
  const body = table.rows.map(
2129
2409
  (row, i) => `<tr class="trow" id="${sid}-r${i}">${row.map((cell2) => `<td>${mathy(cell2)}</td>`).join("")}</tr>`
@@ -2183,16 +2463,30 @@ var dataTable = (beat, ctx) => {
2183
2463
  css: [
2184
2464
  chromeCss(theme),
2185
2465
  // Never derived *downwards*. Shrinking to fit is the one failure mode
2186
- // invariant 10 names: a 30px table clears every automated gate and is
2466
+ // invariant 5 names: a 30px table clears every automated gate and is
2187
2467
  // unreadable projected. A table too wide at 40px eats its margin first and
2188
- // then trips the layout gate at the canvas edge — both of which someone can
2189
- // see, which small type is not. `cell` is floored at 40 for exactly that.
2190
- `table{border-collapse:collapse;width:100%;margin-top:34px;font-size:${cell}px}`,
2191
- `th,td{font-size:inherit;padding:${padY}px ${CELL_PAD}px;text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}`,
2192
- `th{color:${theme.dim};font-weight:500;letter-spacing:.04em;border-bottom:2px solid ${theme.rule}}`,
2193
- "td:first-child,th:first-child{text-align:left}",
2194
- `tbody tr{border-bottom:1px solid ${theme.rule}}`,
2195
- `tbody td{color:${theme.muted}}`,
2468
+ // then trips the layout gate at the canvas edge, which someone can see —
2469
+ // small type is not. A table too TALL to draw is refused above instead,
2470
+ // because there the thing that leaves the canvas is the headline rather
2471
+ // than the table. `cell` is floored at 40 for exactly that.
2472
+ // SCOPED TO THIS SCENE, because two of these rules carry numbers this scene
2473
+ // solved for itself. A deck shares one stylesheet, so an unscoped
2474
+ // `td{padding:${padY}px}` is written once per data-table and the last one
2475
+ // wins for all of them: two tables of different heights in one deck then
2476
+ // render at a single padding, and the shorter one is drawn with the
2477
+ // taller one's arithmetic. The height guard above cannot see that, because
2478
+ // it reasons about the value this scene emitted.
2479
+ //
2480
+ // All six are scoped, not only the two that vary. Scoping `th,td` alone
2481
+ // lifts it to an id's specificity and it would then beat
2482
+ // `td:first-child{text-align:left}`, right-aligning the first column of
2483
+ // every table — a fix that quietly breaks the thing next to it.
2484
+ `#${sid} table{border-collapse:collapse;width:100%;margin-top:34px;font-size:${cell}px}`,
2485
+ `#${sid} th,#${sid} td{font-size:inherit;padding:${padY}px ${CELL_PAD}px;text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}`,
2486
+ `#${sid} th{color:${theme.dim};font-weight:500;letter-spacing:${TH_TRACKING}em;border-bottom:${RULE_HEAD}px solid ${theme.rule}}`,
2487
+ `#${sid} td:first-child,#${sid} th:first-child{text-align:left}`,
2488
+ `#${sid} tbody tr{border-bottom:${RULE_ROW}px solid ${theme.rule}}`,
2489
+ `#${sid} tbody td{color:${theme.muted}}`,
2196
2490
  `.rownote{font-size:${BODY_SIZE}px;line-height:1.45;color:${theme.dim};margin-top:26px}`,
2197
2491
  // A table with nothing emphasised has no focal row, and so no ambient life.
2198
2492
  // `filter` again: the emphasis tween owns this row's colour and weight.
@@ -2439,6 +2733,21 @@ var CELL_MAX2 = 280;
2439
2733
  var MARGIN = 6;
2440
2734
  var MAX_LINES = 2;
2441
2735
  var FIELD_TOP = 52;
2736
+ var NOTE_GAP = FIELD_TOP;
2737
+ var NOTE_LINES = 4;
2738
+ function noteColumn(note, size) {
2739
+ const words = note.split(/\s+/).filter(Boolean);
2740
+ const longest = Math.max(0, ...words.map((w) => textWidth(w, size)));
2741
+ const whole = textWidth(note, size);
2742
+ return { min: longest, want: Math.max(longest, whole / NOTE_LINES) };
2743
+ }
2744
+ function solveCell(cols, rows, boxW, boxH) {
2745
+ return Math.min(
2746
+ CELL_MAX2,
2747
+ (boxW - 2 * MARGIN) / (cols + GAP * (cols - 1)),
2748
+ (boxH - 2 * MARGIN) / (rows + GAP * (rows - 1))
2749
+ );
2750
+ }
2442
2751
  var grid = (beat, ctx) => {
2443
2752
  const { sid, theme } = ctx;
2444
2753
  const p = beat.params;
@@ -2450,19 +2759,17 @@ var grid = (beat, ctx) => {
2450
2759
  }
2451
2760
  }
2452
2761
  const W = contentW(ctx.format);
2453
- const budget2 = bodyBudget(
2762
+ const full = bodyBudget(ctx.format, p.eyebrow, p.headline, 0, FIELD_TOP);
2763
+ const col = p.note ? noteColumn(p.note, BODY_SIZE) : void 0;
2764
+ const stackedBudget = bodyBudget(
2454
2765
  ctx.format,
2455
2766
  p.eyebrow,
2456
2767
  p.headline,
2457
2768
  noteHeight(p.note, noteWidth(ctx.format)),
2458
2769
  FIELD_TOP
2459
2770
  );
2460
- const solve3 = (boxW) => {
2461
- const cell = Math.min(
2462
- CELL_MAX2,
2463
- (boxW - 2 * MARGIN) / (p.cols + GAP * (p.cols - 1)),
2464
- (budget2 - 2 * MARGIN) / (p.rows + GAP * (p.rows - 1))
2465
- );
2771
+ const solve3 = (boxW, budget3) => {
2772
+ const cell = solveCell(p.cols, p.rows, boxW, budget3);
2466
2773
  const gap = cell * GAP;
2467
2774
  return {
2468
2775
  cell,
@@ -2482,18 +2789,26 @@ var grid = (beat, ctx) => {
2482
2789
  };
2483
2790
  const hits = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
2484
2791
  const crowded = p.regions.map((r, i) => p.regions.some((o, k) => k !== i && hits(r, o)));
2485
- const bare = solve3(W);
2486
2792
  const widest2 = Math.max(...p.regions.map((r) => textWidth(r.label, LABEL, 600)));
2487
2793
  const oneLine = Math.min(620, widest2 * 1.06);
2488
2794
  const wrapped = Math.min(620, widest2 / MAX_LINES * 1.12);
2489
- const free = W - bare.w - 2 * MARGIN;
2490
- const needsGutter = p.regions.some((r, i) => crowded[i] || !fitsInside(bare, r));
2491
- const lw = needsGutter ? Math.max(wrapped, Math.min(oneLine, free - 90)) : 0;
2492
- const gutter = needsGutter ? lw + 90 : 0;
2493
- const f = needsGutter ? solve3(W - gutter) : bare;
2494
- const inGutter = p.regions.map((r, i) => crowded[i] || !fitsInside(f, r));
2495
- const lines = (r) => wrap(r.label, LABEL, lw, 600).length;
2496
- const stackH = p.regions.filter((_, i) => inGutter[i]).reduce((t2, r, i) => t2 + lines(r) * LABEL * LH + (i > 0 ? 14 : 16), 0);
2795
+ const layout2 = (budget3) => {
2796
+ const bare = solve3(W, budget3);
2797
+ const free = W - bare.w - 2 * MARGIN;
2798
+ const needsGutter = p.regions.some((r, i) => crowded[i] || !fitsInside(bare, r));
2799
+ const lw2 = needsGutter ? Math.max(wrapped, Math.min(oneLine, free - 90)) : 0;
2800
+ const gutter2 = needsGutter ? lw2 + 90 : 0;
2801
+ const f2 = needsGutter ? solve3(W - gutter2, budget3) : bare;
2802
+ const inGutter2 = p.regions.map((r, i) => crowded[i] || !fitsInside(f2, r));
2803
+ const lines2 = (r) => wrap(r.label, LABEL, lw2, 600).length;
2804
+ const stackH2 = p.regions.filter((_, i) => inGutter2[i]).reduce((t2, r, i) => t2 + lines2(r) * LABEL * LH + (i > 0 ? 14 : 16), 0);
2805
+ return { bare, f: f2, lw: lw2, gutter: gutter2, inGutter: inGutter2, lines: lines2, stackH: stackH2 };
2806
+ };
2807
+ const roomy = layout2(full);
2808
+ const roomBeside = W - Math.ceil(roomy.f.w + 2 * MARGIN + roomy.gutter) - NOTE_GAP;
2809
+ const beside = !isPortrait(ctx.format) && !!col && roomBeside >= col.min;
2810
+ const budget2 = beside ? full : stackedBudget;
2811
+ const { f, lw, gutter, inGutter, lines, stackH } = beside ? roomy : layout2(budget2);
2497
2812
  const H = Math.min(budget2, Math.max(f.h + 2 * MARGIN, stackH));
2498
2813
  const fx = MARGIN;
2499
2814
  const fy = (H - f.h) / 2;
@@ -2502,7 +2817,7 @@ var grid = (beat, ctx) => {
2502
2817
  const corner = Math.min(10, f.cell * 0.12);
2503
2818
  const cells = rows.flatMap(
2504
2819
  (row) => cols.map(
2505
- (col) => roundRect({ x: col.x, y: row.x, w: col.w, h: row.w }, corner, { class: "gcell" })
2820
+ (col2) => roundRect({ x: col2.x, y: row.x, w: col2.w, h: row.w }, corner, { class: "gcell" })
2506
2821
  )
2507
2822
  ).join("");
2508
2823
  const stroke = Math.max(3, Math.min(6, f.cell * 0.16));
@@ -2602,7 +2917,11 @@ var grid = (beat, ctx) => {
2602
2917
  );
2603
2918
  const note = p.note ? `
2604
2919
  <div class="gdnote" id="${id(sid, "note")}">${esc(p.note)}</div>` : "";
2605
- const html = `${chrome(sid, p.eyebrow, p.headline, W)}
2920
+ const fieldW = Math.ceil(f.w + 2 * MARGIN + gutter);
2921
+ const room = W - fieldW - NOTE_GAP;
2922
+ const noteW = col ? Math.round(Math.min(col.want, room)) : 0;
2923
+ const html = beside ? `${chrome(sid, p.eyebrow, p.headline, W)}
2924
+ <div class="growbeside"><div class="gwrap" style="width:${fieldW}px;flex:none">${field}</div><div class="gnotecol" style="width:${noteW}px">${note}</div></div>` : `${chrome(sid, p.eyebrow, p.headline, W)}
2606
2925
  <div class="gwrap">${field}</div>${note}`;
2607
2926
  const drawn = 1.6;
2608
2927
  const tl = [
@@ -2678,6 +2997,16 @@ var grid = (beat, ctx) => {
2678
2997
  css: [
2679
2998
  chromeCss(theme),
2680
2999
  ".gwrap{margin-top:52px}",
3000
+ // The note beside the field rather than under it. `align-items:center` sets
3001
+ // it against the middle of the lattice, and the column takes what the field
3002
+ // left — which `beside` already proved is at least `NOTE_COL`. `margin-top`
3003
+ // on the note is zeroed because `noteCss` sets one for the stacked layout,
3004
+ // where it is the gap under the field; here the gap is horizontal.
3005
+ `.growbeside{display:flex;align-items:center;gap:${NOTE_GAP}px}`,
3006
+ // `max-width:none` because `noteCss` caps the stacked note at `NOTE_MAX_W`,
3007
+ // which is a measure for a note running the full content width. Here the
3008
+ // column is already the width the note asked for, and the cap would fight it.
3009
+ ".growbeside .gdnote{margin-top:0;max-width:none}",
2681
3010
  `.gcell{fill:${theme.panel};stroke:${theme.rule};stroke-width:1}`,
2682
3011
  // `fill-opacity` and the dash offset are what the reveal animates; the
2683
3012
  // stylesheet holds the pre-reveal state so a still render is a bare field.
@@ -2736,8 +3065,8 @@ var lineChart = (beat, ctx) => {
2736
3065
  const budget2 = bodyBudget(ctx.format, p.eyebrow, p.headline, below, CHART_TOP);
2737
3066
  const H = Math.round(tall ? Math.min(budget2, width * TALL_ASPECT) : budget2);
2738
3067
  const last = p.points[p.points.length - 1];
2739
- const valueW = String(last?.y ?? "").length * 40 * 0.58;
2740
- const labelW = (last?.x ?? "").length * 40 * 0.68;
3068
+ const valueW = textWidth(String(last?.y ?? ""), 40);
3069
+ const labelW = textWidth(last?.x ?? "", 40);
2741
3070
  const padR = Math.max(PAD2.r, Math.ceil(Math.max(valueW, labelW) / 2) + 16);
2742
3071
  const plotW = width - PAD2.l - padR;
2743
3072
  const plotH = H - PAD2.t - PAD2.b;
@@ -2750,7 +3079,31 @@ var lineChart = (beat, ctx) => {
2750
3079
  );
2751
3080
  const grid2 = ticks.map((v) => `<line x1="${PAD2.l}" y1="${n2(y(v))}" x2="${width - padR}" y2="${n2(y(v))}" />`).join("");
2752
3081
  const yLabels = ticks.map((v) => `<text x="${PAD2.l - 22}" y="${n2(y(v) + 13)}">${v.toFixed(scale.decimals)}</text>`).join("");
2753
- const xLabels = p.points.map((pt, i) => `<text x="${n2(x(i))}" y="${H - PAD2.b + 56}">${esc(pt.x)}</text>`).join("");
3082
+ const fitIndices = (widthAt, firstAnchoredStart) => {
3083
+ const leftEdge = (i) => i === 0 && firstAnchoredStart ? x(i) : x(i) - widthAt(i) / 2;
3084
+ const rightEdge = (i) => i === 0 && firstAnchoredStart ? x(i) + widthAt(i) : x(i) + widthAt(i) / 2;
3085
+ const kept = [];
3086
+ const last2 = p.points.length - 1;
3087
+ for (let i = 0; i <= last2; i++) {
3088
+ const previous = kept[kept.length - 1];
3089
+ if (i === last2) {
3090
+ while (kept.length > 0 && rightEdge(kept[kept.length - 1]) + 8 > leftEdge(i)) {
3091
+ kept.pop();
3092
+ }
3093
+ kept.push(i);
3094
+ } else if (previous === void 0 || leftEdge(i) >= rightEdge(previous) + 8) {
3095
+ kept.push(i);
3096
+ }
3097
+ }
3098
+ return new Set(kept);
3099
+ };
3100
+ const LABEL_SIZE3 = 40;
3101
+ const runW = (s) => textWidth(s, LABEL_SIZE3);
3102
+ const catW = (s) => textWidth(s, LABEL_SIZE3);
3103
+ const shownX = fitIndices((i) => catW(p.points[i]?.x ?? ""), false);
3104
+ const xLabels = p.points.map(
3105
+ (pt, i) => shownX.has(i) ? `<text x="${n2(x(i))}" y="${H - PAD2.b + 56}">${esc(pt.x)}</text>` : ""
3106
+ ).join("");
2754
3107
  const path2 = p.points.map((pt, i) => `${i === 0 ? "M" : "L"}${n2(x(i))},${n2(y(pt.y))}`).join(" ");
2755
3108
  const length = p.points.reduce(
2756
3109
  (total, pt, i) => i === 0 ? 0 : total + Math.hypot(x(i) - x(i - 1), y(pt.y) - y(p.points[i - 1]?.y ?? pt.y)),
@@ -2759,11 +3112,29 @@ var lineChart = (beat, ctx) => {
2759
3112
  const dots = p.points.map(
2760
3113
  (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}" />`
2761
3114
  ).join("");
2762
- const values = p.points.map((pt, i) => {
3115
+ const shownValues = fitIndices((i) => runW(String(p.points[i]?.y ?? "")), true);
3116
+ const showsValue = (i) => shownValues.has(i);
3117
+ const values = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => {
2763
3118
  const anchor = i === 0 ? ' text-anchor="start"' : "";
2764
3119
  return `<text class="pv"${anchor} x="${n2(x(i))}" y="${n2(Math.max(44, y(pt.y) - 26))}">${esc(String(pt.y))}</text>`;
2765
3120
  }).join("");
2766
- const deltas = (p.deltas ?? []).slice(0, p.points.length - 1).map((d, i) => {
3121
+ const labelBox = (cx, cy, text2, anchorStart = false) => {
3122
+ const w = runW(text2);
3123
+ return { x: anchorStart ? cx : cx - w / 2, y: cy - LABEL_SIZE3, w, h: LABEL_SIZE3 };
3124
+ };
3125
+ const valueBoxes = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => labelBox(x(i), Math.max(44, y(pt.y) - 26), String(pt.y), i === 0));
3126
+ const overlaps = (a, b) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 8 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 8;
3127
+ const deltaBoxes = (p.deltas ?? []).slice(0, p.points.length - 1).map((d, i) => {
3128
+ const a = p.points[i];
3129
+ const b = p.points[i + 1];
3130
+ if (!a || !b) return null;
3131
+ const mid = (y(a.y) + y(b.y)) / 2;
3132
+ return labelBox((x(i) + x(i + 1)) / 2, tall ? mid + 52 : Math.max(44, mid - 28), d);
3133
+ });
3134
+ const deltasFit = deltaBoxes.every(
3135
+ (d, i) => d !== null && !valueBoxes.some((v) => overlaps(d, v)) && deltaBoxes.slice(i + 1).every((o) => o === null || !overlaps(d, o))
3136
+ );
3137
+ const deltas = (deltasFit ? p.deltas ?? [] : []).slice(0, p.points.length - 1).map((d, i) => {
2767
3138
  const a = p.points[i];
2768
3139
  const b = p.points[i + 1];
2769
3140
  if (!a || !b) return "";
@@ -3490,6 +3861,9 @@ function stackLayout(p, format) {
3490
3861
  const inline = solve2(p, format, true);
3491
3862
  return inline.fits || inline.blockH < stacked.blockH ? inline : stacked;
3492
3863
  }
3864
+ function labelWeight(i, count) {
3865
+ return i === count - 1 ? 700 : 600;
3866
+ }
3493
3867
  function solve2(p, format, inline) {
3494
3868
  const width = contentW(format);
3495
3869
  const boxH = contentH(format);
@@ -3499,19 +3873,23 @@ function solve2(p, format, inline) {
3499
3873
  const syMax = SY_MAX[k];
3500
3874
  const tMax = T_MAX[k];
3501
3875
  const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, MIN_FONT, 400) + 28 : 0;
3502
- const want = Math.max(...p.layers.map((l) => textWidth(l.label, LABEL_SIZE2, 600) + noteW(l)));
3876
+ const want = Math.max(
3877
+ ...p.layers.map((l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count)) + noteW(l))
3878
+ );
3503
3879
  const colCap = inline ? width * 0.56 : width * 0.5;
3504
3880
  const colW = clamp(Math.ceil(want) + 12, Math.min(520, width * 0.34), colCap);
3505
3881
  const labelX = width - colW;
3506
3882
  const labelRoom = Math.min(
3507
- ...p.layers.map((l) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, 600)))
3883
+ ...p.layers.map(
3884
+ (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count)))
3885
+ )
3508
3886
  );
3509
3887
  const labelSize = Math.max(MIN_FONT, Math.min(LABEL_SIZE2, labelRoom));
3510
- const lines = p.layers.map((l) => {
3888
+ const lines = p.layers.map((l, i) => {
3511
3889
  const nw = noteW(l);
3512
3890
  const labelMaxW = Math.max(labelSize, colW - nw);
3513
3891
  return {
3514
- label: wrap(l.label, labelSize, labelMaxW, 600),
3892
+ label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count)),
3515
3893
  // Inline notes stay on one line by contract — the schema calls a note "one
3516
3894
  // short line" — and wrapping one would put its second line under the label.
3517
3895
  note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, MIN_FONT, colW, 400),
@@ -3595,7 +3973,7 @@ var stack = (beat, ctx) => {
3595
3973
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
3596
3974
  {
3597
3975
  size: L.labelSize,
3598
- weight: top ? 700 : 600,
3976
+ weight: labelWeight(i, count),
3599
3977
  fill: top ? theme.tones.b : theme.fg,
3600
3978
  maxWidth: block.labelMaxW,
3601
3979
  lineHeight: 1.16,
@@ -3886,17 +4264,30 @@ function planCut(storyboard, source, format, opts = {}) {
3886
4264
  const speed = opts.speed ?? 1;
3887
4265
  const floor = storyboard.beats.filter((b) => b.weight >= format.minWeight);
3888
4266
  const seconds = {};
4267
+ const undrawable = /* @__PURE__ */ new Set();
3889
4268
  floor.forEach((beat, i) => {
3890
4269
  const segments = opts.narration?.beats[beat.id];
3891
- const { scene } = stageScene(
3892
- emitScene(beat, { source, format, theme, sid: `s${i + 1}` }),
3893
- speed
3894
- );
3895
- const own = beatSeconds(beat.seconds * speed, scene, segments);
3896
- const tail = floor[i + 1]?.inside?.beat === beat.id ? MOVE_SECONDS + FADE_SECONDS : 0;
3897
- seconds[beat.id] = rnd(own + tail * speed);
4270
+ let scene;
4271
+ try {
4272
+ ({ scene } = stageScene(emitScene(beat, { source, format, theme, sid: `s${i + 1}` }), speed));
4273
+ } catch (err) {
4274
+ if (!opts.onBeatError) throw err;
4275
+ opts.onBeatError(beat.id, err instanceof Error ? err : new Error(String(err)));
4276
+ undrawable.add(beat.id);
4277
+ return;
4278
+ }
4279
+ seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments);
4280
+ });
4281
+ if (floor.length > 0 && undrawable.size === floor.length) {
4282
+ throw new Error(`every one of ${floor.length} beat(s) failed to draw \u2014 there is no deck`);
4283
+ }
4284
+ const drawable = floor.filter((b) => !undrawable.has(b.id));
4285
+ drawable.forEach((beat, i) => {
4286
+ const tail = drawable[i + 1]?.inside?.beat === beat.id ? MOVE_SECONDS + FADE_SECONDS : 0;
4287
+ seconds[beat.id] = rnd((seconds[beat.id] ?? 0) + tail * speed);
3898
4288
  });
3899
- return selectBeats(storyboard, format, seconds);
4289
+ const board = undrawable.size ? { ...storyboard, beats: storyboard.beats.filter((b) => !undrawable.has(b.id)) } : storyboard;
4290
+ return selectBeats(board, format, seconds);
3900
4291
  }
3901
4292
  function deckLook(storyboard, opts) {
3902
4293
  const base = resolveTheme(opts.theme ?? storyboard.theme);
@@ -3919,22 +4310,7 @@ function layout(storyboard, source, format, opts = {}) {
3919
4310
  const slides = [];
3920
4311
  const spoken = {};
3921
4312
  const entered = enteredParts(beats);
3922
- const drawable = opts.onBeatError ? beats.filter((beat) => {
3923
- try {
3924
- emitScene(beat, { source, format, theme, sid: "trial" });
3925
- return true;
3926
- } catch (err) {
3927
- opts.onBeatError(
3928
- beat.id,
3929
- err instanceof Error ? err : new Error(String(err))
3930
- );
3931
- return false;
3932
- }
3933
- }) : beats;
3934
- if (drawable.length === 0) {
3935
- throw new Error(`every one of ${beats.length} beat(s) failed to draw \u2014 there is no deck`);
3936
- }
3937
- const cuts = drawable.map((beat, i) => {
4313
+ const cuts = beats.map((beat, i) => {
3938
4314
  const sid = `s${i + 1}`;
3939
4315
  const ctx = { source, format, theme, sid };
3940
4316
  const segments = opts.narration?.beats[beat.id];
@@ -4752,7 +5128,7 @@ var SENTENCES_PER_BEAT = 2;
4752
5128
  var FF_BEAT_SECONDS = 8;
4753
5129
  var MIN_BEAT_SECONDS = 4;
4754
5130
  var SHORT_FORM_CPS = 22;
4755
- var CUE_OVERHEAD = 1.17;
5131
+ var CUE_OVERHEAD = 1.28;
4756
5132
  var COMFORTABLE_CPS = 17;
4757
5133
  var MAX_PLAYBACK = 1.25;
4758
5134
  var SPEAKING_STOPS = {
@@ -4795,8 +5171,9 @@ function durationPlan(prefs) {
4795
5171
  );
4796
5172
  }
4797
5173
  if (speedup > 1) {
5174
+ const cue = cps * speedup * CUE_OVERHEAD;
4798
5175
  warnings.push(
4799
- `narration speaks at ${rate} to fit ${chars} characters a slide into ${speechSeconds.toFixed(1)}s \u2014 ${slow.chars} at normal speed. Subtitles run near ${(cps * speedup).toFixed(0)} characters per second against the ${COMFORTABLE_CPS} cps broadcast practice, which is the cost of the extra words.`
5176
+ `narration speaks at ${rate} to fit ${chars} characters a slide into ${speechSeconds.toFixed(1)}s \u2014 ${slow.chars} at normal speed. Subtitles run near ${cue.toFixed(0)} characters per second against the ${COMFORTABLE_CPS} cps broadcast practice, which is the cost of the extra words.`
4800
5177
  );
4801
5178
  }
4802
5179
  const beatChars = Math.round(speechSeconds * cps * speedup);
@@ -5010,9 +5387,15 @@ DESCRIBING ARCHETYPES \u2014 the fallbacks
5010
5387
  data-table A table with up to four rows revealed and toned. Use when the
5011
5388
  cells themselves matter \u2014 mixed units, text values, a matrix the
5012
5389
  reader must scan. If it is one column of numbers in one unit,
5013
- that is bar-compare. params.tableId must name a table, and each
5014
- highlight[].row must match one of that table's first-column
5015
- values exactly.
5390
+ that is bar-compare. It draws EVERY row of the table it names and
5391
+ cannot show a subset. Five to seven rows is what a 16:9 slide
5392
+ holds \u2014 fewer under a long headline or a two-line note, more in
5393
+ portrait or where wider columns set the type smaller \u2014 and a
5394
+ table that does not fit is refused rather than shrunk, which
5395
+ fails the build. For a longer one, take the column that carries
5396
+ the argument to bar-compare, or cite it under another archetype.
5397
+ params.tableId must name a table, and each highlight[].row must
5398
+ match one of that table's first-column values exactly.
5016
5399
 
5017
5400
  callout One to three short panels. The last resort, for structure that
5018
5401
  has no shape at all: a definition, the limitations the source
@@ -5664,7 +6047,11 @@ function stopCount(holds) {
5664
6047
  }
5665
6048
  function stopsFor(beat, source, format, sid = "s1") {
5666
6049
  const ctx = { source, format, theme: ink, sid };
5667
- return stopCount(emitScene(beat, ctx).holds);
6050
+ try {
6051
+ return stopCount(emitScene(beat, ctx).holds);
6052
+ } catch {
6053
+ return 1;
6054
+ }
5668
6055
  }
5669
6056
  function splitSentences(text2) {
5670
6057
  const parts = [];
@@ -7610,7 +7997,7 @@ function deckTools(opts) {
7610
7997
  const root2 = resolve4(opts.root);
7611
7998
  let cached;
7612
7999
  const found = async () => {
7613
- cached ??= await prereqs();
8000
+ cached ??= await (opts.probe ?? prereqs)();
7614
8001
  return cached;
7615
8002
  };
7616
8003
  const insideRoot2 = (p) => {
@@ -7659,6 +8046,7 @@ function deckTools(opts) {
7659
8046
  }
7660
8047
  const settings = input.settings ?? {};
7661
8048
  const options = parseOptions(fieldsFor(settings));
8049
+ const file = input.document_path ? insideRoot2(input.document_path) : void 0;
7662
8050
  const missing = missingFor(await found(), options);
7663
8051
  if (missing.length) {
7664
8052
  throw new Error(
@@ -7666,7 +8054,7 @@ function deckTools(opts) {
7666
8054
  );
7667
8055
  }
7668
8056
  const filename = input.document_path ? input.document_path.split(sep2).pop() : "document.md";
7669
- const bytes = input.document_path ? await readFile10(insideRoot2(input.document_path)) : new TextEncoder().encode(input.document_text);
8057
+ const bytes = file ? await readFile10(file) : new TextEncoder().encode(input.document_text);
7670
8058
  if (bytes.byteLength > MAX_UPLOAD_BYTES) {
7671
8059
  throw new Error(
7672
8060
  `Document is ${(bytes.byteLength / 1e6).toFixed(1)} MB, over the ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB cap.`
@@ -7728,7 +8116,11 @@ function report(view, dir) {
7728
8116
  steps: view.steps.map((s) => ({ name: s.name, state: s.state })),
7729
8117
  log: view.log.slice(-12),
7730
8118
  ...view.error ? { error: view.error } : {},
7731
- storyboard_path: join9(dir, "src", "storyboard.json"),
8119
+ // `join(dir, "storyboard.json")` — where `runPipeline` actually writes it
8120
+ // (src/server/pipeline.ts). This said `src/storyboard.json` for one run, which
8121
+ // is the shape of the CLI's output directory and not the server's, and an
8122
+ // agent told to read it would have found nothing there.
8123
+ storyboard_path: join9(dir, "storyboard.json"),
7732
8124
  ...done && view.result ? {
7733
8125
  deck_path: join9(dir, "deck"),
7734
8126
  slides: view.result.slides,