@jokerized/decksmith 0.9.2 → 0.9.3

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
@@ -1814,7 +1814,11 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1814
1814
  await mkdir3(dir, { recursive: true });
1815
1815
  const cssPath = join3(dir, "fonts.css");
1816
1816
  const cached2 = await readFile3(cssPath, "utf8").catch(() => "");
1817
- if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1817
+ if (cached2.startsWith(stamp)) {
1818
+ const css2 = withInterMetrics(cached2);
1819
+ if (css2 !== cached2) await writeFile3(cssPath, css2);
1820
+ return { family, css: css2, files: localNames(css2) };
1821
+ }
1818
1822
  const res = await fetch(
1819
1823
  `https://fonts.googleapis.com/css2?${families.map((f) => `family=${f.replaceAll(" ", "+")}:wght@400;500;700`).join("&")}&text=${encodeURIComponent(text2)}&display=block`,
1820
1824
  { headers: { "User-Agent": UA } }
@@ -1832,11 +1836,19 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1832
1836
  css = css.replaceAll(url2, name);
1833
1837
  files.push(name);
1834
1838
  }
1835
- css = `${stamp}
1836
- ${css}`;
1839
+ css = withInterMetrics(`${stamp}
1840
+ ${css}`);
1837
1841
  await writeFile3(cssPath, css);
1838
1842
  return { family, css, files };
1839
1843
  }
1844
+ var INTER_METRICS = "ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
1845
+ function withInterMetrics(css) {
1846
+ return css.replace(
1847
+ /@font-face\s*\{(?![^}]*ascent-override)/g,
1848
+ `@font-face {
1849
+ ${INTER_METRICS};`
1850
+ );
1851
+ }
1840
1852
  function localNames(css) {
1841
1853
  return [...css.matchAll(/url\(([^)]+)\)/g)].map((m) => m[1] ?? "");
1842
1854
  }
@@ -6832,7 +6844,6 @@ var READOUT_W = 460;
6832
6844
  var READOUT_LH = 1.35;
6833
6845
  var TALL_ASPECT = 1.15;
6834
6846
  var RESHAPE_SECONDS = 1.2;
6835
- var GHOST_OPACITY = 0.28;
6836
6847
  var DRAW_SECONDS = 1.8;
6837
6848
  var DRAW_FLOOR = 1;
6838
6849
  var SEPARATE = 0.5;
@@ -6909,6 +6920,14 @@ var lineChart = (beat2, ctx) => {
6909
6920
  return new Set(kept);
6910
6921
  };
6911
6922
  const LABEL_SIZE3 = 40;
6923
+ const RUN_ABOVE = Math.ceil(LABEL_SIZE3 * 0.969);
6924
+ const RUN_BELOW = Math.ceil(LABEL_SIZE3 * 0.242);
6925
+ const runBox = (left, by, w) => ({
6926
+ x: left,
6927
+ y: by - RUN_ABOVE,
6928
+ w,
6929
+ h: RUN_ABOVE + RUN_BELOW
6930
+ });
6912
6931
  const runW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
6913
6932
  const catW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
6914
6933
  const nameW = (s) => textWidth(s, LABEL_SIZE3, 500, 0, false, face);
@@ -6923,17 +6942,47 @@ var lineChart = (beat2, ctx) => {
6923
6942
  const dots = p.points.map(
6924
6943
  (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}" />`
6925
6944
  ).join("");
6945
+ const labelBox = (cx, cy, text2, anchorStart = false) => {
6946
+ const w = runW(text2);
6947
+ return runBox(anchorStart ? cx : cx - w / 2, cy, w);
6948
+ };
6949
+ const curveBand = (pts, x0, x1) => {
6950
+ const ys = [];
6951
+ const py = (i) => y(pts[i]?.y ?? 0);
6952
+ for (let i = 0; i < pts.length; i++) {
6953
+ if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
6954
+ }
6955
+ for (let i = 0; i + 1 < pts.length; i++) {
6956
+ const [ax, bx] = [x(i), x(i + 1)];
6957
+ for (const edge of [x0, x1]) {
6958
+ if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
6959
+ ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
6960
+ }
6961
+ }
6962
+ }
6963
+ return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
6964
+ };
6965
+ const drawnThrough = (series, b) => {
6966
+ const [top, bottom] = curveBand(series, b.x, b.x + b.w);
6967
+ return Math.min(b.y + b.h, bottom) - Math.max(b.y, top) > 8;
6968
+ };
6969
+ const valueBy = (i) => {
6970
+ const v = p.points[i]?.y ?? 0;
6971
+ const over = Math.max(44, y(v) - 26);
6972
+ if (!cmp) return over;
6973
+ const box2 = (by) => labelBox(x(i), by, String(v), i === 0);
6974
+ if (!drawnThrough(cmp.points, box2(over))) return over;
6975
+ const under = y(v) + 16 + RUN_ABOVE;
6976
+ if (under > H - PAD2.b + 4) return over;
6977
+ return drawnThrough(cmp.points, box2(under)) || drawnThrough(p.points, box2(under)) ? over : under;
6978
+ };
6926
6979
  const shownValues = fitIndices((i) => runW(String(p.points[i]?.y ?? "")), true);
6927
6980
  const showsValue = (i) => shownValues.has(i);
6928
6981
  const values = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => {
6929
6982
  const anchor = i === 0 ? ' text-anchor="start"' : "";
6930
- return `<text class="pv"${anchor} x="${n2(x(i))}" y="${n2(Math.max(44, y(pt.y) - 26))}">${esc(String(pt.y))}</text>`;
6983
+ return `<text class="pv"${anchor} x="${n2(x(i))}" y="${n2(valueBy(i))}">${esc(String(pt.y))}</text>`;
6931
6984
  }).join("");
6932
- const labelBox = (cx, cy, text2, anchorStart = false) => {
6933
- const w = runW(text2);
6934
- return { x: anchorStart ? cx : cx - w / 2, y: cy - LABEL_SIZE3, w, h: LABEL_SIZE3 };
6935
- };
6936
- 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));
6985
+ const valueBoxes = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => labelBox(x(i), valueBy(i), String(pt.y), i === 0));
6937
6986
  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;
6938
6987
  const deltaBoxes = (p.deltas ?? []).slice(0, p.points.length - 1).map((d, i) => {
6939
6988
  const a = p.points[i];
@@ -6961,40 +7010,22 @@ var lineChart = (beat2, ctx) => {
6961
7010
  lineTag("line", basePath, ' opacity="0"'),
6962
7011
  `<path id="${sid}-target" d="${path2}" fill="none" stroke="none" />`
6963
7012
  ].join("\n ") : lineTag("line", path2);
6964
- const curveBand = (pts, x0, x1) => {
6965
- const ys = [];
6966
- const py = (i) => y(pts[i]?.y ?? 0);
6967
- for (let i = 0; i < pts.length; i++) {
6968
- if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
6969
- }
6970
- for (let i = 0; i + 1 < pts.length; i++) {
6971
- const [ax, bx] = [x(i), x(i + 1)];
6972
- for (const edge of [x0, x1]) {
6973
- if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
6974
- ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
6975
- }
6976
- }
6977
- }
6978
- return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
6979
- };
6980
7013
  const fixedBoxes = [
6981
7014
  // `.axname` is the one run here set at weight 500 rather than 400, and
6982
7015
  // under-charging a width is the unrecoverable direction (see `padR`).
6983
7016
  // No `text-anchor` on the y half, so it runs rightwards from x=0.
6984
- { x: 0, y: 42 - LABEL_SIZE3, w: nameW(p.yLabel), h: LABEL_SIZE3 },
6985
- {
6986
- x: PAD2.l + plotW / 2 - nameW(p.xLabel) / 2,
6987
- y: H - 16 - LABEL_SIZE3,
6988
- w: nameW(p.xLabel),
6989
- h: LABEL_SIZE3
6990
- },
7017
+ runBox(0, 42, nameW(p.yLabel)),
7018
+ runBox(PAD2.l + plotW / 2 - nameW(p.xLabel) / 2, H - 16, nameW(p.xLabel)),
6991
7019
  ...ticks.map((v) => {
6992
7020
  const w = runW(v.toFixed(scale.decimals));
6993
- return { x: PAD2.l - 22 - w, y: y(v) + 13 - LABEL_SIZE3, w, h: LABEL_SIZE3 };
7021
+ return runBox(PAD2.l - 22 - w, y(v) + 13, w);
6994
7022
  }),
6995
7023
  ...p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => shownX.has(i)).map(({ pt, i }) => labelBox(x(i), H - PAD2.b + 56, pt.x))
6996
7024
  ];
6997
7025
  const lastI = p.points.length - 1;
7026
+ const LINE = RUN_ABOVE + RUN_BELOW;
7027
+ const SWATCH = 48;
7028
+ const SWATCH_GAP = 16;
6998
7029
  const ghost = (() => {
6999
7030
  if (!cmp) return void 0;
7000
7031
  const w = ghostW(cmp.label);
@@ -7003,6 +7034,18 @@ var lineChart = (beat2, ctx) => {
7003
7034
  `line-chart ${beat2.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.`
7004
7035
  );
7005
7036
  }
7037
+ const drawnDeltas = deltasFit ? deltaBoxes : [];
7038
+ const gapTo = (series, b) => {
7039
+ const [top, bottom] = curveBand(series, b.x, b.x + b.w);
7040
+ return Math.max(0, top - (b.y + b.h), b.y - bottom);
7041
+ };
7042
+ const clearOfText = (b) => !fixedBoxes.some((f) => overlaps(b, f)) && !valueBoxes.some((v) => overlaps(b, v)) && !drawnDeltas.some((d) => d !== null && overlaps(b, d));
7043
+ const stacked = (b) => valueBoxes.some(
7044
+ (v) => Math.min(b.x + b.w, v.x + v.w) - Math.max(b.x, v.x) > 0 && Math.max(v.y - (b.y + b.h), b.y - (v.y + v.h)) < LINE
7045
+ );
7046
+ const crossed = (b) => [p.points, cmp.points].some((series) => drawnThrough(series, b));
7047
+ const owned = (b) => gapTo(p.points, b) - gapTo(cmp.points, b) >= LINE;
7048
+ const honest = (b) => clearOfText(b) && !stacked(b) && !crossed(b) && owned(b);
7006
7049
  const atEnd = (i, start) => {
7007
7050
  const cy = y(cmp.points[i]?.y ?? 0);
7008
7051
  const away = Math.max(44, cy - 26);
@@ -7010,20 +7053,42 @@ var lineChart = (beat2, ctx) => {
7010
7053
  const sides = cy < y(p.points[i]?.y ?? 0) ? [away, toward] : [toward, away];
7011
7054
  return sides.map((by) => ({ i, start, by }));
7012
7055
  };
7013
- const drawnDeltas = deltasFit ? deltaBoxes : [];
7014
- return [...atEnd(lastI, false), ...atEnd(0, true)].find((c) => {
7015
- const box2 = { x: c.start ? x(c.i) : x(c.i) - w, y: c.by - LABEL_SIZE3, w, h: LABEL_SIZE3 };
7016
- if (fixedBoxes.some((f) => overlaps(box2, f))) return false;
7017
- if (valueBoxes.some((v) => overlaps(box2, v))) return false;
7018
- if (drawnDeltas.some((d) => d !== null && overlaps(box2, d))) return false;
7019
- return ![p.points, cmp.points].some((series) => {
7020
- const [top, bottom] = curveBand(series, box2.x, box2.x + box2.w);
7021
- return Math.min(box2.y + box2.h, bottom) - Math.max(box2.y, top) > 8;
7022
- });
7056
+ const end2 = [...atEnd(lastI, false), ...atEnd(0, true)].find(
7057
+ (c) => honest(runBox(c.start ? x(c.i) : x(c.i) - w, c.by, w))
7058
+ );
7059
+ if (end2) {
7060
+ return { kind: "label", anchor: end2.start ? "start" : "end", x: x(end2.i), by: end2.by };
7061
+ }
7062
+ const yAt = (series, at2) => curveBand(series, at2, at2)[0];
7063
+ const sep4 = (at2) => yAt(p.points, at2) - yAt(cmp.points, at2);
7064
+ const centres = p.points.flatMap(
7065
+ (_, i) => i < lastI ? [x(i), (x(i) + x(i + 1)) / 2] : [x(i)]
7066
+ );
7067
+ const along = centres.map((cx) => {
7068
+ const left = Math.min(Math.max(cx - w / 2, PAD2.l), width - padR - w);
7069
+ const side = Math.sign(sep4(left + w / 2));
7070
+ const cols = [
7071
+ left,
7072
+ left + w,
7073
+ ...p.points.map((_, i) => x(i)).filter((v) => v > left && v < left + w)
7074
+ ];
7075
+ const narrowest = Math.min(...cols.map((c) => side * sep4(c)));
7076
+ const [top, bottom] = curveBand(cmp.points, left, left + w);
7077
+ const by = side > 0 ? top - 16 - RUN_BELOW : bottom + 16 + RUN_ABOVE;
7078
+ return { left, by, narrowest, side };
7079
+ }).filter((c) => c.side !== 0 && c.by >= 44 && c.by <= H - PAD2.b + 4).sort((a, b) => b.narrowest - a.narrowest).find((c) => honest(runBox(c.left, c.by, w)));
7080
+ if (along) return { kind: "label", anchor: "middle", x: along.left + w / 2, by: along.by };
7081
+ const kw = SWATCH + SWATCH_GAP + w;
7082
+ const right = width - padR;
7083
+ const key = [42, H - 16].find((by) => {
7084
+ const b = runBox(right - kw, by, kw);
7085
+ return clearOfText(b) && !stacked(b) && !crossed(b);
7023
7086
  });
7087
+ return key === void 0 ? void 0 : { kind: "key", left: right - kw, right, by: key };
7024
7088
  })();
7025
- const ghostLabel = cmp && ghost ? `
7026
- <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>` : "";
7089
+ const ghostLabel = !cmp || !ghost ? "" : ghost.kind === "label" ? `
7090
+ <text class="ghostlab" id="${sid}-ghostlab" text-anchor="${ghost.anchor}" x="${n2(ghost.x)}" y="${n2(ghost.by)}">${esc(cmp.label)}</text>` : `
7091
+ <g class="ghostkey" id="${sid}-ghostlab"><line x1="${n2(ghost.left)}" y1="${n2(ghost.by - 14)}" x2="${n2(ghost.left + SWATCH)}" y2="${n2(ghost.by - 14)}" stroke="${theme.dim}" stroke-width="5" stroke-linecap="round" /><text class="ghostlab" text-anchor="end" x="${n2(ghost.right)}" y="${n2(ghost.by)}">${esc(cmp.label)}</text></g>`;
7027
7092
  const readout = p.readout ? `
7028
7093
  <div class="readout" id="${sid}-read">${esc(p.readout)}</div>` : "";
7029
7094
  const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
@@ -7085,7 +7150,12 @@ var lineChart = (beat2, ctx) => {
7085
7150
  // The copy lifts off — same geometry, so the half-second reads as one line
7086
7151
  // separating from itself rather than as a second line arriving.
7087
7152
  tween(`#${sid}-line`, { opacity: 0 }, { opacity: 1, duration: SEPARATE }, lift2),
7088
- tween(`#${sid}-base`, { opacity: 1 }, { opacity: GHOST_OPACITY, duration: SEPARATE }, lift2),
7153
+ tween(
7154
+ `#${sid}-base`,
7155
+ { stroke: theme.accent },
7156
+ { stroke: theme.dim, duration: SEPARATE },
7157
+ lift2
7158
+ ),
7089
7159
  reshape(`#${sid}-line`, `#${sid}-target`, lift2 + SEPARATE, RESHAPE_SECONDS, true)
7090
7160
  );
7091
7161
  }
@@ -11628,8 +11698,11 @@ function closeSafe(code) {
11628
11698
  }
11629
11699
  function sceneHtml(sid, start, clip3, label, scene, transit) {
11630
11700
  const setup = [...mathSetup(sid, scene.html), ...scene.setup ?? []].map(statement).join("\n ");
11701
+ const measure4 = (scene.measure ?? []).map(statement);
11702
+ const laps = lapSetup(sid, setup + measure4.join(""));
11631
11703
  const build = (pad) => [
11632
- ...(scene.measure ?? []).map(statement),
11704
+ ...measure4,
11705
+ ...laps,
11633
11706
  `window.__timelines = window.__timelines || {};`,
11634
11707
  `// Each scene owns a paused timeline under its own composition id, with`,
11635
11708
  `// times relative to its start \u2014 one absolute root timeline yields a`,
@@ -11674,6 +11747,15 @@ function mathSetup(sid, html) {
11674
11747
  })`
11675
11748
  ];
11676
11749
  }
11750
+ function lapSetup(sid, script) {
11751
+ if (!script.includes("katex.render")) return [];
11752
+ const laps = [".rlap", ".llap", ".clap"].map((c) => `#${sid} .katex ${c} *`).join(", ");
11753
+ return [
11754
+ statement(`Array.prototype.forEach.call(document.querySelectorAll('${laps}'), function (el) {
11755
+ el.setAttribute("data-layout-allow-overlap", "");
11756
+ })`)
11757
+ ];
11758
+ }
11677
11759
  function statement(s) {
11678
11760
  const line2 = s.trim();
11679
11761
  return line2.endsWith(";") ? line2 : `${line2};`;
@@ -18016,7 +18098,8 @@ var FONTSOURCE = {
18016
18098
  async function vendorFace(face, dir) {
18017
18099
  return vendorFontsource(FONTSOURCE[face], familyOf(face), dir);
18018
18100
  }
18019
- var PACK_DISPLAY = "font-display: block;\n ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
18101
+ var PACK_DISPLAY = `font-display: block;
18102
+ ${INTER_METRICS}`;
18020
18103
  async function vendorFontsource(pkgName, family, dir) {
18021
18104
  const pkg = dirname(createRequire2(import.meta.url).resolve(pkgName));
18022
18105
  const css = await readFile11(join11(pkg, "index.css"), "utf8");
@@ -21697,12 +21780,13 @@ function scanPaperArc(storyboard, prefs) {
21697
21780
  }));
21698
21781
  }
21699
21782
  function scanNarrationDrift(storyboard, narration) {
21700
- const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
21783
+ const sentences = (s) => splitSentences((s ?? "").replace(/\s+/g, " ").trim()).join("\n");
21701
21784
  const stale = [];
21702
21785
  for (const beat2 of storyboard.beats) {
21703
21786
  const segments2 = narration.beats[beat2.id];
21704
- if (!segments2?.length || !flat(beat2.narration)) continue;
21705
- if (flat(segments2.map((s) => s.text).join(" ")) !== flat(beat2.narration)) stale.push(beat2.id);
21787
+ const said = sentences(beat2.narration);
21788
+ if (!segments2?.length || !said) continue;
21789
+ if (sentences(segments2.map((s) => s.text).join(" ")) !== said) stale.push(beat2.id);
21706
21790
  }
21707
21791
  if (!stale.length) return [];
21708
21792
  return [
package/dist/index.js CHANGED
@@ -1811,7 +1811,11 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1811
1811
  await mkdir3(dir, { recursive: true });
1812
1812
  const cssPath = join3(dir, "fonts.css");
1813
1813
  const cached2 = await readFile3(cssPath, "utf8").catch(() => "");
1814
- if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1814
+ if (cached2.startsWith(stamp)) {
1815
+ const css2 = withInterMetrics(cached2);
1816
+ if (css2 !== cached2) await writeFile3(cssPath, css2);
1817
+ return { family, css: css2, files: localNames(css2) };
1818
+ }
1815
1819
  const res = await fetch(
1816
1820
  `https://fonts.googleapis.com/css2?${families.map((f) => `family=${f.replaceAll(" ", "+")}:wght@400;500;700`).join("&")}&text=${encodeURIComponent(text2)}&display=block`,
1817
1821
  { headers: { "User-Agent": UA } }
@@ -1829,11 +1833,19 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1829
1833
  css = css.replaceAll(url, name);
1830
1834
  files.push(name);
1831
1835
  }
1832
- css = `${stamp}
1833
- ${css}`;
1836
+ css = withInterMetrics(`${stamp}
1837
+ ${css}`);
1834
1838
  await writeFile3(cssPath, css);
1835
1839
  return { family, css, files };
1836
1840
  }
1841
+ var INTER_METRICS = "ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
1842
+ function withInterMetrics(css) {
1843
+ return css.replace(
1844
+ /@font-face\s*\{(?![^}]*ascent-override)/g,
1845
+ `@font-face {
1846
+ ${INTER_METRICS};`
1847
+ );
1848
+ }
1837
1849
  function localNames(css) {
1838
1850
  return [...css.matchAll(/url\(([^)]+)\)/g)].map((m) => m[1] ?? "");
1839
1851
  }
@@ -2321,7 +2333,8 @@ var FONTSOURCE = {
2321
2333
  async function vendorFace(face, dir) {
2322
2334
  return vendorFontsource(FONTSOURCE[face], familyOf(face), dir);
2323
2335
  }
2324
- var PACK_DISPLAY = "font-display: block;\n ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
2336
+ var PACK_DISPLAY = `font-display: block;
2337
+ ${INTER_METRICS}`;
2325
2338
  async function vendorFontsource(pkgName, family, dir) {
2326
2339
  const pkg = dirname(createRequire(import.meta.url).resolve(pkgName));
2327
2340
  const css = await readFile4(join4(pkg, "index.css"), "utf8");
@@ -6992,7 +7005,6 @@ var READOUT_W = 460;
6992
7005
  var READOUT_LH = 1.35;
6993
7006
  var TALL_ASPECT = 1.15;
6994
7007
  var RESHAPE_SECONDS = 1.2;
6995
- var GHOST_OPACITY = 0.28;
6996
7008
  var DRAW_SECONDS = 1.8;
6997
7009
  var DRAW_FLOOR = 1;
6998
7010
  var SEPARATE = 0.5;
@@ -7069,6 +7081,14 @@ var lineChart = (beat2, ctx) => {
7069
7081
  return new Set(kept);
7070
7082
  };
7071
7083
  const LABEL_SIZE3 = 40;
7084
+ const RUN_ABOVE = Math.ceil(LABEL_SIZE3 * 0.969);
7085
+ const RUN_BELOW = Math.ceil(LABEL_SIZE3 * 0.242);
7086
+ const runBox = (left, by, w) => ({
7087
+ x: left,
7088
+ y: by - RUN_ABOVE,
7089
+ w,
7090
+ h: RUN_ABOVE + RUN_BELOW
7091
+ });
7072
7092
  const runW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
7073
7093
  const catW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
7074
7094
  const nameW = (s) => textWidth(s, LABEL_SIZE3, 500, 0, false, face);
@@ -7083,17 +7103,47 @@ var lineChart = (beat2, ctx) => {
7083
7103
  const dots = p.points.map(
7084
7104
  (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}" />`
7085
7105
  ).join("");
7106
+ const labelBox = (cx, cy, text2, anchorStart = false) => {
7107
+ const w = runW(text2);
7108
+ return runBox(anchorStart ? cx : cx - w / 2, cy, w);
7109
+ };
7110
+ const curveBand = (pts, x0, x1) => {
7111
+ const ys = [];
7112
+ const py = (i) => y(pts[i]?.y ?? 0);
7113
+ for (let i = 0; i < pts.length; i++) {
7114
+ if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
7115
+ }
7116
+ for (let i = 0; i + 1 < pts.length; i++) {
7117
+ const [ax, bx] = [x(i), x(i + 1)];
7118
+ for (const edge of [x0, x1]) {
7119
+ if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
7120
+ ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
7121
+ }
7122
+ }
7123
+ }
7124
+ return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
7125
+ };
7126
+ const drawnThrough = (series, b) => {
7127
+ const [top, bottom] = curveBand(series, b.x, b.x + b.w);
7128
+ return Math.min(b.y + b.h, bottom) - Math.max(b.y, top) > 8;
7129
+ };
7130
+ const valueBy = (i) => {
7131
+ const v = p.points[i]?.y ?? 0;
7132
+ const over = Math.max(44, y(v) - 26);
7133
+ if (!cmp) return over;
7134
+ const box2 = (by) => labelBox(x(i), by, String(v), i === 0);
7135
+ if (!drawnThrough(cmp.points, box2(over))) return over;
7136
+ const under = y(v) + 16 + RUN_ABOVE;
7137
+ if (under > H - PAD2.b + 4) return over;
7138
+ return drawnThrough(cmp.points, box2(under)) || drawnThrough(p.points, box2(under)) ? over : under;
7139
+ };
7086
7140
  const shownValues = fitIndices((i) => runW(String(p.points[i]?.y ?? "")), true);
7087
7141
  const showsValue = (i) => shownValues.has(i);
7088
7142
  const values = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => {
7089
7143
  const anchor = i === 0 ? ' text-anchor="start"' : "";
7090
- return `<text class="pv"${anchor} x="${n2(x(i))}" y="${n2(Math.max(44, y(pt.y) - 26))}">${esc(String(pt.y))}</text>`;
7144
+ return `<text class="pv"${anchor} x="${n2(x(i))}" y="${n2(valueBy(i))}">${esc(String(pt.y))}</text>`;
7091
7145
  }).join("");
7092
- const labelBox = (cx, cy, text2, anchorStart = false) => {
7093
- const w = runW(text2);
7094
- return { x: anchorStart ? cx : cx - w / 2, y: cy - LABEL_SIZE3, w, h: LABEL_SIZE3 };
7095
- };
7096
- 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));
7146
+ const valueBoxes = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => labelBox(x(i), valueBy(i), String(pt.y), i === 0));
7097
7147
  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;
7098
7148
  const deltaBoxes = (p.deltas ?? []).slice(0, p.points.length - 1).map((d, i) => {
7099
7149
  const a = p.points[i];
@@ -7121,40 +7171,22 @@ var lineChart = (beat2, ctx) => {
7121
7171
  lineTag("line", basePath, ' opacity="0"'),
7122
7172
  `<path id="${sid}-target" d="${path2}" fill="none" stroke="none" />`
7123
7173
  ].join("\n ") : lineTag("line", path2);
7124
- const curveBand = (pts, x0, x1) => {
7125
- const ys = [];
7126
- const py = (i) => y(pts[i]?.y ?? 0);
7127
- for (let i = 0; i < pts.length; i++) {
7128
- if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
7129
- }
7130
- for (let i = 0; i + 1 < pts.length; i++) {
7131
- const [ax, bx] = [x(i), x(i + 1)];
7132
- for (const edge of [x0, x1]) {
7133
- if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
7134
- ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
7135
- }
7136
- }
7137
- }
7138
- return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
7139
- };
7140
7174
  const fixedBoxes = [
7141
7175
  // `.axname` is the one run here set at weight 500 rather than 400, and
7142
7176
  // under-charging a width is the unrecoverable direction (see `padR`).
7143
7177
  // No `text-anchor` on the y half, so it runs rightwards from x=0.
7144
- { x: 0, y: 42 - LABEL_SIZE3, w: nameW(p.yLabel), h: LABEL_SIZE3 },
7145
- {
7146
- x: PAD2.l + plotW / 2 - nameW(p.xLabel) / 2,
7147
- y: H - 16 - LABEL_SIZE3,
7148
- w: nameW(p.xLabel),
7149
- h: LABEL_SIZE3
7150
- },
7178
+ runBox(0, 42, nameW(p.yLabel)),
7179
+ runBox(PAD2.l + plotW / 2 - nameW(p.xLabel) / 2, H - 16, nameW(p.xLabel)),
7151
7180
  ...ticks.map((v) => {
7152
7181
  const w = runW(v.toFixed(scale.decimals));
7153
- return { x: PAD2.l - 22 - w, y: y(v) + 13 - LABEL_SIZE3, w, h: LABEL_SIZE3 };
7182
+ return runBox(PAD2.l - 22 - w, y(v) + 13, w);
7154
7183
  }),
7155
7184
  ...p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => shownX.has(i)).map(({ pt, i }) => labelBox(x(i), H - PAD2.b + 56, pt.x))
7156
7185
  ];
7157
7186
  const lastI = p.points.length - 1;
7187
+ const LINE = RUN_ABOVE + RUN_BELOW;
7188
+ const SWATCH = 48;
7189
+ const SWATCH_GAP = 16;
7158
7190
  const ghost = (() => {
7159
7191
  if (!cmp) return void 0;
7160
7192
  const w = ghostW(cmp.label);
@@ -7163,6 +7195,18 @@ var lineChart = (beat2, ctx) => {
7163
7195
  `line-chart ${beat2.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.`
7164
7196
  );
7165
7197
  }
7198
+ const drawnDeltas = deltasFit ? deltaBoxes : [];
7199
+ const gapTo = (series, b) => {
7200
+ const [top, bottom] = curveBand(series, b.x, b.x + b.w);
7201
+ return Math.max(0, top - (b.y + b.h), b.y - bottom);
7202
+ };
7203
+ const clearOfText = (b) => !fixedBoxes.some((f) => overlaps(b, f)) && !valueBoxes.some((v) => overlaps(b, v)) && !drawnDeltas.some((d) => d !== null && overlaps(b, d));
7204
+ const stacked = (b) => valueBoxes.some(
7205
+ (v) => Math.min(b.x + b.w, v.x + v.w) - Math.max(b.x, v.x) > 0 && Math.max(v.y - (b.y + b.h), b.y - (v.y + v.h)) < LINE
7206
+ );
7207
+ const crossed = (b) => [p.points, cmp.points].some((series) => drawnThrough(series, b));
7208
+ const owned = (b) => gapTo(p.points, b) - gapTo(cmp.points, b) >= LINE;
7209
+ const honest = (b) => clearOfText(b) && !stacked(b) && !crossed(b) && owned(b);
7166
7210
  const atEnd = (i, start) => {
7167
7211
  const cy = y(cmp.points[i]?.y ?? 0);
7168
7212
  const away = Math.max(44, cy - 26);
@@ -7170,20 +7214,42 @@ var lineChart = (beat2, ctx) => {
7170
7214
  const sides = cy < y(p.points[i]?.y ?? 0) ? [away, toward] : [toward, away];
7171
7215
  return sides.map((by) => ({ i, start, by }));
7172
7216
  };
7173
- const drawnDeltas = deltasFit ? deltaBoxes : [];
7174
- return [...atEnd(lastI, false), ...atEnd(0, true)].find((c) => {
7175
- const box2 = { x: c.start ? x(c.i) : x(c.i) - w, y: c.by - LABEL_SIZE3, w, h: LABEL_SIZE3 };
7176
- if (fixedBoxes.some((f) => overlaps(box2, f))) return false;
7177
- if (valueBoxes.some((v) => overlaps(box2, v))) return false;
7178
- if (drawnDeltas.some((d) => d !== null && overlaps(box2, d))) return false;
7179
- return ![p.points, cmp.points].some((series) => {
7180
- const [top, bottom] = curveBand(series, box2.x, box2.x + box2.w);
7181
- return Math.min(box2.y + box2.h, bottom) - Math.max(box2.y, top) > 8;
7182
- });
7217
+ const end2 = [...atEnd(lastI, false), ...atEnd(0, true)].find(
7218
+ (c) => honest(runBox(c.start ? x(c.i) : x(c.i) - w, c.by, w))
7219
+ );
7220
+ if (end2) {
7221
+ return { kind: "label", anchor: end2.start ? "start" : "end", x: x(end2.i), by: end2.by };
7222
+ }
7223
+ const yAt = (series, at2) => curveBand(series, at2, at2)[0];
7224
+ const sep4 = (at2) => yAt(p.points, at2) - yAt(cmp.points, at2);
7225
+ const centres = p.points.flatMap(
7226
+ (_, i) => i < lastI ? [x(i), (x(i) + x(i + 1)) / 2] : [x(i)]
7227
+ );
7228
+ const along = centres.map((cx) => {
7229
+ const left = Math.min(Math.max(cx - w / 2, PAD2.l), width - padR - w);
7230
+ const side = Math.sign(sep4(left + w / 2));
7231
+ const cols = [
7232
+ left,
7233
+ left + w,
7234
+ ...p.points.map((_, i) => x(i)).filter((v) => v > left && v < left + w)
7235
+ ];
7236
+ const narrowest = Math.min(...cols.map((c) => side * sep4(c)));
7237
+ const [top, bottom] = curveBand(cmp.points, left, left + w);
7238
+ const by = side > 0 ? top - 16 - RUN_BELOW : bottom + 16 + RUN_ABOVE;
7239
+ return { left, by, narrowest, side };
7240
+ }).filter((c) => c.side !== 0 && c.by >= 44 && c.by <= H - PAD2.b + 4).sort((a, b) => b.narrowest - a.narrowest).find((c) => honest(runBox(c.left, c.by, w)));
7241
+ if (along) return { kind: "label", anchor: "middle", x: along.left + w / 2, by: along.by };
7242
+ const kw = SWATCH + SWATCH_GAP + w;
7243
+ const right = width - padR;
7244
+ const key = [42, H - 16].find((by) => {
7245
+ const b = runBox(right - kw, by, kw);
7246
+ return clearOfText(b) && !stacked(b) && !crossed(b);
7183
7247
  });
7248
+ return key === void 0 ? void 0 : { kind: "key", left: right - kw, right, by: key };
7184
7249
  })();
7185
- const ghostLabel = cmp && ghost ? `
7186
- <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>` : "";
7250
+ const ghostLabel = !cmp || !ghost ? "" : ghost.kind === "label" ? `
7251
+ <text class="ghostlab" id="${sid}-ghostlab" text-anchor="${ghost.anchor}" x="${n2(ghost.x)}" y="${n2(ghost.by)}">${esc(cmp.label)}</text>` : `
7252
+ <g class="ghostkey" id="${sid}-ghostlab"><line x1="${n2(ghost.left)}" y1="${n2(ghost.by - 14)}" x2="${n2(ghost.left + SWATCH)}" y2="${n2(ghost.by - 14)}" stroke="${theme.dim}" stroke-width="5" stroke-linecap="round" /><text class="ghostlab" text-anchor="end" x="${n2(ghost.right)}" y="${n2(ghost.by)}">${esc(cmp.label)}</text></g>`;
7187
7253
  const readout = p.readout ? `
7188
7254
  <div class="readout" id="${sid}-read">${esc(p.readout)}</div>` : "";
7189
7255
  const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
@@ -7245,7 +7311,12 @@ var lineChart = (beat2, ctx) => {
7245
7311
  // The copy lifts off — same geometry, so the half-second reads as one line
7246
7312
  // separating from itself rather than as a second line arriving.
7247
7313
  tween(`#${sid}-line`, { opacity: 0 }, { opacity: 1, duration: SEPARATE }, lift2),
7248
- tween(`#${sid}-base`, { opacity: 1 }, { opacity: GHOST_OPACITY, duration: SEPARATE }, lift2),
7314
+ tween(
7315
+ `#${sid}-base`,
7316
+ { stroke: theme.accent },
7317
+ { stroke: theme.dim, duration: SEPARATE },
7318
+ lift2
7319
+ ),
7249
7320
  reshape(`#${sid}-line`, `#${sid}-target`, lift2 + SEPARATE, RESHAPE_SECONDS, true)
7250
7321
  );
7251
7322
  }
@@ -11801,8 +11872,11 @@ function closeSafe(code) {
11801
11872
  }
11802
11873
  function sceneHtml(sid, start, clip3, label, scene, transit) {
11803
11874
  const setup = [...mathSetup(sid, scene.html), ...scene.setup ?? []].map(statement).join("\n ");
11875
+ const measure4 = (scene.measure ?? []).map(statement);
11876
+ const laps = lapSetup(sid, setup + measure4.join(""));
11804
11877
  const build = (pad) => [
11805
- ...(scene.measure ?? []).map(statement),
11878
+ ...measure4,
11879
+ ...laps,
11806
11880
  `window.__timelines = window.__timelines || {};`,
11807
11881
  `// Each scene owns a paused timeline under its own composition id, with`,
11808
11882
  `// times relative to its start \u2014 one absolute root timeline yields a`,
@@ -11847,6 +11921,15 @@ function mathSetup(sid, html) {
11847
11921
  })`
11848
11922
  ];
11849
11923
  }
11924
+ function lapSetup(sid, script) {
11925
+ if (!script.includes("katex.render")) return [];
11926
+ const laps = [".rlap", ".llap", ".clap"].map((c) => `#${sid} .katex ${c} *`).join(", ");
11927
+ return [
11928
+ statement(`Array.prototype.forEach.call(document.querySelectorAll('${laps}'), function (el) {
11929
+ el.setAttribute("data-layout-allow-overlap", "");
11930
+ })`)
11931
+ ];
11932
+ }
11850
11933
  function statement(s) {
11851
11934
  const line2 = s.trim();
11852
11935
  return line2.endsWith(";") ? line2 : `${line2};`;
@@ -18762,12 +18845,13 @@ function scanPaperArc(storyboard, prefs) {
18762
18845
  }));
18763
18846
  }
18764
18847
  function scanNarrationDrift(storyboard, narration) {
18765
- const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
18848
+ const sentences = (s) => splitSentences((s ?? "").replace(/\s+/g, " ").trim()).join("\n");
18766
18849
  const stale = [];
18767
18850
  for (const beat2 of storyboard.beats) {
18768
18851
  const segments2 = narration.beats[beat2.id];
18769
- if (!segments2?.length || !flat(beat2.narration)) continue;
18770
- if (flat(segments2.map((s) => s.text).join(" ")) !== flat(beat2.narration)) stale.push(beat2.id);
18852
+ const said = sentences(beat2.narration);
18853
+ if (!segments2?.length || !said) continue;
18854
+ if (sentences(segments2.map((s) => s.text).join(" ")) !== said) stale.push(beat2.id);
18771
18855
  }
18772
18856
  if (!stale.length) return [];
18773
18857
  return [
package/dist/mcp.js CHANGED
@@ -1655,7 +1655,11 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1655
1655
  await mkdir2(dir, { recursive: true });
1656
1656
  const cssPath = join3(dir, "fonts.css");
1657
1657
  const cached2 = await readFile2(cssPath, "utf8").catch(() => "");
1658
- if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1658
+ if (cached2.startsWith(stamp)) {
1659
+ const css2 = withInterMetrics(cached2);
1660
+ if (css2 !== cached2) await writeFile2(cssPath, css2);
1661
+ return { family, css: css2, files: localNames(css2) };
1662
+ }
1659
1663
  const res = await fetch(
1660
1664
  `https://fonts.googleapis.com/css2?${families.map((f) => `family=${f.replaceAll(" ", "+")}:wght@400;500;700`).join("&")}&text=${encodeURIComponent(text2)}&display=block`,
1661
1665
  { headers: { "User-Agent": UA } }
@@ -1673,11 +1677,19 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1673
1677
  css = css.replaceAll(url, name);
1674
1678
  files.push(name);
1675
1679
  }
1676
- css = `${stamp}
1677
- ${css}`;
1680
+ css = withInterMetrics(`${stamp}
1681
+ ${css}`);
1678
1682
  await writeFile2(cssPath, css);
1679
1683
  return { family, css, files };
1680
1684
  }
1685
+ var INTER_METRICS = "ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
1686
+ function withInterMetrics(css) {
1687
+ return css.replace(
1688
+ /@font-face\s*\{(?![^}]*ascent-override)/g,
1689
+ `@font-face {
1690
+ ${INTER_METRICS};`
1691
+ );
1692
+ }
1681
1693
  function localNames(css) {
1682
1694
  return [...css.matchAll(/url\(([^)]+)\)/g)].map((m) => m[1] ?? "");
1683
1695
  }
@@ -2165,7 +2177,8 @@ var FONTSOURCE = {
2165
2177
  async function vendorFace(face, dir) {
2166
2178
  return vendorFontsource(FONTSOURCE[face], familyOf(face), dir);
2167
2179
  }
2168
- var PACK_DISPLAY = "font-display: block;\n ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
2180
+ var PACK_DISPLAY = `font-display: block;
2181
+ ${INTER_METRICS}`;
2169
2182
  async function vendorFontsource(pkgName, family, dir) {
2170
2183
  const pkg = dirname2(createRequire3(import.meta.url).resolve(pkgName));
2171
2184
  const css = await readFile3(join4(pkg, "index.css"), "utf8");
@@ -6036,7 +6049,6 @@ var READOUT_W = 460;
6036
6049
  var READOUT_LH = 1.35;
6037
6050
  var TALL_ASPECT = 1.15;
6038
6051
  var RESHAPE_SECONDS = 1.2;
6039
- var GHOST_OPACITY = 0.28;
6040
6052
  var DRAW_SECONDS = 1.8;
6041
6053
  var DRAW_FLOOR = 1;
6042
6054
  var SEPARATE = 0.5;
@@ -6113,6 +6125,14 @@ var lineChart = (beat, ctx) => {
6113
6125
  return new Set(kept);
6114
6126
  };
6115
6127
  const LABEL_SIZE3 = 40;
6128
+ const RUN_ABOVE = Math.ceil(LABEL_SIZE3 * 0.969);
6129
+ const RUN_BELOW = Math.ceil(LABEL_SIZE3 * 0.242);
6130
+ const runBox = (left, by, w) => ({
6131
+ x: left,
6132
+ y: by - RUN_ABOVE,
6133
+ w,
6134
+ h: RUN_ABOVE + RUN_BELOW
6135
+ });
6116
6136
  const runW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
6117
6137
  const catW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
6118
6138
  const nameW = (s) => textWidth(s, LABEL_SIZE3, 500, 0, false, face);
@@ -6127,17 +6147,47 @@ var lineChart = (beat, ctx) => {
6127
6147
  const dots = p.points.map(
6128
6148
  (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}" />`
6129
6149
  ).join("");
6150
+ const labelBox = (cx, cy, text2, anchorStart = false) => {
6151
+ const w = runW(text2);
6152
+ return runBox(anchorStart ? cx : cx - w / 2, cy, w);
6153
+ };
6154
+ const curveBand = (pts, x0, x1) => {
6155
+ const ys = [];
6156
+ const py = (i) => y(pts[i]?.y ?? 0);
6157
+ for (let i = 0; i < pts.length; i++) {
6158
+ if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
6159
+ }
6160
+ for (let i = 0; i + 1 < pts.length; i++) {
6161
+ const [ax, bx] = [x(i), x(i + 1)];
6162
+ for (const edge of [x0, x1]) {
6163
+ if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
6164
+ ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
6165
+ }
6166
+ }
6167
+ }
6168
+ return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
6169
+ };
6170
+ const drawnThrough = (series, b) => {
6171
+ const [top, bottom] = curveBand(series, b.x, b.x + b.w);
6172
+ return Math.min(b.y + b.h, bottom) - Math.max(b.y, top) > 8;
6173
+ };
6174
+ const valueBy = (i) => {
6175
+ const v = p.points[i]?.y ?? 0;
6176
+ const over = Math.max(44, y(v) - 26);
6177
+ if (!cmp) return over;
6178
+ const box2 = (by) => labelBox(x(i), by, String(v), i === 0);
6179
+ if (!drawnThrough(cmp.points, box2(over))) return over;
6180
+ const under = y(v) + 16 + RUN_ABOVE;
6181
+ if (under > H - PAD2.b + 4) return over;
6182
+ return drawnThrough(cmp.points, box2(under)) || drawnThrough(p.points, box2(under)) ? over : under;
6183
+ };
6130
6184
  const shownValues = fitIndices((i) => runW(String(p.points[i]?.y ?? "")), true);
6131
6185
  const showsValue = (i) => shownValues.has(i);
6132
6186
  const values = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => {
6133
6187
  const anchor = i === 0 ? ' text-anchor="start"' : "";
6134
- return `<text class="pv"${anchor} x="${n2(x(i))}" y="${n2(Math.max(44, y(pt.y) - 26))}">${esc(String(pt.y))}</text>`;
6188
+ return `<text class="pv"${anchor} x="${n2(x(i))}" y="${n2(valueBy(i))}">${esc(String(pt.y))}</text>`;
6135
6189
  }).join("");
6136
- const labelBox = (cx, cy, text2, anchorStart = false) => {
6137
- const w = runW(text2);
6138
- return { x: anchorStart ? cx : cx - w / 2, y: cy - LABEL_SIZE3, w, h: LABEL_SIZE3 };
6139
- };
6140
- 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));
6190
+ const valueBoxes = p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => showsValue(i)).map(({ pt, i }) => labelBox(x(i), valueBy(i), String(pt.y), i === 0));
6141
6191
  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;
6142
6192
  const deltaBoxes = (p.deltas ?? []).slice(0, p.points.length - 1).map((d, i) => {
6143
6193
  const a = p.points[i];
@@ -6165,40 +6215,22 @@ var lineChart = (beat, ctx) => {
6165
6215
  lineTag("line", basePath, ' opacity="0"'),
6166
6216
  `<path id="${sid}-target" d="${path2}" fill="none" stroke="none" />`
6167
6217
  ].join("\n ") : lineTag("line", path2);
6168
- const curveBand = (pts, x0, x1) => {
6169
- const ys = [];
6170
- const py = (i) => y(pts[i]?.y ?? 0);
6171
- for (let i = 0; i < pts.length; i++) {
6172
- if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
6173
- }
6174
- for (let i = 0; i + 1 < pts.length; i++) {
6175
- const [ax, bx] = [x(i), x(i + 1)];
6176
- for (const edge of [x0, x1]) {
6177
- if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
6178
- ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
6179
- }
6180
- }
6181
- }
6182
- return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
6183
- };
6184
6218
  const fixedBoxes = [
6185
6219
  // `.axname` is the one run here set at weight 500 rather than 400, and
6186
6220
  // under-charging a width is the unrecoverable direction (see `padR`).
6187
6221
  // No `text-anchor` on the y half, so it runs rightwards from x=0.
6188
- { x: 0, y: 42 - LABEL_SIZE3, w: nameW(p.yLabel), h: LABEL_SIZE3 },
6189
- {
6190
- x: PAD2.l + plotW / 2 - nameW(p.xLabel) / 2,
6191
- y: H - 16 - LABEL_SIZE3,
6192
- w: nameW(p.xLabel),
6193
- h: LABEL_SIZE3
6194
- },
6222
+ runBox(0, 42, nameW(p.yLabel)),
6223
+ runBox(PAD2.l + plotW / 2 - nameW(p.xLabel) / 2, H - 16, nameW(p.xLabel)),
6195
6224
  ...ticks.map((v) => {
6196
6225
  const w = runW(v.toFixed(scale.decimals));
6197
- return { x: PAD2.l - 22 - w, y: y(v) + 13 - LABEL_SIZE3, w, h: LABEL_SIZE3 };
6226
+ return runBox(PAD2.l - 22 - w, y(v) + 13, w);
6198
6227
  }),
6199
6228
  ...p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => shownX.has(i)).map(({ pt, i }) => labelBox(x(i), H - PAD2.b + 56, pt.x))
6200
6229
  ];
6201
6230
  const lastI = p.points.length - 1;
6231
+ const LINE = RUN_ABOVE + RUN_BELOW;
6232
+ const SWATCH = 48;
6233
+ const SWATCH_GAP = 16;
6202
6234
  const ghost = (() => {
6203
6235
  if (!cmp) return void 0;
6204
6236
  const w = ghostW(cmp.label);
@@ -6207,6 +6239,18 @@ var lineChart = (beat, ctx) => {
6207
6239
  `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.`
6208
6240
  );
6209
6241
  }
6242
+ const drawnDeltas = deltasFit ? deltaBoxes : [];
6243
+ const gapTo = (series, b) => {
6244
+ const [top, bottom] = curveBand(series, b.x, b.x + b.w);
6245
+ return Math.max(0, top - (b.y + b.h), b.y - bottom);
6246
+ };
6247
+ const clearOfText = (b) => !fixedBoxes.some((f) => overlaps(b, f)) && !valueBoxes.some((v) => overlaps(b, v)) && !drawnDeltas.some((d) => d !== null && overlaps(b, d));
6248
+ const stacked = (b) => valueBoxes.some(
6249
+ (v) => Math.min(b.x + b.w, v.x + v.w) - Math.max(b.x, v.x) > 0 && Math.max(v.y - (b.y + b.h), b.y - (v.y + v.h)) < LINE
6250
+ );
6251
+ const crossed = (b) => [p.points, cmp.points].some((series) => drawnThrough(series, b));
6252
+ const owned = (b) => gapTo(p.points, b) - gapTo(cmp.points, b) >= LINE;
6253
+ const honest = (b) => clearOfText(b) && !stacked(b) && !crossed(b) && owned(b);
6210
6254
  const atEnd = (i, start) => {
6211
6255
  const cy = y(cmp.points[i]?.y ?? 0);
6212
6256
  const away = Math.max(44, cy - 26);
@@ -6214,20 +6258,42 @@ var lineChart = (beat, ctx) => {
6214
6258
  const sides = cy < y(p.points[i]?.y ?? 0) ? [away, toward] : [toward, away];
6215
6259
  return sides.map((by) => ({ i, start, by }));
6216
6260
  };
6217
- const drawnDeltas = deltasFit ? deltaBoxes : [];
6218
- return [...atEnd(lastI, false), ...atEnd(0, true)].find((c) => {
6219
- const box2 = { x: c.start ? x(c.i) : x(c.i) - w, y: c.by - LABEL_SIZE3, w, h: LABEL_SIZE3 };
6220
- if (fixedBoxes.some((f) => overlaps(box2, f))) return false;
6221
- if (valueBoxes.some((v) => overlaps(box2, v))) return false;
6222
- if (drawnDeltas.some((d) => d !== null && overlaps(box2, d))) return false;
6223
- return ![p.points, cmp.points].some((series) => {
6224
- const [top, bottom] = curveBand(series, box2.x, box2.x + box2.w);
6225
- return Math.min(box2.y + box2.h, bottom) - Math.max(box2.y, top) > 8;
6226
- });
6261
+ const end2 = [...atEnd(lastI, false), ...atEnd(0, true)].find(
6262
+ (c) => honest(runBox(c.start ? x(c.i) : x(c.i) - w, c.by, w))
6263
+ );
6264
+ if (end2) {
6265
+ return { kind: "label", anchor: end2.start ? "start" : "end", x: x(end2.i), by: end2.by };
6266
+ }
6267
+ const yAt = (series, at2) => curveBand(series, at2, at2)[0];
6268
+ const sep5 = (at2) => yAt(p.points, at2) - yAt(cmp.points, at2);
6269
+ const centres = p.points.flatMap(
6270
+ (_, i) => i < lastI ? [x(i), (x(i) + x(i + 1)) / 2] : [x(i)]
6271
+ );
6272
+ const along = centres.map((cx) => {
6273
+ const left = Math.min(Math.max(cx - w / 2, PAD2.l), width - padR - w);
6274
+ const side = Math.sign(sep5(left + w / 2));
6275
+ const cols = [
6276
+ left,
6277
+ left + w,
6278
+ ...p.points.map((_, i) => x(i)).filter((v) => v > left && v < left + w)
6279
+ ];
6280
+ const narrowest = Math.min(...cols.map((c) => side * sep5(c)));
6281
+ const [top, bottom] = curveBand(cmp.points, left, left + w);
6282
+ const by = side > 0 ? top - 16 - RUN_BELOW : bottom + 16 + RUN_ABOVE;
6283
+ return { left, by, narrowest, side };
6284
+ }).filter((c) => c.side !== 0 && c.by >= 44 && c.by <= H - PAD2.b + 4).sort((a, b) => b.narrowest - a.narrowest).find((c) => honest(runBox(c.left, c.by, w)));
6285
+ if (along) return { kind: "label", anchor: "middle", x: along.left + w / 2, by: along.by };
6286
+ const kw = SWATCH + SWATCH_GAP + w;
6287
+ const right = width - padR;
6288
+ const key = [42, H - 16].find((by) => {
6289
+ const b = runBox(right - kw, by, kw);
6290
+ return clearOfText(b) && !stacked(b) && !crossed(b);
6227
6291
  });
6292
+ return key === void 0 ? void 0 : { kind: "key", left: right - kw, right, by: key };
6228
6293
  })();
6229
- const ghostLabel = cmp && ghost ? `
6230
- <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>` : "";
6294
+ const ghostLabel = !cmp || !ghost ? "" : ghost.kind === "label" ? `
6295
+ <text class="ghostlab" id="${sid}-ghostlab" text-anchor="${ghost.anchor}" x="${n2(ghost.x)}" y="${n2(ghost.by)}">${esc(cmp.label)}</text>` : `
6296
+ <g class="ghostkey" id="${sid}-ghostlab"><line x1="${n2(ghost.left)}" y1="${n2(ghost.by - 14)}" x2="${n2(ghost.left + SWATCH)}" y2="${n2(ghost.by - 14)}" stroke="${theme.dim}" stroke-width="5" stroke-linecap="round" /><text class="ghostlab" text-anchor="end" x="${n2(ghost.right)}" y="${n2(ghost.by)}">${esc(cmp.label)}</text></g>`;
6231
6297
  const readout = p.readout ? `
6232
6298
  <div class="readout" id="${sid}-read">${esc(p.readout)}</div>` : "";
6233
6299
  const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
@@ -6289,7 +6355,12 @@ var lineChart = (beat, ctx) => {
6289
6355
  // The copy lifts off — same geometry, so the half-second reads as one line
6290
6356
  // separating from itself rather than as a second line arriving.
6291
6357
  tween(`#${sid}-line`, { opacity: 0 }, { opacity: 1, duration: SEPARATE }, lift2),
6292
- tween(`#${sid}-base`, { opacity: 1 }, { opacity: GHOST_OPACITY, duration: SEPARATE }, lift2),
6358
+ tween(
6359
+ `#${sid}-base`,
6360
+ { stroke: theme.accent },
6361
+ { stroke: theme.dim, duration: SEPARATE },
6362
+ lift2
6363
+ ),
6293
6364
  reshape(`#${sid}-line`, `#${sid}-target`, lift2 + SEPARATE, RESHAPE_SECONDS, true)
6294
6365
  );
6295
6366
  }
@@ -10767,8 +10838,11 @@ function closeSafe(code) {
10767
10838
  }
10768
10839
  function sceneHtml(sid, start, clip3, label, scene, transit) {
10769
10840
  const setup = [...mathSetup(sid, scene.html), ...scene.setup ?? []].map(statement).join("\n ");
10841
+ const measure4 = (scene.measure ?? []).map(statement);
10842
+ const laps = lapSetup(sid, setup + measure4.join(""));
10770
10843
  const build = (pad) => [
10771
- ...(scene.measure ?? []).map(statement),
10844
+ ...measure4,
10845
+ ...laps,
10772
10846
  `window.__timelines = window.__timelines || {};`,
10773
10847
  `// Each scene owns a paused timeline under its own composition id, with`,
10774
10848
  `// times relative to its start \u2014 one absolute root timeline yields a`,
@@ -10813,6 +10887,15 @@ function mathSetup(sid, html) {
10813
10887
  })`
10814
10888
  ];
10815
10889
  }
10890
+ function lapSetup(sid, script) {
10891
+ if (!script.includes("katex.render")) return [];
10892
+ const laps = [".rlap", ".llap", ".clap"].map((c) => `#${sid} .katex ${c} *`).join(", ");
10893
+ return [
10894
+ statement(`Array.prototype.forEach.call(document.querySelectorAll('${laps}'), function (el) {
10895
+ el.setAttribute("data-layout-allow-overlap", "");
10896
+ })`)
10897
+ ];
10898
+ }
10816
10899
  function statement(s) {
10817
10900
  const line2 = s.trim();
10818
10901
  return line2.endsWith(";") ? line2 : `${line2};`;
@@ -23,3 +23,25 @@ export declare function familyFor(lang: string): string | null;
23
23
  export declare function bundleFont(lang: string, glyphs: string, dir: string, opts?: {
24
24
  serif?: boolean;
25
25
  }): Promise<FontBundle | null>;
26
+ /**
27
+ * Inter's vertical metrics as `@font-face` descriptors: ascent 0.969em,
28
+ * descent 0.241em, no line gap.
29
+ *
30
+ * Every vertical number in the emitters (line boxes, the SVG label `dy`, the
31
+ * centring of a tspan block) and every box the gates measure was tuned on Inter.
32
+ * `src/build/files.ts` declares a v2 pack's Latin faces with these for that
33
+ * reason, and the CJK bundle needs them for the same one. Noto Sans SC as Google
34
+ * serves it is 1.160 / 0.288 (hhea, read from the woff2), so its line box is
35
+ * 1.448em where the emitters budget 1.21. MEASURED in Chrome on a zh-Hans deck
36
+ * (HypePaper, v0.9.2, 2026-10-11): a 56px split-compare heading spanned 65px
37
+ * above its baseline and overran the top of its svg by 11px. Every verify
38
+ * reports that as `container_overflow` on `#sN-sideI`; HypePaper's build also
39
+ * reported it as `text_box_overflow` on `#sN-labI`, failed on it, and a later
40
+ * verify of the same deck did not. The ink was never cut, because a Han glyph's
41
+ * ink sits inside 0.88em. Only the box was wrong.
42
+ *
43
+ * Advances are untouched, so the width tables in `src/emit/svg.ts` still apply.
44
+ */
45
+ export declare const INTER_METRICS = "ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
46
+ /** Declare every `@font-face` in `css` with `INTER_METRICS`. Idempotent. */
47
+ export declare function withInterMetrics(css: string): string;
@@ -340,10 +340,16 @@ export declare function scanPaperArc(storyboard: Storyboard, prefs: Prefs): Find
340
340
  * NARROW ON PURPOSE. It compares only beats that have BOTH a narration text and
341
341
  * segments, so a deck narrated at a lower density — where some beats are
342
342
  * deliberately silent — says nothing, and the existing missing-audio error keeps
343
- * its own case. Whitespace is normalised because the splitter re-joins sentences
344
- * at stop boundaries; on the demo's thirteen beats the concatenation reproduces
345
- * the authored text exactly, which is what makes the comparison safe to make
346
- * fatal.
343
+ * its own case.
344
+ *
345
+ * COMPARED SENTENCE BY SENTENCE, through the same `splitSentences` that cut the
346
+ * segments. The splitter trims each sentence, so whitespace at a sentence
347
+ * boundary is gone from the record and the segments' rejoin cannot know what it
348
+ * was. Rejoining with a space and comparing strings was right for English and
349
+ * wrong for every Chinese and Japanese deck: they write no space after 。, so
350
+ * every beat narrated over two or more stops "drifted" — 15 of 18 beats on a
351
+ * freshly narrated zh-Hans HypePaper run, exactly the multi-segment ones.
352
+ * Whitespace inside a sentence is still normalised, not dropped.
347
353
  */
348
354
  export declare function scanNarrationDrift(storyboard: Storyboard, narration: {
349
355
  beats: Record<string, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jokerized/decksmith",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Turn a source document into an animated explanation deck.",
5
5
  "type": "module",
6
6
  "license": "MIT",