Ticket diagram — an editable, mobile-legible status graph per epic
Turn a GitHub epic's sub-issue tree into a picture: boxes coloured by status, dependency
arrows bound to the boxes, committed as a single PNG that renders in the issue/PR body
on mobile and carries the editable Excalidraw scene embedded inside it — open that PNG
in Excalidraw and you get the live scene to rearrange and hand back.
This is workstream #2 of the human-readable-tickets epic (#479)
made concrete, tracked under epic #483. It
reuses the sign-off / revision loop of #310 (the same loop ui-proposal and schema-review
use): generate → post on the PR → iterate on feedback → approve.
Why Excalidraw + a PNG? Mermaid is disqualified: on the GitHub mobile app a
```mermaid block shows raw code + a perpetual "Loading" spinner (confirmed on #479).
A committed PNG renders everywhere, including mobile, with no stall. The scene is embedded in
the PNG (Excalidraw's own "Embed scene" format), so the image is also the editable source —
no separate verbose .excalidraw file in the repo.
Artifacts (what gets committed)
Per diagram, in writeups/diagrams/:
<slug>.graph.json — the small, diffable semantic source (nodes + status + edges).
This is the human-auditable record; git blame/diffs of what the epic is live here.
<slug>.png — the render + embedded editable scene in one file. Shows in the issue
body on mobile; open it in Excalidraw to edit.
The loose <slug>.excalidraw is not committed (it's gitignored) — the scene rides inside the
PNG. Pass --excalidraw to the generator only if you want a loose copy locally.
The pipeline (two halves)
YOU (over the GitHub MCP) the GENERATOR (deterministic, offline)
──────────────────────── ──────────────────────────────────────
read epic + get_sub_issues ──▶ scripts/ticket-excalidraw.mjs <graph.json>
build the graph JSON → writeups/diagrams/<slug>.graph.json (semantic source)
→ writeups/diagrams/<slug>.png (render + scene embedded)
The split matters: the agent does the GitHub I/O; the script does the graph→artifacts step
(no network at render time, byte-stable output → minimal diffs).
1 · Read the epic → build the graph JSON
For epic #NN: issue_read (get) the epic, then issue_read (get_sub_issues). For each child
read its title, state (open/closed), labels (status:*), and any Blocked-by: #x, #y
line in the body. Then build:
{
"epic": 479,
"slug": "make-tickets-human-readable",
"title": "Make tickets human-readable",
"feedbackUrl": "https://github.com/FriendlyInternet/nuxt-crouton/pull/487",
"nodes": [{ "id": 454, "title": "Schemas + config", "status": "done" }],
"edges": [{ "from": 454, "to": 455 }],
"goal": { "label": "library-catalog.pmcp.dev" }
}
- status per node —
closed → done · label status:in-progress → in_progress ·
status:blocked → blocked · otherwise pending.
- edges —
Blocked-by: #x on child C ⇒ edge { "from": x, "to": C } (arrow points from
blocker to dependent; the generator lays blockers above dependents).
- goal (optional) — add it when the epic's acceptance is a concrete artifact (e.g. a preview
URL). Every terminal node wires into it; it's coloured violet.
- ref (optional, per node) — top-line label override (e.g.
"W2") for nodes that aren't real
issues, like an epic's workstreams before it's decomposed. Defaults to #<id>.
- feedbackUrl (optional) — makes the diagram's footer a clickable "💬 Comment or send edits"
link; pass the PR URL. Defaults to the epic issue URL.
- slug — kebab-case; drives the filenames. Reuse the same slug every regeneration so the diff
stays small and the sticky keeps pointing at the same files.
2 · Generate
node scripts/ticket-excalidraw.mjs /tmp/<slug>.graph.json --out-dir writeups/diagrams
Writes <slug>.graph.json + <slug>.png (PNG via the shared renderer
.claude/skills/ui-proposal/render.mjs, headless Chromium — same as schema-review; the scene is
then embedded into the PNG). Then commit both with /commit, referencing (#NN).
3 · Post the sticky comment & iterate (the #310 loop)
Post the PNG as a single sticky comment on the epic's PR, marked with an HTML anchor, and make
the image a link that opens the scene in Excalidraw:
<!-- ticket-diagram:make-tickets-human-readable -->
### 🗺️ Epic diagram — Make tickets human-readable

**✏️ To edit:** download this PNG and **open it in Excalidraw** — the scene is embedded, so it
opens live; drag boxes (arrows follow), then **export it (Embed scene ON) and attach the PNG to a
comment here**. I'll pull your edits back in.
_Revisions:_
- r1: initial
Reference the PNG by its raw.githubusercontent.com URL on the branch so it renders on mobile.
The human reviews on the diff: they inline-comment, or they edit the scene and attach the
exported PNG to a comment. Both are change requests. Ignore bot/self comments
(user.type === 'Bot').
Approval = a comment containing approve/lgtm. (A 👍 reaction or a label does not
resume the pipeline — only issue_comment triggers resume-on-comment.yml, #572.) On approval,
mark the PR ready / merge per the epic flow.
4 · Round-trip a human edit back IN
When a human attaches an edited, scene-embedded PNG (or a .excalidraw) to a comment, grab its
URL and run the importer — it decodes the scene, re-renders the PNG from that scene (so boxes
they moved stay moved), re-embeds the scene, and writes the committed PNG:
node scripts/ticket-excalidraw-import.mjs <attachment-url-or-path> --slug <slug> --out-dir writeups/diagrams
Commit the PNG and refresh the sticky (append - rN: <what changed>). Pair this with
subscribe_pr_activity: a comment with an attached PNG → run the importer → commit → done.
5 · Keep it current (it's a live view, not a one-shot)
When the epic's children change — a new sub-issue, a child closes, a Blocked-by edge changes —
rebuild the graph JSON and regenerate. Same slug ⇒ the PNG updates in place and graph.json's diff
shows exactly what moved semantically. (Regenerating from graph.json re-lays-out the boxes; if a
human has hand-arranged the scene, prefer the importer in §4 to preserve their layout.)
Status palette
| status | colour | meaning |
|---|
done | 🟩 green | child issue closed |
in_progress | 🟦 blue | status:in-progress label |
blocked | 🟥 red | status:blocked label |
pending | 🟨 yellow | open, not started |
| goal | 🟪 violet | the epic's concrete acceptance artifact |
Files
scripts/ticket-excalidraw.mjs — CLI: graph JSON → .graph.json + scene-embedded .png.
scripts/ticket-excalidraw-import.mjs — CLI: an edited (scene-embedded) PNG / .excalidraw →
re-rendered, re-embedded .png (the round-trip IN).
scripts/lib/excalidraw.mjs — pure emitters: layout() (layered DAG), toExcalidraw() (bound
arrows + bound text + chrome), toSvg()/toHtml() (graph render), sceneToSvg()/sceneToHtml()
(faithful render of an edited scene). No dependencies.
scripts/lib/excalidraw-png.mjs — dependency-free PNG metadata codec: extractScene() /
embedScene() (Excalidraw's "Embed scene" tEXt format; Node zlib + manual chunk parsing).
Gotchas
- The PNG is both the render and the editable source (scene embedded). Never put a
client-rendered diagram (Mermaid/widget) in a GitHub body — it must be a committed image so it
shows on mobile.
- Editing = open the PNG in Excalidraw, not a
#url= deep link — that needed a hosted
.excalidraw, which we no longer commit. (If you want one-tap deep-link editing back, run the
generator with --excalidraw, commit the loose file, and link https://excalidraw.com/#url=<raw url>.)
- One-tap mobile editing = the hosted
ticket-editor Worker (workers/ticket-editor, epic
#483). Link …/?slug=<slug>&branch=<branch>&issue=<NN> — pass &issue=<NN> so each Save
posts a new comment on issue NN with that edit's diagram (a timeline of versions, not just
silent nuxt-harness[bot] commits). Requires the loose <slug>.excalidraw committed (generate
with --excalidraw), since the editor reads it.
- Pin a diagram image you want to stay put to its commit, not the branch. A
raw.githubusercontent.com/<repo>/<branch>/…png URL re-renders whenever the file is later
edited (so a "proposal" comment's picture drifts); …/<commit-sha>/…png is immutable and frozen.
Post the proposal/sticky image pinned to the commit that produced it so the timeline reads
correctly (the editor's per-save comments already do this).
- Round-trip needs "Embed scene" ON in Excalidraw's export, or the importer can't find the
scene (it errors clearly).
- Reuse the slug across regenerations, or you'll orphan the old PNG and the sticky image link.
- If the epic has no sub-issues yet, diagram its workstreams as
ref-labelled nodes (e.g.
W1…W5).