npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

storyink

v0.6.0

Published

Diagram library, CLI and OpenCode v2 plugin: architecture, workflow, sequence, dataflow and lifecycle diagrams from JSON or Mermaid into standalone HTML and SVG, with optional storyboard animation.

Readme

storyink

Architecture, data-flow, workflow, sequence and lifecycle diagrams from a small JSON spec (or Mermaid), in a warm ink-on-paper style with Commit Mono labels, light and dark. A diagram can play as a story, show a code change as a change diagram or a change story, and sit in a page that you read, scroll through (scrollytelling) or present as slides. Everything renders to one offline HTML file (a React + Motion viewer over server-rendered SVG) and a static SVG; stories also compile to an animated SVG that plays inside a plain <img>, so in READMEs and PR comments too.

One package, four ways to use it: library, CLI, OpenCode plugin, and a skill for other agents.

Checkout recovery (spec): a pager row types in, the agent's diagnosis script types out with syntax colours, its active lines light up as it queries metrics and probes both pools in parallel, the triage row ends in a cross, then the program is swapped for a traffic shift: the wire to pool-a is retired, staggered requests flow to pool-b, and a verification query closes the incident with a check. One JSON file, ~20 s.

Animated SVGs (SMIL, no script): they play inside a plain <img>, so in a README or a PR comment too. See Animated SVG.

Quick start

npx storyink render examples/checkout.architecture.json -o checkout.html --svg checkout.svg
npx storyink snapshot checkout.html -o shots     # light + dark PNGs, a contact sheet, lint, a receipt
npx storyink diff main...HEAD -o changes.json    # your branch's diff, parsed
npx storyink render change.json --changes changes.json -o change.html   # real +/− and hunks

change.json is a change diagram whose boxes point at files; --changes fills in their +/−, resolves diff code nodes and embeds the hunks.

The HTML has no external requests. It includes pan/zoom (wheel at the cursor, drag, +/-/0, fit on load), a theme toggle that follows prefers-color-scheme and is saved in localStorage, SVG export and 2× PNG export (of the final frame, or of the frame on screen with Frame: now). It also works without JavaScript.

Showcase

Diagram types

Five types share one spec shape. Graphs (architecture, dataflow, workflow, lifecycle) have nodes, edges and groups and lay themselves out; a sequence has participants and ordered messages.

{
  "type": "architecture",
  "title": "Checkout",
  "subtitle": "Order path from browser to payment provider",
  "direction": "LR",
  "style": { "arrowheads": true },
  "groups": [
    { "id": "aws", "label": "AWS eu-west-1", "kind": "region" },
    { "id": "app", "label": "Application tier", "kind": "cluster", "parent": "aws", "direction": "TB" }
  ],
  "nodes": [
    { "id": "web", "label": "Web storefront", "kind": "client", "detail": "Next.js" },
    { "id": "orders", "label": "Orders", "kind": "service", "detail": "Go", "parent": "app" },
    { "id": "db", "label": "Orders DB", "kind": "database", "tag": "primary", "parent": "aws" },
    { "id": "stripe", "label": "Stripe", "kind": "external" },
    { "id": "why", "label": "Writes are idempotent", "kind": "note" }
  ],
  "edges": [
    { "from": "web", "to": "orders", "label": "HTTPS", "style": "thick" },
    { "from": "orders", "to": "db", "style": "dashed", "arrow": "both" },
    { "from": "orders", "to": "stripe", "label": "charge" },
    { "from": "why", "to": "db", "style": "dashed", "arrow": "none" }
  ]
}

| type | node kinds (shape / accent) | | --- | --- | | architecture, dataflow | service function database store cache queue client user external, plus note and the rich nodes panel code chip | | workflow | start end step decision io, plus note panel code chip | | lifecycle | initial final state composite choice fork join, plus note; children of a composite set parent to it | | sequence participants | participant actor service database queue external |

  • Direction. Leave out direction and the layout tries TB and LR and keeps the one whose aspect ratio is closer to 16:10; TB (TD), BT, LR, RL pin it. Groups (and lifecycle composites) take their own direction.
  • Arrowheads. Graph edges have none by default (Kit's style: direction reads from the layout); "style": { "arrowheads": true } draws them, and each edge's arrow (end, both, none) applies. Sequences always have heads.
  • Labels wrap at 26 characters; put technology in detail. tag is the small uppercase line ("" hides it). A note is a sticky note; join it with a dashed "arrow": "none" edge.

A sequence, with every part it supports:

{
  "type": "sequence", "title": "Login", "autonumber": true,
  "participants": [{ "id": "u", "label": "User", "kind": "actor" }, { "id": "api", "kind": "service" }, { "id": "db", "kind": "database" }],
  "boxes": [{ "label": "Backend", "participants": ["api", "db"] }],
  "messages": [
    { "id": "req", "from": "u", "to": "api", "label": "POST /login" },
    { "from": "api", "to": "db", "label": "SELECT user" },
    { "from": "db", "to": "api", "label": "row", "kind": "return" },
    { "from": "api", "to": "api", "label": "verify hash", "kind": "self" },
    { "from": "api", "to": "u", "label": "200 + cookie", "kind": "return" },
    { "from": "api", "to": "u", "label": "401", "kind": "return" }
  ],
  "activations": [{ "participant": "api", "start": "req", "end": 5 }],
  "notes": [{ "text": "bcrypt, cost 12", "right": "api", "after": 3 }],
  "frames": [{ "kind": "alt", "label": "valid", "start": 4, "end": 5, "sections": [{ "label": "else", "start": 5 }] }],
  "bands": [{ "start": 1, "end": 2, "label": "lookup" }]
}

Message kinds: sync (default), async, return, self (automatic when from === to). Message references (start, end, after) are 0-based indices or message ids. Frames: alt opt loop par critical break. Notes sit over one or two participants, left or right.

Mermaid in. flowchart / graph becomes a workflow, sequenceDiagram a sequence and stateDiagram / stateDiagram-v2 a lifecycle; what has no equivalent (classDef, style, click…) is a warning, never a crash.

storyink render examples/mermaid/incident.flowchart.mmd -o incident.html   # render Mermaid directly
storyink mermaid examples/mermaid/order.state.mmd -o order.json           # or convert, then refine the JSON
cat flow.mmd | storyink render - -o flow.html

Examples: examples/ (one per type) and examples/mermaid/. Spec: graph types, sequence, Mermaid import.

Storyboards

Add a story and the HTML viewer plays the diagram as beats: nodes reveal, wires draw on under a travelling pulse, arrivals glow, captions type in, and counters roll (via @kitlangton/rolling-number). The final frame is the static diagram.

"story": {
  "autoplay": false,
  "end": "hold",
  "pace": 0.6,
  "motion": "full",
  "camera": "follow",
  "steps": [
    { "at": 0.3, "reveal": ["web"], "caption": "A shopper presses Pay", "stop": "Request" },
    { "at": "+0.2", "pulse": "web->gateway" },
    { "reveal": ["gateway"], "highlight": { "ids": ["cache"], "for": 1.5 }, "caption": "The gateway checks the session" },
    { "at": "+0.2", "pulse": ["gateway->orders", "gateway->cache"] },
    { "reveal": ["orders"], "counter": { "id": "orders", "to": 1 } },
    { "pulse": { "route": ["orders->events", "events->receipts"] }, "stop": "Persist", "hold": 2 },
    { "id": "volume", "counter": { "id": "orders", "to": 128 }, "caption": "Every checkout runs the same path" }
  ]
}
  • Steps and beats. at is seconds, or "+x" after the previous step ends (default "+0"). Consecutive steps that read as one change form a beat (a pulse and the reveal of its target are one beat); stop names a chapter. Ids never revealed are on screen from the start, as context. A pulse can be one edge, a list (in parallel), a multi-hop route, or { "edge", "duration", "reverse", "delay" }.
  • Auto. "story": "auto" (or --story auto) derives the steps from the graph or message order; { "steps": "auto", "motion": "system" } sets options on it.
  • Playback. A click-to-play gate ("autoplay": true plays when scrolled into view), play/pause, a tape-rewind replay (R) and a scrubber with step and chapter ticks; "end": "loop" loops.
  • Step moves. → plays to the end of the next beat's motion and pauses; ← rewinds at 2× to the previous one; Shift moves by chapter, and repeated presses extend the move. The beat's caption shows at once.
  • Reading holds. After each beat, playback pauses for about 1 s + 0.3 s per word of its caption (1.5–6 s; 0.8 s without one) × pace (default 0.6; 0 for none, 1.5 to slow it; --pace N). "hold": 2 on a step overrides it for that beat. Readers change it with Pauses in the toolbar (None · Short · Normal · Long · Longer, [ / ], remembered; #pace=); the viewer recompiles the timeline in the browser and keeps their place.
  • Motion. Full by default, even when the reader's system asks for reduced motion. "motion": "reduced" always steps; "system" follows prefers-reduced-motion (--motion full|reduced|system). Readers switch with Motion (M, remembered), and #motion=full|reduced overrides everything. Reduced mode plays each beat's settled state, held for its reading time, with no travelling pulses or tweens; the page opens on the final frame.
  • Follow camera. When the whole diagram would render its labels too small, Play zooms to a readable scale and pans from step to step, moving only when the step leaves the middle 80 % of the view, then eases back to fit. Your own zoom is kept; a drag pauses following until the next step that is out of view. Follow (F, remembered), #camera=fit, "camera": "fit" or --camera fit turn it off.
  • Deterministic. Every frame is a pure function of time (storyState(scene, timeline, t)), so #t=2.5 seeks exactly, and snapshots render stills and beat sheets whose gates check that the end frame and the reduced-motion page match the static diagram.
storyink render examples/checkout.architecture.json -o checkout.html          # the spec's own story
storyink render examples/release.workflow.json --story auto --pace 1 -o release.html
storyink snapshot checkout.html --sheet beats --at 2.5,end

Examples: checkout, OAuth. Spec: storyboard, beats, motion, reduced motion, follow camera.

Tones, acts and overlays

Available since 0.6.0. Everything in this section needs storyink 0.6.0 or later; earlier versions do not know these fields. See CHANGELOG.md.

Three example stories in examples/stories/ show the new pieces: request-colours (tones and payload labels), secret-broker (acts, toasts, annotation, HUD) and payment-retry (a code change told as two acts).

Tones: colour carries meaning. Five tones, the same as page tones: note (blue, data in flight), warn (gold, pending), risk (rose, failure), good (sage, done), neutral (ink). A pulse takes a tone and a payload label; tint colours its target on arrival, stain keeps the tone on each wire it has passed, and land: "edge" leaves the label on the wire as its edge label. The tone step colours nodes, groups, edges (and toasts, annotations, HUD metrics) with an optional stagger for a contagion sweep; to: null clears. set / status work on plain graph nodes (detail + tone, a spinner / check / cross on the detail line), and *phrase* in a caption is drawn in the accent.

{ "caption": "Gold: the order is *pending* while payments decides",
  "status": { "id": "orders", "to": "running" },
  "set": { "id": "orders", "detail": "charging card…", "tone": "warn" },
  "pulse": { "edge": "orders->payments", "tone": "warn", "label": "charge $42.00", "tint": true } },
{ "tone": { "ids": ["payments", "orders->payments", "orders->alerts"], "to": "risk", "stagger": 0.15 } },
{ "pulse": { "route": ["client->gateway", "gateway->orders"], "reverse": true, "tone": "good",
             "label": "201 Created", "stain": true, "land": "edge" } }

Payload labels you can read in flight. A label follows a label track: it waits parked clear of the source box until the dot catches up, follows the dot, then waits clear of the destination box until the dot touches it, and fades out after arrival (multi-hop routes hand off between hops instead of drawing over the middle node). A labelled pulse also flies slower, at a steady cruise: 0.6 s + 0.05 s per character of reading time over the part of the flight where the label moves, clamped to 1.2–3.2 s and never faster than an unlabelled pulse; an explicit duration wins. Labels over 32 characters are clipped with "…". Reduced motion has no flights, so no labels.

Acts: problem → rewind → fix. story.acts (2–6, graph diagrams) puts several scenarios on one stage. A step with act starts the next one; by default ("enter": "rewind") the previous act plays backwards to its start, then the topology morphs in place (leaving wires retract, a node fades in in the gap, new wires draw on); the act's own steps then send the request through the fix. The rewind only plays the previous act backwards: nothing replays act 1's steps for you, so write the second pass as steps of its own ("cut" and "continue" are the other entries). Elements say which acts they are in with in: [actIds]; in an act story delta: "removed" means the first act only and delta: "added" every later act, so a change diagram becomes a before / after (as in payment-retry). Positions come from one layout of every act, so nothing jumps; each wire is routed only against the boxes it shares a stage with. Act tiles have their own sheet and anchors: #act=<id> seeks to an act's settled end, #sheet=acts / snapshot --sheet acts lays the acts side by side.

"nodes": [ { "id": "broker", "label": "secret broker", "in": ["after"] }, … ],
"edges": [ { "id": "ask", "from": "agent", "to": "cli", "in": ["before"] }, … ],
"story": {
  "acts": [ { "id": "before", "label": "raw CLI", "tone": "risk" },
            { "id": "after", "label": "with broker", "tone": "good" } ],
  "steps": [ …, { "act": "after", "caption": "Same request, with a secret broker in between" }, … ]
}

Annotations, toasts and HUD metrics. An annotation is one mono line attached above (or below) a node; it can be typed, set to a new text and tone, and after a rewind the same slot can hold the opposite (the leaked key in act 1, a reference in act 2). A toast is a small card near a node (toast step, gone after for seconds or at a dismiss); slots are placed by the layout so a pile never overlaps nodes, labels or each other. A HUD metric is a big label: value number on the stage, driven by the existing counter step: the number that drops between the problem and the fix.

"annotations": [ { "id": "chat", "on": "agent", "text": "chat ▸ sk-live-7Hq2Zx9mK4pL…", "tone": "risk" } ],
"story": {
  "hud": [ { "id": "prompts", "label": "prompts", "value": 0 } ],
  "steps": [
    { "toast": { "id": "p1", "near": "cli", "title": "Allow access?", "text": "vault CLI wants 1 secret", "tone": "warn" },
      "counter": { "id": "prompts", "to": 1 } },
    { "dismiss": "all", "type": { "id": "chat", "cps": 24 } },
    { "toast": { "near": "cli", "title": "approved once ✓", "text": "no more prompts this session", "tone": "good", "for": 2.8 } }
  ]
}

Where each piece shows. The HTML viewer, snapshots, beat sheets and the animated SVG share one timeline, so rewinds, morphs, toasts and labels play everywhere a story plays. The act chip and the HUD are pinned to the HTML stage; the animated SVG draws them in its header row; beat and act sheet tiles show the HUD in their caption line. The static SVG has no header, so it shows neither (tones, annotations and the end state are there); render --svg x.svg --act <id> writes one act's settled end. --act applies only to --svg (open the HTML with #act=<id> instead). A toast still up at the end of an act or the story is a warning.

storyink render examples/stories/secret-broker.architecture.json -o broker.html \
  --svg broker.before.svg --act before
storyink render examples/stories/secret-broker.architecture.json -o broker.html \
  --animated-svg broker.svg --theme both
storyink snapshot broker.html --sheet acts -o shots

Spec: tones, acts, annotations, toasts, HUD. All three examples, with captures and sheets: bun run gallery:stories (writes examples/stories/out/index.html).

Rich nodes and content steps

For agent sessions, generated code and tool servers: panel (a window of rows), code (a syntax-coloured program) and chip nodes in architecture, data-flow and workflow diagrams, with wires anchored to a row (session#exec) or a code line (code#1), and story steps that change what they show.

{
  "type": "architecture", "title": "Code mode", "direction": "LR",
  "groups": [{ "id": "servers", "label": "MCP servers", "bare": true }],
  "nodes": [
    { "id": "session", "kind": "panel", "label": "Session", "icon": "terminal", "size": { "cols": 32, "lines": 6 },
      "rows": [
        { "id": "ask", "tag": "You", "text": "File GitHub bugs in Linear." },
        { "id": "exec", "icon": "wrench", "text": "EXECUTE", "detail": "{ code }", "status": "done" },
        { "id": "found", "text": "tools.github.list_issues", "indent": 1, "muted": true }
      ] },
    { "id": "code", "kind": "code", "label": "Code mode", "lang": "ts",
      "code": ["const issues = await tools.github.list_issues()", "return issues.length"] },
    { "id": "github", "kind": "chip", "label": "github", "icon": "plug", "parent": "servers" },
    { "id": "sentry", "kind": "chip", "label": "sentry", "icon": "plug", "parent": "servers", "muted": true, "stack": 2 }
  ],
  "edges": [
    { "id": "call", "from": "session#exec", "to": "code#1" },
    { "id": "gh", "from": "code", "to": "github" },
    { "from": "code", "to": "sentry" }
  ],
  "story": {
    "pace": 0, "spotlight": true, "rewind": "glitch",
    "steps": [
      { "at": 0.5, "type": "session#ask", "stop": "Ask" },
      { "reveal": "session#exec", "status": { "id": "session#exec", "to": "running" } },
      { "wire": { "edge": "call", "duration": 0.45 } },
      { "type": { "id": "code", "cps": 160 } },
      { "line": "code#1", "pulse": { "edge": "gh", "duration": 0.3 }, "glow": "github" },
      { "pulse": { "edge": "gh", "reverse": true }, "unglow": "github", "line": { "id": "code", "off": true } },
      { "status": { "id": "session#exec", "to": "done" }, "dim": "session#ask" },
      { "set": { "id": "code", "code": ["return 42"] }, "type": "code" }
    ]
  }
}
  • Nodes. Panel rows take tag, icon, text, detail, status (none running done error), indent (0–4) and muted. size: { cols, lines } reserves room, so later content never resizes the box. Icons: wrench plug file terminal search globe bolt user spark. muted: true dims any node at rest; a chip's stack (1–3) draws sheets below it; bare: true turns a group into a column heading.
  • Content steps. type (code by character with a caret, rows by word), set / clear (crossfade content), line (an active-line bar), status (spinner and shimmer, check, cross), dim / undim, hide / show, wire / unwire, glow / unglow and focus. Story options "spotlight": true (a soft light follows the action) and "rewind": "glitch" (HTML only). Finish with spinners done and glows unglowed: the compiler warns when the end state still shows them.

What the two showcases use:

| feature | checkout-recovery | failover | | --- | --- | --- | | type code (caret, syntax colours) and rows (words) | diagnosis + shift scripts, pager row | | | set / clear content swap, line bar | program 1 → program 2, lines 1–2, 3–4, 5 | | | row status running / done / error | triage ✗, shift ✓ | primary ✗, replica ✓ | | row / line wire anchors (oncall#triage → script#1) | session rows → code line 1 | client → router#req | | parallel, reverse and staggered pulses | probes to both pools, replies, 3 requests | multi-hop route | | unwire / wire | retire pool-a, draw run2 | retire primary, draw replica | | dim / undim, highlight, glow, spotlight / focus | failed pool dims, healthy pool lifts | primary dims, replica lit |

storyink render examples/checkout-recovery.architecture.json -o recovery.html --animated-svg recovery.svg --theme both

Examples: checkout-recovery, code-mode, retry-helper, agent-session, failover. Spec: rich nodes, content steps.

Change diagrams

Mark what a change did to each element and storyink colours it, in the static SVG, the HTML and the animated SVG, in both themes.

{
  "type": "dataflow",
  "title": "Batch sending",
  "change": { "base": "main", "head": "feat/batch", "title": "Send broadcasts in batches of 500" },
  "groups": [{ "id": "lib", "label": "broadcast-lib", "kind": "package", "delta": "added" }],
  "nodes": [
    { "id": "queue", "label": "broadcastQueue", "kind": "database", "delta": "modified",
      "stat": { "add": 21, "del": 4 }, "summary": "Queue documents gained batchSize." },
    { "id": "loop", "label": "processBroadcast", "kind": "function", "delta": "removed",
      "files": [{ "path": "functions/src/processBroadcast.ts", "lines": [1, 118], "revision": "base" }] },
    { "id": "bulk", "label": "sendBroadcastBulk", "kind": "function", "delta": "added", "parent": "lib",
      "files": [{ "path": "functions/src/sendBroadcastBulk.ts", "lines": [1, 142] }] },
    { "id": "postmark", "label": "Postmark", "kind": "external", "delta": "unchanged" },
    { "id": "logs", "label": "Logs", "kind": "store", "delta": "unchanged" }
  ],
  "edges": [
    { "from": "queue", "to": "loop", "delta": "removed" },
    { "from": "loop", "to": "postmark", "label": "1 msg/call", "delta": "removed" },
    { "from": "queue", "to": "bulk", "delta": "added" },
    { "from": "bulk", "to": "postmark", "label": "500 msgs/call", "delta": "added", "emphasis": "hero",
      "summary": "10,000 recipients now take 20 requests." },
    { "from": "bulk", "to": "logs", "delta": "added", "emphasis": "muted" }
  ]
}

| delta | look | | --- | --- | | added | sage outline and accent, NEW badge | | modified | gold outline and accent, CHANGED badge | | removed | a ghost: rose dashed outline, faded, label struck through, REMOVED badge | | unchanged | receded context, no badge |

  • delta goes on nodes, groups, edges, participants and messages (a group's label gets · NEW / · CHANGED / · REMOVED).
  • stat: { add, del } shows +21 −4 beside the badge; --changes fills it from files.
  • summary is one line of what changed and why (it becomes auto captions and the drawer text); files are "src/x.ts" or { "path", "lines": n | [a, b], "revision": "head" | "base" } (head-side line numbers; base-side for removed elements).
  • Edges and messages take "emphasis": "hero" (thicker, with a soft glow; keep it to 1–2, more is a warning) or "muted".
  • The root change ({ "base", "head", "title", "url" }) shows main → feat/batch next to an automatic legend of the deltas present; "style": { "legend": false } hides the legend.
  • An added edge touching a removed node is an error. Specs without these fields render byte-identically, and deltas compose with stories, rich nodes and sequences.
storyink render examples/changes/batch-email.dataflow.json -o batch.html --svg batch.svg

Examples: examples/changes/: batch-email, auth-session-to-jwt (a sequence), rate-limit-plugin (deltas on panel, code and chip nodes and on groups). Spec: change diagrams.

Diffs and diff code nodes

Point a change diagram at real code: parse the git diff once, give elements files and line ranges, and let --changes fill in the numbers and embed the hunks. Nothing reads git when the page is viewed.

storyink diff                                    # working tree vs the merge base with origin/HEAD, main or master
storyink diff main...HEAD -o changes.json        # a..b, a...b (from the merge base), or one revision
storyink diff --staged --json                    # the index vs HEAD, printed as JSON
storyink diff v1.2.0 v1.3.0 -- src ':!docs'      # base and head, limited to pathspecs
git diff | storyink diff --patch - -o changes.json
storyink render examples/changes/storyink-core.architecture.json \
  --changes examples/changes/storyink-0.4.0.changes.json -o core.html

storyink diff prints the status, +/− and path of each file; -o writes the changes file (a DiffSet). Untracked files are not included (git add -N them). render --changes takes that file or a .diff / .patch, and:

  • fills stat on elements that have files but no stat;
  • resolves code nodes with "diff": { "file", "lines" } to the hunks in that range (± context, default 3);
  • embeds the hunks any element references (at most 400 lines per file), so the drawer can show them;
  • warns about changed files nothing points at, ranges that touch no hunk, and refs whose path is not in the diff.

Diff code nodes. A code node with diff shows a gutter of old | new line numbers, + / − markers, sage and rose rows with stronger marks on the changed words, @@ headers and syntax colours, folding after max lines (4–200, default 24) with … N more lines.

"nodes": [
  { "id": "state", "label": "storyState(t)", "kind": "service", "delta": "modified",
    "files": [{ "path": "src/core/story/state.ts", "lines": [211, 216] }] },
  { "id": "state-diff", "kind": "code", "label": "story/state.ts", "delta": "modified",
    "diff": { "file": "src/core/story/state.ts", "lines": [213, 216], "context": 2, "max": 24 } },
  { "id": "pay", "kind": "code", "label": "payments/charge.ts", "lang": "ts", "delta": "modified",
    "diff": "@@ -12,3 +12,6 @@ export async function charge(order)\n   const res = await stripe.charge(order)\n-  if (!res.paid) throw new PaymentError(res)\n+  if (!res.paid) {\n+    await retries.enqueue(order.id)\n+  }" },
  { "id": "frame", "label": "contentFrame", "kind": "function", "delta": "added" },
  { "id": "retries", "label": "payment-retries", "kind": "queue", "delta": "added" }
],
"edges": [
  { "id": "cf", "from": "state-diff#+215", "to": "frame", "delta": "added", "emphasis": "hero" },
  { "id": "enqueue", "from": "pay#+14", "to": "retries", "delta": "added" }
],
"story": { "steps": [
  { "at": 0.3, "line": "pay#-13", "caption": "Before: any decline threw", "stop": "Before" },
  { "apply": { "id": "pay", "hunk": 1, "cps": 60 }, "line": { "id": "pay", "off": true }, "stop": "Change" },
  { "line": "pay#+14", "wire": "enqueue" },
  { "apply": "state-diff" }
] }
  • diff is unified hunk text (inline), { "file", "lines", "context", "max" } (resolved by --changes; without it validation fails with a hint), or resolved { "file", "hunks" }.
  • Anchors. node#+14 is head line 14 and node#-13 base line 13; wires can leave from the changed line. node#3 stays the third displayed row.
  • apply plays the change: the node shows the base version until then, removed rows strike, added rows open and type in (cps, default 60), one hunk at a time with "hunk": n. line takes #+14, #-13 or { "id", "hunk" }. set, clear and type don't apply to diff nodes.
  • Languages. ts (default) js json py go rust sql yaml sh text, with the aliases python golang rs yml bash shell zsh typescript tsx javascript jsx; diff nodes default to the file extension's language.

Examples: payment-retry (inline hunks), etl-dedupe (Python and SQL, one hunk at a time), storyink-core with storyink-0.4.0.changes.json. Spec: diffs and --changes, diff code nodes.

Change stories

Play a change instead of showing it all at once: each changed element keeps its before look until a step changes it.

Derive the walkthrough from the deltas:

"story": { "steps": "changes", "spotlight": "veil", "pace": 0.8 }

or write it, with change steps and a focus per step (the ids are payment-retry's; its story, shortened):

"story": {
  "spotlight": "veil",
  "steps": [
    { "at": 0.3, "pulse": { "route": ["c-p", "p-s"] }, "caption": "Checkout charges the card through Payments", "stop": "Before" },
    { "change": ["payments", "pay-ts"], "focus": ["payments", "pay-ts"], "caption": "Now a soft decline is queued", "stop": "Change" },
    { "change": ["retries", "enqueue"], "line": "pay-ts#+14", "focus": ["pay-ts", "retries"] },
    { "change": ["worker", "consume", "worker-ts", "w-code", "w-p"], "line": { "id": "pay-ts", "off": true }, "focus": ["retries", "worker", "payments"], "stop": "Worker" },
    { "pulse": { "route": ["enqueue", "consume", "w-p"] }, "caption": "Declines loop back, off the request path" }
  ]
}

| delta | before | the change step | | --- | --- | --- | | added node / group | absent | reveals it | | added edge / message | absent | draws it on | | removed node | plain | fades to the struck-through ghost | | removed edge / message | a plain wire | retracts, then the dashed ghost fades in | | modified | plain | a flash, then the gold look and CHANGED badge | | diff code node | the base version | apply |

  • "story": "changes" (or { "steps": "changes", … }, --story changes, tool story: "changes") builds: an opening beat on the before state, captioned with change.title; one beat per changed node in data-flow order, with its changed edges and attached diff node (applied); a pulse train along each hero edge; a closing overview ("1 added · 2 changed · 1 removed"). Captions and narration come from each summary. Sequences get one beat per changed message.
  • "spotlight": "veil" dims everything outside a soft cutout around each step's focus (a list is their union box); the cutout glides between steps and is gone in the final frame. HTML, snapshots and the animated SVG share it.
storyink render examples/changes/batch-email.dataflow.json --story changes -o batch-auto.html

Examples: cache-layer (the beat sheet above), batch-email.changes, auth-session-to-jwt.changes, payment-retry (hand-written). Spec: change stories.

Narration and drawer

For PR walkthroughs in the HTML viewer: a narration rail that follows the story, and a drawer with the real hunks behind any changed box.

{ "reveal": ["graph", "panels"], "pulse": "validate->graph",
  "narrate": {
    "heading": "Layout sizes rich boxes",
    "body": "layoutGraph hands rich nodes to the new panels.ts, which measures headers, rows and code so a box fits its largest state and never resizes mid-story.",
    "cites": [
      { "text": "layoutGraph", "ref": "graph" },
      { "text": "panels.ts", "ref": "src/core/layout/panels.ts" }
    ] } }
  • narrate is { heading?, body, cites? } on a story step; heading defaults to the step's stop. Each cite's text must occur in the body, in order; its ref is an element id, node#row, or a file path, path#L12 or path#L12-20. A narrated step without a caption captions with its heading, so beat sheets and the animated SVG show it too.
  • The rail shows "02 / 07", the heading and the body; it follows playback and → / ←. Hovering a cite outlines its element; clicking a file cite opens it in the drawer. It sits beside the stage on wide screens and below it under 760 px; Narration in the toolbar or N toggles it.
  • The drawer opens when you click (not drag) an element with a summary, files or a delta: kind, label, badge, stat, summary and each file ref. Rendered with --changes, each ref shows its hunks as a diff. × or Esc closes it.
  • Both are HTML only. #rail=0|1 and #drawer=<id|path> set them from the URL.
storyink render examples/changes/storyink-0.4.0.pr.json \
  --changes examples/changes/storyink-0.4.0.changes.json -o pr.html
storyink snapshot pr.html --rail --camera follow --at 9        # the rail shot above
storyink snapshot pr.html --drawer panels --camera follow --at end   # the drawer shot

Example: storyink-0.4.0.pr (commit b526d50, 7 narrated beats). Spec: narration and drawer.

Pages

A page ("type": "page") is an offline explainer document rendered from JSON: a summary first, then sections of prose, data blocks and live figures. For PR reviews, plan reviews and recaps.

{
  "type": "page",
  "eyebrow": "Diff review",
  "title": "Payment retries move off the request path",
  "subtitle": "A soft card decline now queues a retry instead of failing the checkout.",
  "summary": "Checkout used to throw on **every** declined charge. Soft declines now wait on a queue.\n\nRecommendation: **approve with two follow-ups**.",
  "change": { "base": "main", "head": "payments/retry-queue" },
  "toc": true,
  "layout": "article",
  "present": true,
  "sections": [
    { "id": "what", "title": "What changed", "eyebrow": "Context", "blocks": [
      { "figure": { "id": "flow", "spec": "../changes/payment-retry.architecture.json",
                    "claim": "Declines now loop back through a queue, **off** the request path.", "wide": true } },
      { "filemap": "changes" }
    ] }
  ]
}

A block is an object with exactly one type key (plus an optional anchor id). One of each:

"blocks": [
  { "prose": "Checkout used to throw on **every** decline. *Soft* ones now retry:\n\n- `insufficient_funds`\n- `processing_error`" },
  { "figure": { "id": "flow", "spec": "flow.architecture.json", "claim": "Declines now loop back through a queue.", "wide": true } },
  { "kpis": [{ "label": "Files", "value": 3, "detail": "1 modified, 2 added" }, { "label": "Lines", "value": "+23 −2", "tone": "good" }] },
  { "table": { "columns": ["Concern", { "label": "Proposed", "align": "left" }], "rows": [["Staleness", { "text": "≤ 10 min", "tone": "warn", "badge": true }]], "caption": "Estimates, not measurements." } },
  { "cards": [{ "title": "Retry worker", "body": "Consumes the queue with backoff.", "tag": "worker", "tone": "good", "delta": "added" }] },
  { "callout": { "tone": "warn", "title": "Still open", "body": "A retried charge needs an idempotency key." } },
  { "filemap": { "files": [{ "path": "src/payments/charge.ts", "status": "modified", "add": 7, "del": 2, "note": "soft declines enqueue a retry" }] } },
  { "diff": { "file": "src/payments/charge.ts", "text": "@@ -13 +13 @@\n-  if (!res.paid) throw err\n+  if (!res.paid) return retry(order)" } },
  { "code": { "code": ["const backoff = (m) => 30 * 2 ** m.attempt"], "lang": "ts", "file": "src/workers/retry.ts", "start": 9 } },
  { "risks": [{ "risk": "A retry can double-bill.", "severity": "high", "area": "payments", "mitigation": "Idempotency key.", "refs": ["src/payments/charge.ts#L13"] }] },
  { "decisions": [{ "decision": "Only soft declines retry.", "why": "Hard declines never succeed.", "confidence": "sourced", "refs": ["src/payments/charge.ts#L14"] }] },
  { "evidence": [{ "claim": "Hard declines still throw.", "source": "src/payments/charge.ts#L14", "status": "verified" }] },
  { "timeline": [{ "when": "Phase 1", "title": "Cache reads behind a flag", "body": "Misses populate the key.", "tone": "note" }] },
  { "checklist": [{ "text": "Worker caps attempts at 3", "done": true }, { "text": "Idempotency key", "note": "blocking" }] },
  { "details": { "summary": "Why not a job table?", "blocks": [{ "prose": "SQS was already in the stack." }] } },
  { "columns": [[{ "prose": "**Before**: every read hits Postgres." }], [{ "prose": "**After**: Redis first, Postgres on a miss." }]] },
  { "scrolly": { "figure": { "spec": "flow.architecture.json" }, "steps": "auto" } },
  { "break": { "title": "Before merging", "layout": "flow" } }
]
  • Data blocks. Tones are semantic: good sage, warn gold, risk rose, note blue, neutral ink. severity is low medium high critical; confidence is sourced inferred unknown (a sourced decision without refs warns); evidence status is verified corrected unsupported unverifiable. Refs such as src/x.ts#L12-20 or src/x.ts:12 render as mono refs. A filemap over 16 files shows the 8 largest and collapses the tree; columns takes 2–3 columns; details is collapsed (opened for print).
  • From a real diff. These read the diff given to render --changes, which also resolves every figure (stat, diff nodes, drawer hunks): { "filemap": "changes" }, { "filemap": { "from": "changes", "notes": { "src/x.ts": "…" } } } and { "diff": { "file": "src/x.ts", "lines": [12, 20], "context": 2, "max": 24 } }.
  • Prose is a safe Markdown subset: paragraphs, - / 1. lists, ### / #### headings, > quotes, bold, italic, code and links. No raw HTML.
  • Figures are the full viewer, embedded: play, step, pan, open the drawer, and Expand to fill the window. spec is a diagram object or a path (JSON or .mmd) relative to the page file; claim is the caption, and should state the point; id defaults to fig-1, fig-2…; wide: true / false forces or prevents breaking out of the text column (by default, diagrams whose labels would be too small there break out); builds: false keeps a figure still in slides. Keys act on the figure you clicked; #fig=<id>&t=9 targets one figure and #fig=<id>&solo=1 shows it alone.
  • Pages are HTML only: --svg / --animated-svg are errors, and --story / --motion / --camera / --pace are ignored with a warning (set them in each figure's spec).
storyink render examples/pages/pr-review.page.json -o pr-review.html
storyink render examples/pages/storyink-0.4.0.page.json \
  --changes examples/changes/storyink-0.4.0.changes.json -o commit-review.html
storyink validate examples/pages/cache-plan.page.json    # the page and every figure
storyink snapshot pr-review.html                          # the full page, per theme
storyink snapshot pr-review.html --figure retry-flow --sheet beats

Examples: pr-review (a diff review), cache-plan (a plan review with columns, table and timeline), storyink-recap (a project recap), storyink-0.4.0 (a commit review rendered with --changes). Spec: pages, pages: viewer and page contract.

Scrollytelling

A scrolly block pins a figure beside prose step cards; scrolling to a card moves the figure's story to that card's beat (forward plays, backward rewinds).

{ "scrolly": {
    "id": "retry",
    "side": "right",
    "figure": { "id": "retry-flow", "spec": "../changes/payment-retry.architecture.json",
                "claim": "Declines loop back through Payments via a queue and a worker." },
    "steps": [
      { "at": "Before", "title": "Before", "body": "Checkout charges the card through **Payments**.",
        "cites": [{ "text": "Payments", "ref": "payments" }] },
      { "at": 2, "body": "Any decline threw a `PaymentError` straight back to checkout." },
      { "at": "Change", "title": "Soft declines wait", "body": "Now a soft decline goes on the new payment-retries queue.",
        "cites": [{ "text": "payment-retries queue", "ref": "retries" }] },
      { "at": "end", "title": "Off the request path", "body": "Declines loop back without holding the request open." }
    ] } }
  • at is a story step id, a chapter stop label, a 1-based beat number, "start" (before the first beat) or "end". Steps whose targets go backwards warn.
  • "steps": "auto" makes one card per narrated step (title = heading, body, cites), or one per chapter stop with its caption, so a story: "changes" or a narrated figure becomes a scrolly for free.
  • The figure needs a story. cites follow the narrate rules; hovering one highlights its element. A scrolly goes directly in a section's blocks (not inside details / columns).
  • Without JavaScript, in print and with #static=1, the steps are listed beside the final frame. #scrolly=<id>&step=<n> opens on step n, settled.
storyink render examples/pages/payment-retry.scrolly.page.json -o scrolly.html
storyink snapshot scrolly.html --scrolly retry      # one 1280×800 frame per step + a sheet

Examples: payment-retry.scrolly, storyink-0.4.0.scrolly ("steps": "auto" from a narrated story; render it with --changes). Spec: scrollytelling, viewer.

Slides

Any page presents as 16:9 slides from the same JSON: Present in the header, P, or #present=1; "layout": "slides" opens presenting. A title slide, one slide per section, and a new one at every break; a story figure's beats become the slide's builds.

{
  "type": "page",
  "title": "Payment retries move off the request path",
  "layout": "slides",
  "sections": [
    { "id": "impact", "title": "Fewer lost checkouts, one new queue", "eyebrow": "Impact",
      "blocks": [{ "kpis": [{ "label": "Files", "value": 3 }, { "label": "Max attempts", "value": 3, "tone": "note" }] }] },
    { "id": "context", "title": "Declines now loop through a queue", "slide": { "layout": "full" },
      "blocks": [{ "figure": { "spec": "../changes/payment-retry.architecture.json", "claim": "Step through the change with →." } }] },
    { "id": "risks", "title": "Risks", "blocks": [
      { "risks": [{ "risk": "A retried charge can double-bill.", "severity": "high" }] },
      { "break": { "title": "Before merging", "layout": "flow" } },
      { "checklist": [{ "text": "Idempotency key on the retried charge", "note": "blocking" }] }
    ] }
  ]
}

| layout | for | picked when | | --- | --- | --- | | title | the title slide (the page header) | slide 1 | | full | one figure filling the slide | the slide is only a figure (± its claim) | | split | text left, figure right | a figure or scrolly with other blocks | | center | a statement, KPIs or a callout, large | no figure and at most 2 short blocks | | flow | everything else, top to bottom | otherwise |

  • Override with "slide": { "layout": … } on a section or layout on a break (auto infers). { "break": true } also works; a continuation slide's title defaults to the section's.
  • Builds. → steps through the beats of the slide's first story figure (or its scrolly's steps) before the next slide; "builds": false on a figure turns that off.
  • Keys. → Space PgDn Enter (next build, then next slide) · ← PgUp Backspace · Home / End · O outline · ? help · Esc back to the article · P present / stop. The chrome shows a progress bar, n / N, prev / next and the slide title.
  • #present=1|0, #slide=<n> (1 = the title slide) and #build=<k> (0 = entry) open a frame; "present": false turns presenting off. Slides whose content would need a scale under 0.7 are reported by the snapshot lint (slide-overflow).
storyink render examples/pages/pr-review.deck.page.json -o deck.html
storyink snapshot deck.html --slides             # one 1280×720 frame per slide + a labelled sheet
storyink snapshot deck.html --slides --builds    # every build state too

Example: pr-review.deck. Spec: slides, viewer.

Animated SVG (READMEs and PRs)

GitHub shows images in READMEs and PR comments as a plain <img> through its camo proxy, so no script runs and nothing outside the file loads. --animated-svg compiles the story into SMIL inside one self-contained SVG that plays there:

storyink render examples/checkout.architecture.json --animated-svg docs/checkout.svg --theme both
# wrote docs/checkout.light.svg, docs/checkout.dark.svg and prints:
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="docs/checkout.dark.svg">
  <img alt="Checkout platform" src="docs/checkout.light.svg">
</picture>
  • An SVG image doesn't follow the page theme, so each file is pinned to one theme; the <picture> picks one. --theme light|dark writes a single file.
  • It loops: story, a 3 s hold on the final frame, a 0.4 s reset. --once plays once and freezes.
  • The attributes' base values are the final frame, so viewers without SMIL show the static diagram.
  • Fonts: --font system (default) uses the system mono stack; --font embed embeds Commit Mono (+~127 KB) for the exact look. See the size table in docs/spec.md.
  • No story in the spec? The auto story is used (with a warning).
  • Library: renderAnimatedSvg(spec, { theme, once, font, hold, reset, fps }) from storyink/core; plugin: storyink_render with animatedSvg: true | "both", once, font.
  • GitHub's image proxy caches by URL: when a diagram changes, give it a new file name (or URL).
  • Rich content (typing, status glyphs, line bars, spotlight), change looks, diff apply and the veil animate too. Frame parity is checked in headless Chrome; Firefox plays it in <img>; Safari is untested. The glitch rewind, the follow camera, the narration rail and the drawer are HTML-only (a narrated step's heading becomes its caption).

Snapshots and the agent loop

storyink snapshot opens the HTML in headless Chrome so you, or an agent, can look before shipping: PNGs per theme, contact sheets, the in-page lint and a receipt with gates.

storyink snapshot out.html                                   # light + dark PNGs and a light|dark sheet
storyink snapshot out.html --sheet beats                     # one tile per beat + the final frame
storyink snapshot out.html --at 1,2.5,end --scale 2          # stills at story times, 2× pixels
storyink snapshot out.html --at 6 --camera follow            # what a reader sees mid-story (16:9)
storyink snapshot out.html --at 6 --motion reduced           # a settled, stepped frame
storyink snapshot out.html --pace 0 --sheet beats            # pin the reading-hold pace
storyink snapshot pr.html --at 9 --rail                      # with the narration rail
storyink snapshot pr.html --at end --drawer panels           # with the drawer open (id or file path)
storyink snapshot page.html                                  # a page: the full page per theme
storyink snapshot page.html --figure fig-1 --sheet beats     # one figure, like a single diagram
storyink snapshot deck.html --slides --builds                # slide and build frames + sheets
storyink snapshot page.html --scrolly all                    # every scrolly step + sheets
storyink snapshot out.html --sheet beats --preview out.jpg   # one compact JPEG for a model

| gate | passes when | | --- | --- | | ready | the page hydrated and its fonts loaded | | lint | no text or label overflow and no overlaps (text-overflow, label-overflow, label-node, label-label, node-node); pages also check clipped page text, and slides slide-overflow | | deterministic | the same frame captured twice is identical | | end=static | t=end matches the static diagram | | reduced=static | the reduced-motion page shows the static diagram | | reduced=stepped | reduced-motion frames mid-story show no pulse in flight | | follow-deterministic | with --camera follow, every at frame re-captures identically |

Exit codes: 0 pass, 1 gate failed