ticket-diagram
Attach a self-contained Excalidraw status diagram to a GitHub epic — read the epic + its sub-issue tree, render boxes-coloured-by-status with dependency arrows to a committed PNG that (a) renders on t
Files1
View on GitHubSKILL.md.claude/skills/ticket-diagram/SKILL.md
GitHubTicket 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
```mermaidblock 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.excalidrawfile 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· labelstatus:in-progress → in_progress·status:blocked → blocked· otherwisepending. - edges —
Blocked-by: #xon childC⇒ 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; Nodezlib+ 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 linkhttps://excalidraw.com/#url=<raw url>.) - One-tap mobile editing = the hosted
ticket-editorWorker (workers/ticket-editor, epic #483). Link…/?slug=<slug>&branch=<branch>&issue=<NN>— pass&issue=<NN>so each Save posts a new comment on issueNNwith that edit's diagram (a timeline of versions, not just silentnuxt-harness[bot]commits). Requires the loose<slug>.excalidrawcommitted (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>/…pngURL re-renders whenever the file is later edited (so a "proposal" comment's picture drifts);…/<commit-sha>/…pngis 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).