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.
Maintainers
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 hunkschange.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
directionand the layout tries TB and LR and keeps the one whose aspect ratio is closer to 16:10;TB(TD),BT,LR,RLpin it. Groups (and lifecycle composites) take their owndirection. - Arrowheads. Graph edges have none by default (Kit's style: direction reads from the layout);
"style": { "arrowheads": true }draws them, and each edge'sarrow(end,both,none) applies. Sequences always have heads. - Labels wrap at 26 characters; put technology in
detail.tagis the small uppercase line (""hides it). Anoteis 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.htmlExamples: 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.
atis 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);stopnames 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-hoproute, 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": trueplays 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;0for none,1.5to slow it;--pace N)."hold": 2on 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"followsprefers-reduced-motion(--motion full|reduced|system). Readers switch with Motion (M, remembered), and#motion=full|reducedoverrides 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 fitturn it off. - Deterministic. Every frame is a pure function of time (
storyState(scene, timeline, t)), so#t=2.5seeks 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,endExamples: 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 shotsSpec: 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(nonerunningdoneerror),indent(0–4) andmuted.size: { cols, lines }reserves room, so later content never resizes the box. Icons:wrenchplugfileterminalsearchglobeboltuserspark.muted: truedims any node at rest; a chip'sstack(1–3) draws sheets below it;bare: trueturns 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/unglowandfocus. Story options"spotlight": true(a soft light follows the action) and"rewind": "glitch"(HTML only). Finish with spinnersdoneand glowsunglowed: 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 bothExamples: 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 |
deltagoes on nodes, groups, edges, participants and messages (a group's label gets· NEW/· CHANGED/· REMOVED).stat: { add, del }shows+21 −4beside the badge;--changesfills it fromfiles.summaryis one line of what changed and why (it becomes auto captions and the drawer text);filesare"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" }) showsmain → feat/batchnext to an automatic legend of the deltas present;"style": { "legend": false }hides the legend. - An
addededge touching aremovednode 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.svgExamples: 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.htmlstoryink 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
staton elements that havefilesbut nostat; - 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" }
] }diffis unified hunk text (inline),{ "file", "lines", "context", "max" }(resolved by--changes; without it validation fails with a hint), or resolved{ "file", "hunks" }.- Anchors.
node#+14is head line 14 andnode#-13base line 13; wires can leave from the changed line.node#3stays the third displayed row. applyplays 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.linetakes#+14,#-13or{ "id", "hunk" }.set,clearandtypedon't apply to diff nodes.- Languages.
ts(default)jsjsonpygorustsqlyamlshtext, with the aliasespythongolangrsymlbashshellzshtypescripttsxjavascriptjsx; 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, toolstory: "changes") builds: an opening beat on the before state, captioned withchange.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 eachsummary. Sequences get one beat per changed message."spotlight": "veil"dims everything outside a soft cutout around each step'sfocus(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.htmlExamples: 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" }
] } }narrateis{ heading?, body, cites? }on a story step;headingdefaults to the step'sstop. Each cite'stextmust occur in the body, in order; itsrefis an element id,node#row, or a filepath,path#L12orpath#L12-20. A narrated step without acaptioncaptions 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
Ntoggles it. - The drawer opens when you click (not drag) an element with a
summary,filesor 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|1and#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 shotExample: 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:
goodsage,warngold,riskrose,noteblue,neutralink.severityislowmediumhighcritical;confidenceissourcedinferredunknown(asourceddecision withoutrefswarns); evidencestatusisverifiedcorrectedunsupportedunverifiable. Refs such assrc/x.ts#L12-20orsrc/x.ts:12render as mono refs. Afilemapover 16 files shows the 8 largest and collapses the tree;columnstakes 2–3 columns;detailsis 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,codeand links. No raw HTML. - Figures are the full viewer, embedded: play, step, pan, open the drawer, and Expand to
fill the window.
specis a diagram object or a path (JSON or.mmd) relative to the page file;claimis the caption, and should state the point;iddefaults tofig-1,fig-2…;wide: true/falseforces or prevents breaking out of the text column (by default, diagrams whose labels would be too small there break out);builds: falsekeeps a figure still in slides. Keys act on the figure you clicked;#fig=<id>&t=9targets one figure and#fig=<id>&solo=1shows it alone. - Pages are HTML only:
--svg/--animated-svgare errors, and--story/--motion/--camera/--paceare 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 beatsExamples: 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." }
] } }atis a story stepid, a chapterstoplabel, 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 chapterstopwith its caption, so astory: "changes"or a narrated figure becomes a scrolly for free.- The figure needs a story.
citesfollow thenarraterules; hovering one highlights its element. A scrolly goes directly in a section'sblocks(not insidedetails/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 sheetExamples: 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 orlayouton abreak(autoinfers).{ "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": falseon 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": falseturns 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 tooExample: 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|darkwrites a single file. - It loops: story, a 3 s hold on the final frame, a 0.4 s reset.
--onceplays 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 embedembeds 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 })fromstoryink/core; plugin:storyink_renderwithanimatedSvg: 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
applyand 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
