| name | visual-verify |
| description | Check the built UI against the design, rung by rung — capture the real rendered component (Storybook via Playwright on web, the debug preview gallery on an iOS simulator or Android emulator), diff it against the 1:1 design export, and write the verdict the visual-gate Stop hook reads. Use after /build lands a rung, and whenever the gate blocks. Requires .claude/design-gate.json. |
Skill: /visual-verify
ORCHESTRATOR. Capture, diff, write a verdict. Pass/fail is a measured number — a pixel
diff ratio against a threshold. The critic agent's opinion is advisory and never touches
the verdict. VLMs rank pictures reliably and score them unreliably; a gate built on an
agent's judgement of "close enough" drifts every run.
Invocation
/visual-verify # every rung whose builtFrom already passed
/visual-verify <rung-id> # one rung
/visual-verify --diverged <rung-id> "<reason>" # record an intentional divergence
/visual-verify --accept <rung-id> "<reason>" # promote the capture to baseline (human only)
Step 0 — Config
Read .claude/design-gate.json. Absent → tell the user to copy
.claude/design-gate.json.example and fill it in, then STOP. Do not invent a config.
Select rungs: the argument if given; else every rung owning a file edited this session;
else all. Skip any rung whose builtFrom ids have not passed at this commit — a screen
diff on top of a broken atom tells you nothing you can act on. Report what you skipped and
why.
Step 1 — Capture the real render
Same rules everywhere: fixed size, animations off, wait for settle, exactly one image out,
to swarm-report/visual/<rung-id>/candidate.png.
Web — Storybook is the gallery. Start it with --ci (without it the CLI hangs on the
port prompt and opens a browser you did not ask for).
# story rung: crop the component, not the viewport
npx playwright screenshot --selector '#storybook-root > *' \
"$STORYBOOK/iframe.html?id=<story>&viewMode=story" swarm-report/visual/<id>/candidate.png
# url rung: fixed viewport from the config
npx playwright screenshot --viewport-size=1440,900 "<url>" swarm-report/visual/<id>/candidate.png
Inject *{animation:none!important;transition:none!important} before the shot, and collect
console errors during load — Step 2 needs them.
iOS — the debug preview gallery, built into the app behind a launch argument, is the
Storybook equivalent. It boots straight to one rung, bypassing auth and navigation.
xcrun simctl boot "<simulator>"
xcodebuild -scheme "<scheme>" -destination "platform=iOS Simulator,name=<simulator>" build
xcrun simctl launch --console "<udid>" <bundle-id> -MockRung <rung-id>
xcrun simctl io "<udid>" screenshot swarm-report/visual/<id>/candidate.png
Ship the gallery under #if DEBUG so it cannot reach a release build.
Android — the same idea with a @Preview-backed gallery activity.
./gradlew :<module>:installDebug
adb shell am start -n "<activity>" -e rung "<rung-id>"
adb exec-out screencap -p > swarm-report/visual/<id>/candidate.png
No capture tool available on this machine → the rung gets pass: false,
reason: "capture-unavailable". Never a silent pass.
Step 2 — Render-validity pre-gate (before any diff)
All of these, in order. A broken render must be fixed, never graded:
candidate.png exists and is over 200 bytes. (A tight crop of a 44×44 button is
legitimately ~600 bytes — do not set this floor at 1 KB.)
- It parses as a PNG (
file / sips -g pixelWidth / magick identify).
- Width ≥ 8px and height ≥ 8px.
- Not a single flat colour —
magick identify -format "%[fx:standard_deviation]" near 0
means a blank render. No ImageMagick → skip this check and say so in the report.
- Zero fatal console/logcat errors during load.
Any failure → renderValid: false, pass: false, stop this rung. No diff, no critic.
Step 3 — Reference
reference = the accepted baseline (design-ref/baselines/<id>.png) if one exists, else
the rung's ref export.
Know which engine produced that reference — it decides the threshold and half the
findings. Figma exportAsync or a designer export is Figma's own engine: authoritative,
cross-engine against your Chrome/UIKit capture, so the text floor below applies. An
accepted baseline is same-engine: 0.01 holds even on text. A @grida/refig render is
neither authoritative nor stable — it substitutes fonts and ignores instance boolean
overrides, so treat a diff against it as a hint and never as a gate.
Assert the dimensions match the candidate (aspect ratios within 1%). A mismatch is a
run error, not a design failure — the export scale is wrong, so fix the export, not the
UI. Say exactly that; the difference matters, and an agent that "fixes" the UI to match a
mis-scaled reference will destroy correct code.
No reference at all → pass: null, reason: "no-reference". A skipped rung never fails
the gate and never counts as passing either.
Step 4 — Diff
npx odiff-bin --antialiasing --threshold=0.1 <reference> <candidate> swarm-report/visual/<id>/diff.png
# fallback:
magick compare -metric AE <reference> <candidate> swarm-report/visual/<id>/diff.png 2>&1
# ratio = AE / (width * height)
Neither tool available → pass: false, reason: "differ-unavailable", and print the
install line. An unmeasurable rung is never a passing rung.
Threshold by kind, and this is not a fudge factor:
kind | threshold | why |
|---|
opaque | 0.01 | icons, blocks of colour, no glyphs — these really can land near zero |
text | 0.05 | Figma and the browser/UIKit rasterise glyphs differently. Sub-pixel antialiasing and sub-2px glyph advance are irreducible: the same design, implemented perfectly, floors around 0.03–0.05 |
Chasing a text rung below 0.05 against a Figma export is chasing the rasteriser, not a bug.
The one case where 0.01 holds on text is a same-engine comparison — an accepted baseline
captured by this same pipeline. Do not loosen a baseline comparison to 0.05.
If the reference has an alpha channel, flatten it onto the target background with
magick <in> -background '#RRGGBB' -alpha remove -alpha off <out> and check one pixel by
hand before trusting the batch. A plain -flatten can gamma-shift dark pixels (a real
run turned rgb(35,36,37) into rgb(79,79,79) and poisoned thirteen rungs at once).
Step 5 — Critic (advisory)
Spawn visual-critic with the reference and the candidate:
Answer TERSE. Read .claude/agents/visual-critic.md and follow it exactly.
Rung: <id>. Reference: <path>. Candidate: <path>. Passport: design-ref/frames/<id>.md.
It returns ≤8 concrete, named differences. They go in the report so the exec agent knows
what to fix. They never change pass.
Step 6 — Verdict
Write .claude/sessions/latest.visual-verdict.json — this is the only thing
visual-gate.sh reads:
{
"head": "<git rev-parse HEAD>",
"generatedAt": "<ISO-8601>",
"overall": "pass|fail|error",
"rungs": [
{"id":"atom-button-primary","renderValid":true,"diffRatio":0.031,"threshold":0.05,
"pass":true,"reason":"","level":"atom"}
]
}
pass is tri-state: true measured within threshold, false measured over it or
render-invalid, null skipped. overall is pass only when every non-null rung passed.
diffRatio is a 0–1 ratio, never a percentage.
Also write swarm-report/<slug>-visual-<YYYY-MM-DD>.md: per-rung table (ratio vs
threshold, verdict), the critic findings, and any check you skipped for a missing tool.
Escape hatches
--diverged <rung-id> "<reason>" — the implementation intentionally differs from the
design (the design is stale, or the divergence was agreed). Sets pass: true,
reason: "diverged: <reason>". The reason must be a real one, from the human or from a
recorded decision. An agent may not talk itself into this to clear a block.
--accept <rung-id> "<reason>" — promote the current capture to
design-ref/baselines/<id>.png, so later runs diff same-engine at 0.01. Only when the
human asked for it in their own words in this session. A failing gate is not a request.
Never run it on your own initiative.
Loop guards
- One capture retry per rung per run. Two failures → report and move on.
- Same HEAD, unchanged candidate → reuse the diff, refresh the timestamp only.
- Never re-run the whole ladder to re-check one rung.
- The gate blocks at most twice per commit and then stands down — it nags, it does not trap.