storyink 0.3.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3
4
+
5
+ - **Fix: `snapshot --camera follow` wasn't deterministic on real diagrams** (`follow-deterministic`
6
+ failed on OpenWick's 24- and 18-node architecture/data-flow diagrams). The DOM, camera and
7
+ transform were identical across runs; what varied was Chrome's paint history. Some runs painted a
8
+ frame at the fit transform before the followed camera was placed, which changed the canvas
9
+ raster (anti-aliasing of some node boxes and text). With `#camera=follow&t=` the canvas now stays
10
+ hidden until the followed camera has painted. `follow-deterministic` now re-captures every
11
+ `at` frame, not only the first.
12
+ - **Arrow keys animate step moves.** → plays forward to the next step boundary and pauses (while
13
+ playing: advance one step); ← rewinds backwards at 2× to the previous boundary with a short
14
+ bounce-free ease. Shift goes by chapter, repeated presses extend the target, and the opposite key
15
+ reverses. Space pauses a move, the follow camera follows it (backwards too), and reduced motion
16
+ still jumps.
17
+ - `window.__storyink.step(dir, chapter?)` animates the same way and returns a Promise;
18
+ `stepAnimated()` is the running move. `state()` reads the clock/mode without render lag.
19
+ - Core: `stepBoundary`, `stepMoveTarget`, `stepMoveSpeed`, `STEP_MOVE`.
20
+ - `verify:viewer` drives → / ← / Shift / Space with real key events (CDP `Input.dispatchKeyEvent`).
21
+
22
+ ## 0.3.2
23
+
24
+ - **Fix: clicking the transport's play/pause did nothing** (space worked). The stage's native
25
+ `pointerdown` listener, which starts a pan, ran before React's root listener, so the controls'
26
+ React `stopPropagation` came too late: the stage captured the pointer and the button's `click`
27
+ never fired. Pans now start only on the diagram surface (not on buttons, the scrubber, the
28
+ toolbar, the transport, the gate or `[data-no-pan]`) and capture the pointer only after 3 px of
29
+ movement, so a still click on the surface stays a click. Quick repeated −/+ clicks now compound.
30
+ - **Follow camera.** While a story plays on a large diagram, the viewer zooms to a readable scale
31
+ (labels ≈ 12.4 px, when fit would render them below 10 px) and pans to each step, moving only
32
+ when the step's focus leaves the inner 80 % of the view (bounce-free spring, 0.75 s), then eases
33
+ back to fit at the end. Manual zoom is kept (follow pans); a drag suspends follow until the next
34
+ out-of-view step; reduced motion jumps; scrubbing and ←/→ move the camera too.
35
+ - Toolbar **Follow** toggle (`aria-pressed`, `F`), saved in `localStorage["storyink-follow"]`.
36
+ - `story.camera: "follow" | "fit"` (default follow), `render --camera`, tool `storyink_render`
37
+ `camera`; `#camera=follow|fit`; `window.__storyink.camera()`.
38
+ - Core: `stepFocus`, `cameraAt` (pure: t + stage + scale → camera), `followStep`,
39
+ `readableScale`, `fitCamera`, `inView`, `stepAt`, `CAMERA`.
40
+ - `storyink snapshot --camera follow --at …` (tool `camera`) captures the followed view in a
41
+ 16:9 stage, with a `follow-deterministic` gate. Default snapshots and beat sheets stay at fit.
42
+ - `bun run verify:viewer` drives every control with real mouse input (CDP
43
+ `Input.dispatchMouseEvent`) in full and reduced motion, and checks the follow camera on a
44
+ 25-node diagram (readable zoom, pans, drag suspend/resume, end at fit, toggle persistence,
45
+ reduced jumps).
46
+ - Not in the animated SVG (SMIL): a follow camera there would need an animated `viewBox`; a
47
+ possible follow-up.
48
+
3
49
  ## 0.3.1
4
50
 
5
51
  - **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
 
@@ -122,7 +123,8 @@ Add a `story` to play a diagram as a sequence of beats in the HTML viewer:
122
123
 
123
124
  `"story": "auto"` (or `--story auto`) derives the beats from the graph or message order. Playback
124
125
  has a click-to-play gate, play/pause, a tape-rewind replay and a scrubber with step and chapter
125
- ticks. Space, ←/→ and R control it.
126
+ ticks. Space, ←/→ and R control it: → plays to the next step and pauses, ← rewinds at 2× to the
127
+ previous one (Shift: by chapter; repeated presses extend the move).
126
128
 
127
129
  Motion is **full by default, even when the reader's system asks for reduced motion**. Authors can
128
130
  set `"story": { "motion": "reduced" }` (always step by step) or `"motion": "system"` (follow
@@ -132,6 +134,13 @@ for auto stories. Readers switch with the toolbar's **Motion** toggle (`M`, reme
132
134
  each step's settled state shown at once and held for its reading time, with no travelling pulses
133
135
  or tweens, and the page opens on the final frame.
134
136
 
137
+ The camera **follows the story** while it plays. When the whole diagram would render its labels
138
+ too small, Play zooms to a readable scale and pans from step to step, moving only when the active
139
+ step leaves the middle 80 % of the view, then eases back to fit at the end. Your own zoom is kept
140
+ and a drag pauses following until the next step that is out of view. Toggle it with **Follow**
141
+ (`F`, remembered), `#camera=fit`, or `"story": { "camera": "fit" }` / `--camera fit`.
142
+ `storyink snapshot --camera follow --at …` captures the followed view.
143
+
135
144
  The final frame is always the static diagram. Every frame is a pure function of time
136
145
  (`storyState(scene, timeline, t)`), so `#t=2.5` seeks exactly and
137
146
  `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-xmj21twd.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,17 @@ 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 diff = [];
343
+ for (const c of captures.filter((x) => x.theme === first.theme)) {
344
+ const f2 = path2.join(tmpRoot, `follow-${diff.length}-${String(c.at)}.png`);
345
+ await shoot(`theme=${c.theme}&chrome=0${tq(c.at ?? "end")}${mq}${cq}`, f2, FW, Math.round(FW * 0.5625));
346
+ if (sha256(f2) !== c.sha256)
347
+ diff.push(String(c.at));
348
+ }
349
+ const at = captures.filter((x) => x.theme === first.theme).map((x) => x.at).join(",");
350
+ gates.push({ name: "follow-deterministic", pass: !diff.length, detail: !diff.length ? `camera=follow at t=${at} captured twice: identical` : `camera=follow at t=${diff.join(",")} differs between runs` });
351
+ }
337
352
  if (tl) {
338
353
  const stat = path2.join(tmpRoot, "static.png");
339
354
  const end = path2.join(tmpRoot, "end.png");
@@ -497,6 +512,12 @@ function setStoryMotion(spec, motion) {
497
512
  spec.story = spec.story === "auto" ? { steps: "auto", motion } : { ...spec.story, motion };
498
513
  return true;
499
514
  }
515
+ function setStoryCamera(spec, camera) {
516
+ if (spec.story === undefined)
517
+ return false;
518
+ spec.story = spec.story === "auto" ? { steps: "auto", camera } : { ...spec.story, camera };
519
+ return true;
520
+ }
500
521
  function pictureSnippet(light, dark, alt) {
501
522
  const a = alt.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
502
523
  return `<picture>
@@ -525,4 +546,4 @@ function writeAnimatedSvg(input, out, opts = {}) {
525
546
  return { ok: true, diagnostics: v.diagnostics, files, autoStory, snippet };
526
547
  }
527
548
 
528
- export { browserVersion, findBrowser, snapshot, screenshotPage, parseSource, loadSpec, writeDiagram, setStoryMotion, pictureSnippet, writeAnimatedSvg };
549
+ export { browserVersion, findBrowser, snapshot, screenshotPage, parseSource, loadSpec, writeDiagram, setStoryMotion, setStoryCamera, pictureSnippet, writeAnimatedSvg };