storyink 0.3.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2
4
+
5
+ - **Fix: clicking the transport's play/pause did nothing** (space worked). The stage's native
6
+ `pointerdown` listener, which starts a pan, ran before React's root listener, so the controls'
7
+ React `stopPropagation` came too late: the stage captured the pointer and the button's `click`
8
+ never fired. Pans now start only on the diagram surface (not on buttons, the scrubber, the
9
+ toolbar, the transport, the gate or `[data-no-pan]`) and capture the pointer only after 3 px of
10
+ movement, so a still click on the surface stays a click. Quick repeated −/+ clicks now compound.
11
+ - **Follow camera.** While a story plays on a large diagram, the viewer zooms to a readable scale
12
+ (labels ≈ 12.4 px, when fit would render them below 10 px) and pans to each step, moving only
13
+ when the step's focus leaves the inner 80 % of the view (bounce-free spring, 0.75 s), then eases
14
+ back to fit at the end. Manual zoom is kept (follow pans); a drag suspends follow until the next
15
+ out-of-view step; reduced motion jumps; scrubbing and ←/→ move the camera too.
16
+ - Toolbar **Follow** toggle (`aria-pressed`, `F`), saved in `localStorage["storyink-follow"]`.
17
+ - `story.camera: "follow" | "fit"` (default follow), `render --camera`, tool `storyink_render`
18
+ `camera`; `#camera=follow|fit`; `window.__storyink.camera()`.
19
+ - Core: `stepFocus`, `cameraAt` (pure: t + stage + scale → camera), `followStep`,
20
+ `readableScale`, `fitCamera`, `inView`, `stepAt`, `CAMERA`.
21
+ - `storyink snapshot --camera follow --at …` (tool `camera`) captures the followed view in a
22
+ 16:9 stage, with a `follow-deterministic` gate. Default snapshots and beat sheets stay at fit.
23
+ - `bun run verify:viewer` drives every control with real mouse input (CDP
24
+ `Input.dispatchMouseEvent`) in full and reduced motion, and checks the follow camera on a
25
+ 25-node diagram (readable zoom, pans, drag suspend/resume, end at fit, toggle persistence,
26
+ reduced jumps).
27
+ - Not in the animated SVG (SMIL): a follow camera there would need an animated `viewBox`; a
28
+ possible follow-up.
29
+
3
30
  ## 0.3.1
4
31
 
5
32
  - **Fix: Play did nothing under reduced motion.** With Reduce Motion on (OS or `#motion=reduced`),
package/README.md CHANGED
@@ -48,11 +48,12 @@ both and has the OpenCode plugin as its default export. Runs on Node ≥ 20 and
48
48
 
49
49
  ```
50
50
  storyink render <in.json|in.mmd|-> [-o out.html] [--svg out.svg] [--theme light|dark] [--story auto]
51
+ [--motion full|reduced|system] [--camera follow|fit]
51
52
  [--animated-svg out.svg [--theme light|dark|both] [--once] [--font system|embed]]
52
53
  storyink mermaid <in.mmd> [-o out.json]
53
54
  storyink validate <in> [--json]
54
55
  storyink snapshot <out.html> [--theme light,dark] [--width N] [--sheet [themes|beats]|--no-sheet] [--at 0.5,1.2,end] [--scale 2] [-o dir] [--json]
55
- [--preview out.jpg [--preview-size 1024]] [--motion reduced]
56
+ [--preview out.jpg [--preview-size 1024]] [--motion reduced] [--camera follow]
56
57
  storyink skill
57
58
  ```
58
59
 
@@ -132,6 +133,13 @@ for auto stories. Readers switch with the toolbar's **Motion** toggle (`M`, reme
132
133
  each step's settled state shown at once and held for its reading time, with no travelling pulses
133
134
  or tweens, and the page opens on the final frame.
134
135
 
136
+ The camera **follows the story** while it plays. When the whole diagram would render its labels
137
+ too small, Play zooms to a readable scale and pans from step to step, moving only when the active
138
+ step leaves the middle 80 % of the view, then eases back to fit at the end. Your own zoom is kept
139
+ and a drag pauses following until the next step that is out of view. Toggle it with **Follow**
140
+ (`F`, remembered), `#camera=fit`, or `"story": { "camera": "fit" }` / `--camera fit`.
141
+ `storyink snapshot --camera follow --at …` captures the followed view.
142
+
135
143
  The final frame is always the static diagram. Every frame is a pure function of time
136
144
  (`storyState(scene, timeline, t)`), so `#t=2.5` seeks exactly and
137
145
  `storyink snapshot --at 1,2.5,end --sheet beats` renders stills and a beat contact sheet. The
@@ -6,7 +6,7 @@ import {
6
6
  renderHtml,
7
7
  renderSvg,
8
8
  validate
9
- } from "./index-6h5ajcgs.js";
9
+ } from "./index-n3gf4fyb.js";
10
10
 
11
11
  // src/node/index.ts
12
12
  import fs3 from "node:fs";
@@ -194,10 +194,13 @@ async function snapshot(htmlPath, opts = {}) {
194
194
  const ats = opts.at?.length ? opts.at : opts.t ? [opts.t === "end" ? "end" : Number(opts.t)] : ["end"];
195
195
  const tq = (at) => tl ? `&t=${at === "end" ? "end" : +at.toFixed(3)}` : "";
196
196
  const mq = opts.motion ? `&motion=${opts.motion}` : "";
197
+ const follow = opts.camera === "follow" && !!tl;
198
+ const cq = follow ? "&camera=follow" : "";
197
199
  const sheetMode = opts.sheet === false ? false : opts.sheet === "beats" ? "beats" : "themes";
198
200
  const W = Math.round(Math.max(500, opts.width ?? Math.min(1600, vb.w + 64)));
199
201
  const s = Math.min(1, (W - 64) / vb.w);
200
202
  const H = Math.round(headerH + vb.h * s + 64 + 8);
203
+ const FW = Math.round(Math.max(500, opts.width ?? 1280));
201
204
  const url = (hash) => `${pathToFileURL(abs).href}#${hash}`;
202
205
  const baseFlags = [
203
206
  ...browser.flavor === "chrome" ? ["--headless=new"] : [],
@@ -321,10 +324,11 @@ async function snapshot(htmlPath, opts = {}) {
321
324
  try {
322
325
  for (const theme of themes)
323
326
  for (const at of ats) {
324
- const tag = `${at === "end" ? "" : `.t${+at.toFixed(2)}`}${opts.motion === "reduced" ? ".reduced" : ""}`;
327
+ const tag = `${at === "end" ? "" : `.t${+at.toFixed(2)}`}${opts.motion === "reduced" ? ".reduced" : ""}${follow ? ".follow" : ""}`;
325
328
  const png = path2.join(outDir, `${base}.${theme}${tag}.png`);
326
- const ms = await shoot(`theme=${theme}&chrome=0${tq(at)}${mq}`, png, W, H);
327
- captures.push({ theme, at, png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: W * scale, height: H * scale, ms });
329
+ const [cw, ch] = follow ? [FW, Math.round(FW * 0.5625)] : [W, H];
330
+ const ms = await shoot(`theme=${theme}&chrome=0${tq(at)}${mq}${cq}`, png, cw, ch);
331
+ captures.push({ theme, at, png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: cw * scale, height: ch * scale, ms });
328
332
  }
329
333
  const first = captures[0];
330
334
  const midT = tl ? ats.find((a) => a !== "end") ?? +(tl.duration / 2).toFixed(3) : "end";
@@ -334,6 +338,12 @@ async function snapshot(htmlPath, opts = {}) {
334
338
  await shoot(`theme=${first.theme}&chrome=0${tq(midT)}`, a2, W, H);
335
339
  const same = sha256(a1) === sha256(a2);
336
340
  gates.push({ name: "deterministic", pass: same, detail: same ? `${first.theme} at t=${midT} captured twice: identical` : `${first.theme} at t=${midT} differs between runs` });
341
+ if (follow) {
342
+ const f2 = path2.join(tmpRoot, "follow-2.png");
343
+ await shoot(`theme=${first.theme}&chrome=0${tq(first.at ?? "end")}${mq}${cq}`, f2, FW, Math.round(FW * 0.5625));
344
+ const ok = sha256(f2) === first.sha256;
345
+ gates.push({ name: "follow-deterministic", pass: ok, detail: ok ? `camera=follow at t=${first.at} captured twice: identical` : `camera=follow at t=${first.at} differs between runs` });
346
+ }
337
347
  if (tl) {
338
348
  const stat = path2.join(tmpRoot, "static.png");
339
349
  const end = path2.join(tmpRoot, "end.png");
@@ -497,6 +507,12 @@ function setStoryMotion(spec, motion) {
497
507
  spec.story = spec.story === "auto" ? { steps: "auto", motion } : { ...spec.story, motion };
498
508
  return true;
499
509
  }
510
+ function setStoryCamera(spec, camera) {
511
+ if (spec.story === undefined)
512
+ return false;
513
+ spec.story = spec.story === "auto" ? { steps: "auto", camera } : { ...spec.story, camera };
514
+ return true;
515
+ }
500
516
  function pictureSnippet(light, dark, alt) {
501
517
  const a = alt.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
502
518
  return `<picture>
@@ -525,4 +541,4 @@ function writeAnimatedSvg(input, out, opts = {}) {
525
541
  return { ok: true, diagnostics: v.diagnostics, files, autoStory, snippet };
526
542
  }
527
543
 
528
- export { browserVersion, findBrowser, snapshot, screenshotPage, parseSource, loadSpec, writeDiagram, setStoryMotion, pictureSnippet, writeAnimatedSvg };
544
+ export { browserVersion, findBrowser, snapshot, screenshotPage, parseSource, loadSpec, writeDiagram, setStoryMotion, setStoryCamera, pictureSnippet, writeAnimatedSvg };