storyink 0.3.0 → 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,55 @@
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
+
30
+ ## 0.3.1
31
+
32
+ - **Fix: Play did nothing under reduced motion.** With Reduce Motion on (OS or `#motion=reduced`),
33
+ Play now walks the story step by step ("Play steps"): each step's settled state at once (reveals,
34
+ landed pulses, counters, the whole caption), held for its reading time (caption read time or
35
+ 1.5 s), then the next, ending on the final frame. No pulses, trails, glows or tweens. Pause, ←/→,
36
+ Shift+←/→, R (restart from step 1, no rewind) and the scrubber work on settled steps. The page
37
+ loads on the final frame with a static play button; `autoplay` is ignored.
38
+ - **`story.motion`: `"full"` (default) | `"reduced"` | `"system"`.** Full motion is the default and
39
+ **ignores the reader's OS reduced-motion setting**; `"system"` restores the 0.3.0 behaviour
40
+ (follow `prefers-reduced-motion`), `"reduced"` always steps. Auto stories:
41
+ `{ "steps": "auto", "motion": … }`, `render --motion …`, tool `storyink_render` `motion`.
42
+ Precedence: `#motion=` > the reader's toolbar choice > `story.motion` > OS (only for `"system"`).
43
+ - Viewer toolbar: **Motion: full / reduced** toggle (`aria-pressed`, shortcut `M`), saved in
44
+ `localStorage`; `#motion=` still wins. Switching mid-playback continues from the current step.
45
+ - Core: `steppedSchedule`, `steppedTime`, `steppedIndex`, `steppedStop`, `STEP_BEAT` and
46
+ `storyState(…, { stepped: true })`.
47
+ - Snapshot: `--motion reduced` (tool `motion`) captures stepped `at` frames; new gate `reduced=stepped`.
48
+ - Fix: live counter reels were placed at the node-local position (top-left of the diagram) and
49
+ shown before their node was revealed; they now sit on their node and follow its reveal.
50
+ `bun run verify:viewer` checks this in headless Chrome (full play, reduced → full, reload,
51
+ resize, theme toggle) along with stepped playback.
52
+
3
53
  ## 0.3.0
4
54
 
5
55
  - **Animated SVG (SMIL):** the story plays inside a plain `<img>`, so in GitHub READMEs and PR
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]]
56
+ [--preview out.jpg [--preview-size 1024]] [--motion reduced] [--camera follow]
56
57
  storyink skill
57
58
  ```
58
59
 
@@ -122,7 +123,22 @@ 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. When reduced motion is on, the viewer jumps to the final frame.
126
+ ticks. Space, ←/→ and R control it.
127
+
128
+ Motion is **full by default, even when the reader's system asks for reduced motion**. Authors can
129
+ set `"story": { "motion": "reduced" }` (always step by step) or `"motion": "system"` (follow
130
+ `prefers-reduced-motion`); `--motion full|reduced|system` on `render` does the same, including
131
+ for auto stories. Readers switch with the toolbar's **Motion** toggle (`M`, remembered), and
132
+ `#motion=full|reduced` overrides everything. In reduced mode Play walks the story step by step:
133
+ each step's settled state shown at once and held for its reading time, with no travelling pulses
134
+ or tweens, and the page opens on the final frame.
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.
126
142
 
127
143
  The final frame is always the static diagram. Every frame is a pure function of time
128
144
  (`storyState(scene, timeline, t)`), so `#t=2.5` seeks exactly and
@@ -6,7 +6,7 @@ import {
6
6
  renderHtml,
7
7
  renderSvg,
8
8
  validate
9
- } from "./index-060cd52n.js";
9
+ } from "./index-n3gf4fyb.js";
10
10
 
11
11
  // src/node/index.ts
12
12
  import fs3 from "node:fs";
@@ -193,10 +193,14 @@ async function snapshot(htmlPath, opts = {}) {
193
193
  const headerH = (scene.subtitle ? 128 : 100) + (tl ? 54 : 0);
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
+ const mq = opts.motion ? `&motion=${opts.motion}` : "";
197
+ const follow = opts.camera === "follow" && !!tl;
198
+ const cq = follow ? "&camera=follow" : "";
196
199
  const sheetMode = opts.sheet === false ? false : opts.sheet === "beats" ? "beats" : "themes";
197
200
  const W = Math.round(Math.max(500, opts.width ?? Math.min(1600, vb.w + 64)));
198
201
  const s = Math.min(1, (W - 64) / vb.w);
199
202
  const H = Math.round(headerH + vb.h * s + 64 + 8);
203
+ const FW = Math.round(Math.max(500, opts.width ?? 1280));
200
204
  const url = (hash) => `${pathToFileURL(abs).href}#${hash}`;
201
205
  const baseFlags = [
202
206
  ...browser.flavor === "chrome" ? ["--headless=new"] : [],
@@ -320,10 +324,11 @@ async function snapshot(htmlPath, opts = {}) {
320
324
  try {
321
325
  for (const theme of themes)
322
326
  for (const at of ats) {
323
- const tag = at === "end" ? "" : `.t${+at.toFixed(2)}`;
327
+ const tag = `${at === "end" ? "" : `.t${+at.toFixed(2)}`}${opts.motion === "reduced" ? ".reduced" : ""}${follow ? ".follow" : ""}`;
324
328
  const png = path2.join(outDir, `${base}.${theme}${tag}.png`);
325
- const ms = await shoot(`theme=${theme}&chrome=0${tq(at)}`, png, W, H);
326
- 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 });
327
332
  }
328
333
  const first = captures[0];
329
334
  const midT = tl ? ats.find((a) => a !== "end") ?? +(tl.duration / 2).toFixed(3) : "end";
@@ -333,6 +338,12 @@ async function snapshot(htmlPath, opts = {}) {
333
338
  await shoot(`theme=${first.theme}&chrome=0${tq(midT)}`, a2, W, H);
334
339
  const same = sha256(a1) === sha256(a2);
335
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
+ }
336
347
  if (tl) {
337
348
  const stat = path2.join(tmpRoot, "static.png");
338
349
  const end = path2.join(tmpRoot, "end.png");
@@ -343,6 +354,14 @@ async function snapshot(htmlPath, opts = {}) {
343
354
  const sStat = sha256(stat);
344
355
  gates.push({ name: "end=static", pass: sha256(end) === sStat, detail: sha256(end) === sStat ? "t=end matches the static diagram" : "t=end differs from the static diagram" });
345
356
  gates.push({ name: "reduced=static", pass: sha256(red) === sStat, detail: sha256(red) === sStat ? "reduced motion shows the static diagram" : "reduced motion differs from the static diagram" });
357
+ const probes = tl.steps.map((st) => +(st.t0 / 2 + st.t1 / 2).toFixed(3)).filter((_, i, a) => i % Math.max(1, Math.ceil(a.length / 3)) === 0);
358
+ let inFlight = 0;
359
+ for (const pt of probes) {
360
+ const d = await run(browser.path, [...baseFlags, fresh(), `--window-size=${W},${H}`, "--dump-dom", url(`theme=${first.theme}&chrome=0&motion=reduced&t=${pt}`)], { timeoutMs, untilStdout: /<\/html>\s*$/, signal: opts.signal });
361
+ if (/data-si="pulse:/.test(d.stdout))
362
+ inFlight++;
363
+ }
364
+ gates.push({ name: "reduced=stepped", pass: inFlight === 0, detail: inFlight === 0 ? `reduced motion at t=${probes.join(",")}: settled steps, no pulse in flight` : `${inFlight} reduced frame(s) show a pulse in flight` });
346
365
  }
347
366
  const beats = [];
348
367
  if (sheetMode === "beats" && tl) {
@@ -482,6 +501,18 @@ function writeDiagram(input, out) {
482
501
  }
483
502
  return res;
484
503
  }
504
+ function setStoryMotion(spec, motion) {
505
+ if (spec.story === undefined)
506
+ return false;
507
+ spec.story = spec.story === "auto" ? { steps: "auto", motion } : { ...spec.story, motion };
508
+ return true;
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
+ }
485
516
  function pictureSnippet(light, dark, alt) {
486
517
  const a = alt.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
487
518
  return `<picture>
@@ -510,4 +541,4 @@ function writeAnimatedSvg(input, out, opts = {}) {
510
541
  return { ok: true, diagnostics: v.diagnostics, files, autoStory, snippet };
511
542
  }
512
543
 
513
- export { browserVersion, findBrowser, snapshot, screenshotPage, parseSource, loadSpec, writeDiagram, pictureSnippet, writeAnimatedSvg };
544
+ export { browserVersion, findBrowser, snapshot, screenshotPage, parseSource, loadSpec, writeDiagram, setStoryMotion, setStoryCamera, pictureSnippet, writeAnimatedSvg };