storyink 0.1.0 → 0.2.0

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.
Files changed (50) hide show
  1. package/README.md +24 -2
  2. package/THIRD_PARTY_NOTICES.md +12 -0
  3. package/dist/chunks/{index-3y6t38gc.js → index-aq1sxjmx.js} +59 -16
  4. package/dist/chunks/index-xjaczbn5.js +5790 -0
  5. package/dist/cli.js +4702 -2874
  6. package/dist/core/index.js +18 -2
  7. package/dist/index.js +30 -6
  8. package/dist/node/index.js +2 -2
  9. package/dist/types/core/index.d.ts +4 -0
  10. package/dist/types/core/layout/nodes.d.ts +8 -0
  11. package/dist/types/core/render/App.d.ts +9 -0
  12. package/dist/types/core/render/Diagram.d.ts +4 -2
  13. package/dist/types/core/render/Story.d.ts +54 -0
  14. package/dist/types/core/render/index.d.ts +2 -0
  15. package/dist/types/core/scene.d.ts +13 -0
  16. package/dist/types/core/spec.d.ts +48 -2
  17. package/dist/types/core/story/auto.d.ts +12 -0
  18. package/dist/types/core/story/compile.d.ts +24 -0
  19. package/dist/types/core/story/ease.d.ts +22 -0
  20. package/dist/types/core/story/state.d.ts +36 -0
  21. package/dist/types/core/story/types.d.ts +142 -0
  22. package/dist/types/generated/meta.d.ts +1 -1
  23. package/dist/types/generated/rolling.d.ts +2 -0
  24. package/dist/types/generated/viewer.d.ts +1 -1
  25. package/dist/types/node/snapshot.d.ts +17 -4
  26. package/dist/types/theme/tokens.d.ts +71 -0
  27. package/docs/spec.md +46 -0
  28. package/examples/checkout.architecture.json +78 -2
  29. package/examples/oauth.sequence.json +197 -22
  30. package/package.json +5 -4
  31. package/schema/storyink.schema.json +226 -1
  32. package/skill/SKILL.md +29 -0
  33. package/dist/chunks/index-sd10277m.js +0 -4017
  34. package/docs/gallery/agent-run.lifecycle.dark.png +0 -0
  35. package/docs/gallery/agent-run.lifecycle.light.png +0 -0
  36. package/docs/gallery/analytics.dataflow.dark.png +0 -0
  37. package/docs/gallery/analytics.dataflow.light.png +0 -0
  38. package/docs/gallery/cache.sequence.dark.png +0 -0
  39. package/docs/gallery/cache.sequence.light.png +0 -0
  40. package/docs/gallery/checkout.architecture.dark.png +0 -0
  41. package/docs/gallery/checkout.architecture.light.png +0 -0
  42. package/docs/gallery/incident.flowchart.dark.png +0 -0
  43. package/docs/gallery/incident.flowchart.light.png +0 -0
  44. package/docs/gallery/oauth.sequence.dark.png +0 -0
  45. package/docs/gallery/oauth.sequence.light.png +0 -0
  46. package/docs/gallery/order.state.dark.png +0 -0
  47. package/docs/gallery/order.state.light.png +0 -0
  48. package/docs/gallery/release.workflow.dark.png +0 -0
  49. package/docs/gallery/release.workflow.light.png +0 -0
  50. /package/dist/chunks/{index-6hdgvcdm.js → index-90zf42zn.js} +0 -0
package/README.md CHANGED
@@ -34,10 +34,10 @@ both and has the OpenCode plugin as its default export. Runs on Node ≥ 20 and
34
34
  ## 2. CLI
35
35
 
36
36
  ```
37
- storyink render <in.json|in.mmd|-> [-o out.html] [--svg out.svg] [--theme light|dark]
37
+ storyink render <in.json|in.mmd|-> [-o out.html] [--svg out.svg] [--theme light|dark] [--story auto]
38
38
  storyink mermaid <in.mmd> [-o out.json]
39
39
  storyink validate <in> [--json]
40
- storyink snapshot <out.html> [--theme light,dark] [--width N] [--no-sheet] [--scale 2] [-o dir] [--json]
40
+ 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]
41
41
  storyink skill
42
42
  ```
43
43
 
@@ -90,6 +90,27 @@ npx storyink skill # prints the SKILL.md path and its content
90
90
 
91
91
  An MCP server is planned.
92
92
 
93
+ ## Storyboards (opt-in)
94
+
95
+ Add a `story` to play a diagram as a sequence of beats in the HTML viewer:
96
+ - nodes reveal;
97
+ - wires draw on under a travelling pulse;
98
+ - arrivals glow;
99
+ - captions type in;
100
+ - counters roll (via [@kitlangton/rolling-number](https://github.com/kitlangton/rolling-number)).
101
+
102
+ `"story": "auto"` (or `--story auto`) derives the beats from the graph or message order. Playback
103
+ has a click-to-play gate, play/pause, a tape-rewind replay and a scrubber with step and chapter
104
+ ticks. Space, ←/→ and R control it. When reduced motion is on, the viewer jumps to the final frame.
105
+
106
+ The final frame is always the static diagram. Every frame is a pure function of time
107
+ (`storyState(scene, timeline, t)`), so `#t=2.5` seeks exactly and
108
+ `storyink snapshot --at 1,2.5,end --sheet beats` renders stills and a beat contact sheet. The
109
+ receipt gates check that the end frame and the reduced-motion page match the static diagram. See
110
+ [docs/spec.md](docs/spec.md#storyboard-story-opt-in).
111
+
112
+ ![Checkout beats](docs/gallery/checkout.architecture.beats.light.png)
113
+
93
114
  ## Gallery
94
115
 
95
116
  `bun run gallery` renders every example and Mermaid sample and writes one light and one dark PNG per
@@ -100,6 +121,7 @@ example to `docs/gallery/`.
100
121
  | ![Checkout platform](docs/gallery/checkout.architecture.light.png) | ![Agent run lifecycle](docs/gallery/agent-run.lifecycle.dark.png) |
101
122
  | ![Release pipeline](docs/gallery/release.workflow.light.png) | ![OAuth sequence](docs/gallery/oauth.sequence.dark.png) |
102
123
  | ![Analytics pipeline](docs/gallery/analytics.dataflow.dark.png) | ![Order state machine (Mermaid)](docs/gallery/order.state.light.png) |
124
+ | ![OAuth beats](docs/gallery/oauth.sequence.beats.dark.png) | ![Order state machine, auto story](docs/gallery/order.state.beats.light.png) |
103
125
 
104
126
  ## Spec
105
127
 
@@ -20,6 +20,18 @@ MIT License. Copyright (c) 2024 Motion B.V.; Copyright (c) 2018 Framer B.V. <htt
20
20
  The viewer bundle inlined into each HTML file starts with a comment banner that carries
21
21
  these notices and the MIT permission text.
22
22
 
23
+ ## @kitlangton/rolling-number (bundled into the HTML viewer)
24
+
25
+ MIT License. Copyright (c) 2026 Kit Langton. <https://github.com/kitlangton/rolling-number>
26
+ Used unmodified from npm for live counter reels; its stylesheet is inlined when a story has
27
+ counters. The notice is also in the viewer bundle banner.
28
+
29
+ ## Storyboard design
30
+
31
+ Timings and curves (beats, springs, pulse phases, rewind, gate) follow the numbers documented in
32
+ our own style study of the "OpenCode Reloaded" figures; the runtime is an independent
33
+ implementation. No code, shaders or figure content from anoma.ly are included.
34
+
23
35
  ## archify (design reference)
24
36
 
25
37
  The spec's overall shape (one JSON document per diagram with `type`, `nodes`/`edges`/`groups`,
@@ -4,7 +4,7 @@ import {
4
4
  renderHtml,
5
5
  renderSvg,
6
6
  validate
7
- } from "./index-sd10277m.js";
7
+ } from "./index-xjaczbn5.js";
8
8
 
9
9
  // src/node/index.ts
10
10
  import fs3 from "node:fs";
@@ -187,12 +187,15 @@ async function snapshot(htmlPath, opts = {}) {
187
187
  const scale = opts.scale ?? 1;
188
188
  const timeoutMs = opts.timeoutMs ?? HARD_TIMEOUT;
189
189
  const budget = opts.budgetMs ?? 3000;
190
- const headerH = scene.subtitle ? 128 : 100;
190
+ const tl = scene.timeline;
191
+ const headerH = (scene.subtitle ? 128 : 100) + (tl ? 54 : 0);
192
+ const ats = opts.at?.length ? opts.at : opts.t ? [opts.t === "end" ? "end" : Number(opts.t)] : ["end"];
193
+ const tq = (at) => tl ? `&t=${at === "end" ? "end" : +at.toFixed(3)}` : "";
194
+ const sheetMode = opts.sheet === false ? false : opts.sheet === "beats" ? "beats" : "themes";
191
195
  const W = Math.round(Math.max(500, opts.width ?? Math.min(1600, vb.w + 64)));
192
196
  const s = Math.min(1, (W - 64) / vb.w);
193
197
  const H = Math.round(headerH + vb.h * s + 64 + 8);
194
198
  const url = (hash) => `${pathToFileURL(abs).href}#${hash}`;
195
- const tHash = opts.t ? `&t=${encodeURIComponent(opts.t)}` : "";
196
199
  const baseFlags = [
197
200
  ...browser.flavor === "chrome" ? ["--headless=new"] : [],
198
201
  "--no-first-run",
@@ -214,7 +217,11 @@ async function snapshot(htmlPath, opts = {}) {
214
217
  const shoot = async (hash, png, w, h) => {
215
218
  fs2.rmSync(png, { force: true });
216
219
  const args = [...baseFlags, fresh(), `--window-size=${w},${h}`, `--screenshot=${png}`, url(hash)];
217
- const r = await run(browser.path, args, { timeoutMs, signal: opts.signal });
220
+ let r = await run(browser.path, args, { timeoutMs, signal: opts.signal });
221
+ if ((!fs2.existsSync(png) || fs2.statSync(png).size === 0) && !opts.signal?.aborted) {
222
+ const retry = [...baseFlags, fresh(), `--window-size=${w},${h}`, `--screenshot=${png}`, url(hash)];
223
+ r = await run(browser.path, retry, { timeoutMs, signal: opts.signal });
224
+ }
218
225
  if (!fs2.existsSync(png) || fs2.statSync(png).size === 0)
219
226
  throw new Error(`screenshot failed (${r.ms} ms): ${r.stderr.split(`
220
227
  `).filter((l) => l.trim()).slice(-3).join(" | ")}`);
@@ -223,19 +230,53 @@ async function snapshot(htmlPath, opts = {}) {
223
230
  const gates = [];
224
231
  const captures = [];
225
232
  let sheetCap;
233
+ let beatCaps = [];
226
234
  let lint;
227
235
  try {
228
- for (const theme of themes) {
229
- const png = path2.join(outDir, `${base}.${theme}.png`);
230
- const ms = await shoot(`theme=${theme}&chrome=0${tHash}`, png, W, H);
231
- captures.push({ theme, png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: W * scale, height: H * scale, ms });
232
- }
236
+ for (const theme of themes)
237
+ for (const at of ats) {
238
+ const tag = at === "end" ? "" : `.t${+at.toFixed(2)}`;
239
+ const png = path2.join(outDir, `${base}.${theme}${tag}.png`);
240
+ const ms = await shoot(`theme=${theme}&chrome=0${tq(at)}`, png, W, H);
241
+ captures.push({ theme, at, png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: W * scale, height: H * scale, ms });
242
+ }
233
243
  const first = captures[0];
234
- const again = path2.join(tmpRoot, "again.png");
235
- await shoot(`theme=${first.theme}&chrome=0${tHash}`, again, W, H);
236
- const same = sha256(again) === first.sha256;
237
- gates.push({ name: "deterministic", pass: same, detail: same ? `${first.theme} captured twice: identical` : `${first.theme} differs between runs` });
238
- if (opts.sheet !== false && themes.length > 1) {
244
+ const midT = tl ? ats.find((a) => a !== "end") ?? +(tl.duration / 2).toFixed(3) : "end";
245
+ const a1 = path2.join(tmpRoot, "same-1.png");
246
+ const a2 = path2.join(tmpRoot, "same-2.png");
247
+ await shoot(`theme=${first.theme}&chrome=0${tq(midT)}`, a1, W, H);
248
+ await shoot(`theme=${first.theme}&chrome=0${tq(midT)}`, a2, W, H);
249
+ const same = sha256(a1) === sha256(a2);
250
+ gates.push({ name: "deterministic", pass: same, detail: same ? `${first.theme} at t=${midT} captured twice: identical` : `${first.theme} at t=${midT} differs between runs` });
251
+ if (tl) {
252
+ const stat = path2.join(tmpRoot, "static.png");
253
+ const end = path2.join(tmpRoot, "end.png");
254
+ const red = path2.join(tmpRoot, "reduced.png");
255
+ await shoot(`theme=${first.theme}&chrome=0&static=1`, stat, W, H);
256
+ await shoot(`theme=${first.theme}&chrome=0&t=end`, end, W, H);
257
+ await shoot(`theme=${first.theme}&chrome=0&motion=reduced`, red, W, H);
258
+ const sStat = sha256(stat);
259
+ 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" });
260
+ 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" });
261
+ }
262
+ const beats = [];
263
+ if (sheetMode === "beats" && tl) {
264
+ const bw = 1440;
265
+ for (const theme of themes) {
266
+ const probe = await run(browser.path, [...baseFlags, fresh(), `--window-size=${bw},900`, "--dump-dom", url(`theme=${theme}&chrome=0&sheet=beats`)], {
267
+ timeoutMs,
268
+ untilStdout: /<\/html>\s*$/,
269
+ signal: opts.signal
270
+ });
271
+ const mh = /<html[^>]*data-content-height="(\d+)"/.exec(probe.stdout);
272
+ const bh = mh ? Number(mh[1]) : 1800;
273
+ const png = path2.join(outDir, `${base}.beats.${theme}.png`);
274
+ const ms = await shoot(`theme=${theme}&chrome=0&sheet=beats`, png, bw, bh);
275
+ beats.push({ theme: "beats", png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: bw * scale, height: bh * scale, ms });
276
+ }
277
+ }
278
+ beatCaps = beats;
279
+ if (sheetMode === "themes" && themes.length > 1) {
239
280
  const colW = Math.max(500, Math.min(1000, vb.w + 48));
240
281
  const sw = colW * themes.length;
241
282
  const sh = Math.round(headerH + 46 + vb.h * (colW - 48) / vb.w + 36);
@@ -243,7 +284,7 @@ async function snapshot(htmlPath, opts = {}) {
243
284
  try {
244
285
  if (process.env.STORYINK_SHEET === "ffmpeg")
245
286
  throw new Error("forced ffmpeg sheet");
246
- const ms = await shoot(`sheet=${themes.join(",")}&chrome=0${tHash}`, png, sw, sh);
287
+ const ms = await shoot(`sheet=${themes.join(",")}&chrome=0`, png, sw, sh);
247
288
  sheetCap = { theme: "sheet", png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: sw * scale, height: sh * scale, ms };
248
289
  } catch (e) {
249
290
  const ff = ffmpegSheet(captures.map((c) => c.png), png);
@@ -253,7 +294,7 @@ async function snapshot(htmlPath, opts = {}) {
253
294
  gates.push({ name: "sheet", pass: false, detail: String(e.message) });
254
295
  }
255
296
  }
256
- const dom = await run(browser.path, [...baseFlags, fresh(), `--window-size=${W},${H}`, "--dump-dom", url(`theme=${themes[0]}&chrome=0`)], {
297
+ const dom = await run(browser.path, [...baseFlags, fresh(), `--window-size=${W},${H}`, "--dump-dom", url(`theme=${themes[0]}&chrome=0${tq("end")}`)], {
257
298
  timeoutMs,
258
299
  untilStdout: /<\/html>\s*$/,
259
300
  signal: opts.signal
@@ -278,6 +319,8 @@ async function snapshot(htmlPath, opts = {}) {
278
319
  flags: [...baseFlags, "--user-data-dir=<fresh tmp>", `--window-size=${W},${H}`],
279
320
  captures,
280
321
  ...sheetCap ? { sheet: sheetCap } : {},
322
+ ...beatCaps.length ? { beats: beatCaps } : {},
323
+ ...tl ? { story: { duration: tl.duration, steps: tl.steps.length } } : {},
281
324
  ...lint ? { lint } : {},
282
325
  gates,
283
326
  ok: gates.every((g) => g.pass),