pr-visual-diff
Skill0 starsChanged 31 days agoMITView on GitHub
Captures before/after screenshots of the pages a frontend change touches, pixel-diffs them at several viewport widths, and writes a ready-to-paste "Visual changes" section for the pull request with side-by-side base | head | diff images. Brings up the base branch in a throwaway git worktree (never touches the working tree), maps changed files to routes (Next.js, Remix / React Router, SvelteKit, Nuxt, Astro, plain HTML), and flags unintended visual regressions. Use when the user says "add before/after screenshots to my PR", "visual diff this branch", "did my CSS refactor change anything visually?", "screenshot the UI changes for review", "check for visual regressions before I merge", or is opening or updating a PR that changes UI, styles or components.
- Reads credentials
In this repository5 other files it gives its agents.
- a11y-auditSkill
- demo-recorderSkill
- motion-videoSkill
- responsive-qaSkill
- token-driftSkill
The file
We split this skill into the rules it gives an agent
12 rules. About 0 are common, 7 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 35
Don't stash, checkout, or reset.
- Distinctive: line 40
Don't stop servers you didn't start.
- Distinctive: line 65
If nothing maps (SPA with unusual routing), read the diff and pick pages yourself.
- Distinctive: line 67
Keep it to about 3-8 pages.
- Very specific: line 73
Reuse a running dev server: if there is one (ask, or check lsof -nP -iTCP -sTCP:LISTEN | grep -E 'node|bun|deno').
- Very specific: line 88
Run --dry-run first if unsure.
- Very specific: line 95
If it can't start, it prints exactly what to run instead.
- Very specific: line 146
Read every focus.png and compare.png the script lists (changed and minor).
- Distinctive: line 163
If you find regressions, tell the user first, before writing PR text, and offer to fix them.
- Very specific: line 168
Write .knack/pr-visual-diff/pr-section.md from summary.md:
- Distinctive: line 204
Don't promise either.
- Distinctive: line 209
Never commit screenshots to the PR branch.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
PR Visual Diff
Reviewers can't see what a frontend PR looks like. This skill builds the evidence: the same pages, same viewports, base vs head, pixel-diffed, with a plain-language description of what changed and what looks unintended.
Scripts live in ${CLAUDE_SKILL_DIR}/scripts/. Every script supports --help. Output goes
to ./.knack/pr-visual-diff/ in the user's project.
Ground rules
- Never modify the user's working tree. The base build runs in a temp git worktree. Don't stash, checkout, or reset.
- Ask before anything leaves the machine: pushing a branch, editing a PR body, posting a comment. Show the markdown first and get a clear yes for each action.
- Never type the user's passwords. For logged-in pages, ask them to create a storage-state file themselves (step 4).
- Don't stop servers you didn't start. Clean up the ones you did (step 8).
Workflow
1. Setup (once per machine)
bash "${CLAUDE_SKILL_DIR}/scripts/setup.sh"2. Decide the base and the pages
# If a PR already exists, its base branch is the right base:
gh pr view --json baseRefName -q .baseRefName 2>/dev/null
node "${CLAUDE_SKILL_DIR}/scripts/affected-pages.mjs" [--base origin/<base>] [--dir apps/web]It prints candidate routes with confidence (high / medium / low) and the reason chain,
then a PAGES= line. Use it as a starting point, not gospel:
- Dynamic routes (
/blog/[slug],/users/:id) need a real value. Find one from seed data, fixtures, or links on the index page, or ask the user. - "Global change" (Tailwind config, global CSS, theme tokens, root layout) means every page can move. Capture a representative set: home, one form-heavy page, one list/table page.
- If nothing maps (SPA with unusual routing), read the diff and pick pages yourself.
- Widths: default
375,1280. Add768when breakpoint or grid code changed. - Keep it to about 3-8 pages. More is slow and buries the signal.
3. Bring up head (the PR's version)
Head should include uncommitted work, so it runs from the user's tree.
- Reuse a running dev server if there is one (ask, or check
lsof -nP -iTCP -sTCP:LISTEN | grep -E 'node|bun|deno'). - Otherwise start one:
node "${CLAUDE_SKILL_DIR}/scripts/worktree-serve.mjs" serve→ printsHEAD_URL=. - Deployed preview URLs (Vercel/Netlify per-PR previews) work too, for both sides.
4. Bring up base (the "before" version)
node "${CLAUDE_SKILL_DIR}/scripts/worktree-serve.mjs" start [--ref origin/main] [--dir apps/web] [--copy-env]It creates a worktree of the merge-base in the OS temp dir, installs deps (lockfile
decides pnpm / yarn / npm / bun), starts the dev server on a free port (4100+), waits until
it answers, and prints BASE_URL=. Notes:
- Run
--dry-runfirst if unsure; it prints the plan without executing. - The worktree lacks untracked
.envfiles. If the app needs them, tell the user, then re-run with--copy-env(copies.env*into the temp worktree only). - Lockfile unchanged since base?
--link-node-modulesskips the install by symlinking (ignored for Next.js: Turbopack rejects it). - It warns if
/answers HTTP 5xx (app erroring: read the log tail it prints) and copes with dev servers that detach themselves (Astro 7 does when it detects an agent). - If it can't start, it prints exactly what to run instead. Follow that, or pass
--cmd "pnpm dev --port {port}". Port flags per framework:references/frameworks.md. - Static sites without package.json are served by a built-in static server. Plain folders
or
file://URLs also work directly in step 5. - Logged-in pages: ask the user to run
npx playwright codegen --save-storage=auth.json <HEAD_URL>/login, log in themselves, close the window. Pass--storage-state auth.jsonto capture. Cookies onlocalhostare shared across ports; localStorage is per port.
5. Capture and diff
node "${CLAUDE_SKILL_DIR}/scripts/capture.mjs" \
--base "$BASE_URL" --head "$HEAD_URL" \
--pages /,/pricing,/settings --widths 375,1280 \
--label-base "main @ a1b2c3d" --label-head "feat/new-nav"Determinism is on by default: a virtual clock (timers, requestAnimationFrame and Date
advance by identical fixed steps on both sides), seeded Math.random, media that never
plays, CSS/JS animations, GIFs and SMIL frozen or stepped, fonts, images and aria-busy
waited for. Unchanged pages diffed at exactly 0 pixels on 116 of 120 test pairs; what's
left (canvas, embeds, exotic widgets) needs --mask. Details: references/stabilization.md.
Useful flags: --full-page (whole scroll height; use when the change is below the fold),
--mask ".timestamp,.avatar" (paint over dynamic regions), --hide (visibility:hidden),
--wait-for <selector|networkidle|ms>, --settle <ms> (JS time after load, default 2000),
--color-scheme dark, --fail-on-change (exit 2, for CI).
Output per page × width: base.png, head.png, diff.png, compare.png (base | head |
diff, labelled), focus.png (close-ups of the changed regions, base vs head at up to 2x),
plus summary.md and summary.json (change %, plus added %, removed % and moved % reported
separately, regions with kind, shifts, size changes, HTTP status, new console errors,
stability, what was still loading). "Pixels changed" counts only content that differs in
place: a deleted card shows up as "20.31% removed" next to a 0% change, not as a 21% change.
Reading the diff: red = changed pixels (boxed), blue wash = rows that only moved (row alignment recognises "everything below y moved down 70px", so a taller header is one red region plus a blue band, not a page of red), amber = faint change below the visible threshold (a colour-token tweak, or the edge of a pale element). Removed blocks are marked with a dashed red bar where they used to be; added blocks (including a bar inserted above the header or a section appended at the bottom) are solid red, and their close-up marks the insertion point on the base side.
Result per pair: changed (≥ 0.1% of pixels changed, added, removed or moved, a size change, or one solid
cluster: ≥ 40 px², or ≥ 16 px² without a sub-pixel halo, e.g. one changed digit), minor
(scattered or faint differences only), unchanged, error. A 1-2px padding change usually
reads as "moved" (blue band from that element down, ±1px is tolerated as sub-pixel
jitter); focus.png puts an orange box where the move starts.
6. Look at the evidence (do not skip)
Read every focus.png and compare.png the script lists (changed and minor). The
close-up shows what changed; the compare shows where and what it pushed around. For each:
- Is it noise? Third-party embeds, avatars, ads, data that differs between base and
head, "kept changing" / "still loading" notes. Fix with
--mask,--hide,--settleor--wait-forand re-run. Seereferences/stabilization.md. Don't report noise as a change. - Describe it in plain words: "Primary button is now a pink gradient pill",
"Hero CTA wraps to two lines at 375px". Explain cascades with
shiftsin summary.json ("content from y≈360 moved down 70px") instead of listing every moved element; the red region just above the blue band is usually the cause. Name faint (amber) changes too; the note gives the dominant colour pair: "date text colour #6b7280 → #64748b". - Intended or not? Compare against the PR's purpose and
git diff. Anything the PR didn't set out to change (other pages affected by shared CSS, overflow at mobile widths, missing styles, a page that now 404s or throws new console errors) is a possible regression. Openbase.png/head.pngat full size if the compare is too small to judge.
If you find regressions, tell the user first, before writing PR text, and offer to fix them. After a fix, re-run step 5 for the affected pages and look again.
7. Write the PR section
Write .knack/pr-visual-diff/pr-section.md from summary.md:
<!-- pr-visual-diff:start -->
## Visual changes
<one-sentence summary>
**Intended**
- `/` (all widths): primary CTA restyled as a gradient pill; copy now "Start your free trial".
**Please check** (possibly unintended)
- `/pricing` (375 & 1280): plan buttons no longer stretch full width. Cause: `.card .btn` rule removed in `styles.css`.
<the summary.md table and images>
<!-- pr-visual-diff:end -->Show it to the user. Images are relative paths until hosted; pick a hosting option with
the user (details and trade-offs: references/pr-images.md):
- Drag and drop (default, works for private repos): the user drags the focus/compare
PNGs into the PR description on github.com, which uploads them. Open the folder for them
(
open .knack/pr-visual-diff/pageson macOS). - gh attachments (gh 2.99+, push access):
gh pr comment|edit --attachuploads local images and rewrites matching references. Print the command, show it, run after a yes:node "${CLAUDE_SKILL_DIR}/scripts/publish-assets.mjs" --gh-attach <pr-number> - Assets branch (any gh version): pushes images to an orphan branch and rewrites links.
Dry run first, push only after a clear yes:
Then rebuildnode "${CLAUDE_SKILL_DIR}/scripts/publish-assets.mjs" --prefix pr-123 # plan only node "${CLAUDE_SKILL_DIR}/scripts/publish-assets.mjs" --prefix pr-123 --push # → summary.pr.mdpr-section.mdwith the absolute URLs fromsummary.pr.md. - The REST/GraphQL APIs have no image upload, and
gh gist createrejects binary files. Don't promise either.
Updating the PR (only when asked): fetch the body with gh pr view <n> --json body -q .body,
replace the block between the pr-visual-diff markers (or append it), write it to a file,
and run gh pr edit <n> --body-file <file>. Or post it with gh pr comment <n> --body-file <file>.
Never commit screenshots to the PR branch.
8. Clean up
node "${CLAUDE_SKILL_DIR}/scripts/worktree-serve.mjs" stop --all # kills servers it started, removes worktreesSuggest adding .knack/ to .gitignore if it isn't there yet.
Troubleshooting
| Symptom | Fix |
| Every pixel differs | Different data on each side (dev DB vs seed), or a font failed to load. Compare base.png/head.png; mask data regions or point both at the same backend. |
| "page kept changing" | Canvas/WebGL, live data, third-party widget. --mask / --hide it. |
| "still loading after 8s" | Slow or broken images/fonts/video; check base.png/head.png for gaps. |
| Content missing on both sides | It waits on a timer longer than the 2 s JS budget: --settle 5000 or --wait-for <selector>. |
| HTTP 404 on both sides | Wrong path. Check routes with affected-pages.mjs --all. |
| Base won't start | Read the log tail it prints. Usually env vars (--copy-env), a monorepo (--dir), or a compound dev script (--cmd). |
| Login wall in screenshots | --storage-state (step 4). |
| Change at one width only | Breakpoint issue. Often a real regression; add 768 to confirm. |
References
references/stabilization.md: flake sources and fixes, when to mask vs freeze.references/frameworks.md: dev-server port flags and file-to-route rules per framework.references/pr-images.md: image hosting options for PR descriptions, honestly compared.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

