visual-evidence · git:20260823.d106b14 · 2026-08-23 · sha256 12f7608756bac28e
visual-evidence git:20260823.d106b14A
Immutable. This exact content never changes and is served at /api/v1/blob/12f7608756bac28e.
---
name: visual-evidence
description: Produce a before/after figure for a rendering change in this repo — render both versions through the real canvas-render pipeline, compose them side by side, and attach the PNG to the PR. Use whenever a change moves pixels (edge routing, layout, theme, SVG backend) and AGENTS.md's PR Visual Evidence rule applies.
---
# Before/after figures for a rendering change
AGENTS.md requires visual evidence on a PR whose change has a user-visible
effect. For a routing or layout change that means: the same canvas, rendered
by the code before and after, side by side. A reviewer should see the defect
and the fix without cloning anything.
The whole thing is four steps and about five minutes. The traps below are the
ones that actually cost time — every one of them has produced a wrong or
misleading figure at least once.
## 1. Pick a case the change actually fixes
**Do not eyeball this.** A canvas can look wrong for several reasons at once,
and the biggest offender is usually not the one you fixed — twice, a case
picked by "worst total ink" turned out to be dominated by a defect class the
change did not touch, and the figure showed the same flaw on both sides.
Select by the metric the change targets, and require it to go to zero:
```ts
// throwaway test: score every corpus case before and after, keep the ones
// this change actually repaired
const fixed = Object.keys(before).filter((name) => before[name] > 0 && after[name] === 0)
```
Run it once with the change, once with it stashed, and diff the two maps.
Prefer the smallest case in the list — three nodes reads; eight does not.
## 2. Render both versions through the real pipeline
A throwaway test, because the pipeline needs a measurer and a body parser
that only the test-utils have. Write to a path from the environment so one
file serves both runs:
```ts
// packages/canvas-render/src/layout/__render.test.ts (delete when done)
import { writeFileSync } from 'node:fs'
import { it } from 'vitest'
import { renderSceneToSvg } from '../svg/backend.js'
import { createFakeMeasure } from '../test-utils/fake-measure.js'
import { layoutSpatialCanvas } from './spatial-canvas.js'
it('render', () => {
const scene = layoutSpatialCanvas(canvas, {
measure: createFakeMeasure(),
parseBody: (text) => ({ type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', value: text }] }] }),
appearance: {
resolveNode: () => ({ fill: '#ffffff', stroke: '#404040' }),
resolveEdge: () => ({ stroke: '#d04040' }),
resolveLabel: () => ({ fill: '#303030', fontFamily: 'sans-serif' }),
},
geometry: { paddingPx: 8, labelFontSizePx: 12, minContentWidthPx: 1 },
})
writeFileSync(process.env.OUT as string, renderSceneToSvg(scene))
})
```
```bash
mkdir -p tmp/screenshots
OUT=$PWD/tmp/screenshots/after.svg pnpm vitest run --project canvas-render-node __render
git stash push -q packages/canvas-render/src/layout/edges/spatial-edges.ts # tracked files only
OUT=$PWD/tmp/screenshots/before.svg pnpm vitest run --project canvas-render-node __render
git stash pop -q
```
**Trap — `git stash push` on a path with nothing to stash silently succeeds**,
and `before.svg` comes out identical to `after.svg`. It happens when the
change is already committed, or when you name an untracked file. Symptoms:
the two files are the same size and the figure shows no difference.
- change still uncommitted → `git stash push -q <tracked file>`
- change already committed → `git checkout HEAD~N -- <file>`, then
`git checkout HEAD -- <file>` to restore
- **always** `grep -oE '<polyline points="[^"]*"' before.svg after.svg` and
confirm the paths actually differ before building the image
**Trap — a `git checkout` to restore also reverts edits you have not
committed.** Re-apply them, and check with `git status` before moving on.
## 3. Compose the two panels
**Compose through the script, not by hand:**
```bash
node .claude/scripts/compose-figure.mjs \
--before tmp/screenshots/before.png --after tmp/screenshots/after.png \
--out tmp/screenshots/figure.png \
--before-label "before — 170px through B" --after-label "after — clean path" \
[--ring x1,y1,x2,y2]
```
It **refuses two identical panels** — the trap above, made mechanical — and
prints both digests so the PR body can cite them. `--ring` draws the same
rectangle on both panels, so the reader is not asked to spot the difference
unaided. It works on finished PNGs; the SVG splicing below is for a figure
composed from raw `canvas-render` output.
### Composing an SVG figure by hand
The serializer emits no `fill` on `<rect>` (appearance is assigned, not
invented), and ImageMagick renders an unfilled rect **black**. Inject fills
while splicing:
```python
def body(p):
s = open(p).read()
s = s[s.index('>') + 1 : s.rindex('</svg>')] # strip the root element
return s.replace('<rect ', '<rect fill="#ffffff" stroke="#404040" stroke-width="2" ')
def panel(inner, dx, title, color):
return (f'<g transform="translate({dx},72) translate(-X,-Y)">{inner}</g>'
f'<text x="{dx+20}" y="44" font-family="sans-serif" font-size="21" '
f'font-weight="bold" fill="{color}">{title}</text>')
```
`translate(-X,-Y)` pulls the scene's top-left corner to the panel origin —
read X/Y off the smallest node coordinates in the canvas. Title the panels
with the *finding*, not the mechanism: "before — 170px through B" beats
"before — tier order". Red `#c0392b` for before, green `#1e8449` for after.
```bash
convert -density 150 tmp/screenshots/figure.svg tmp/screenshots/figure.png
```
**Trap — ImageMagick is not an SVG renderer, and fails by DROPPING things.**
It ignores `fill-opacity` (a 12%-alpha code panel painted as a solid slab of
the full colour) and it can drop `<text>` outright, reporting only
`non-conforming drawing primitive definition 'SF'` on stderr — which reads
like a warning and is actually "your figure has no words in it". Both were
observed on one markdown-body figure whose SVG was verified correct by
grepping its own `fill=` attributes. Anything relying on alpha or on text
must go through **resvg**, the renderer the product itself uses:
```bash
# from packages/mcp-server, where @resvg/resvg-js and the vendored faces live
node -e "…new Resvg(svg, {font:{fontFiles, loadSystemFonts:false, defaultFontFamily:'Roboto'}})…"
```
Keep ImageMagick for what it is good at — `-border`, `+append`, composing
finished PNGs side by side.
**Trap — resvg only has the vendored Roboto** (ADR-0011), so a figure about
anything the browser renders differently (a mono face, a CJK script) is not
showing what a user sees. When the figure IS about that, render it in the
`web-browser` vitest project instead, where a real measurer and real system
fonts exist, and screenshot the DOM:
```ts
await page.screenshot({ path: '../../tmp/screenshots/figure.png', element: host })
```
Note the path: vite's `server.fs` refuses anything outside the project, so
`/tmp/...` fails with `Access denied` — write inside the repo.
**Trap — a synthetic measurer plus a real rasteriser is a lie.** A figure laid
out with `createFakeMeasure` (0.6em per character) and painted by resvg with
real Roboto metrics puts every glyph at coordinates computed for a font that
is not the one drawing it. It reads as bizarre extra spacing between runs and
looks like a layout bug in the code under test. Measure and paint with the
same face, or do not make the figure.
## 4. Look at it, then attach it
**Read the PNG before uploading.** Half the failures above are invisible in
the SVG text and obvious in the image.
```bash
gh image tmp/screenshots/figure.png # prints the markdown
```
Paste under a `## Visual repro` heading with one sentence naming what to look
at, and say what the figure CANNOT show (a case only reachable behind a flag,
a panel state supplied through a seam rather than by the real producer). Cite
the two digests the script printed: they are what tells a reader the panels
were two different renders. Delete the throwaway test and the intermediate
`.svg` files; keep the PNG in `tmp/screenshots/` until the PR merges (the
GitHub upload is the durable copy).
`gh pr create` is gated on this: a diff touching a surface a human looks at
needs a figure in the body, or one line saying why a picture is the wrong
evidence — `Visual evidence: none — <reason>`. The hook
(`.claude/scripts/hooks/pre-pr-visual-evidence.mjs`) exists because this rule
spent a long time as prose and stopped being followed; it only checks that
SOMETHING is there, so everything above is still yours to get right.
## When a figure is the wrong evidence
If the change is invisible to a human — a schema, an internal helper, a pure
optimisation — do not manufacture a picture. For an optimisation, the honest
evidence is an aggregate that did NOT move plus a benchmark that did; see the
`measured-change` skill.