Skills / PR Visual Diff

PR Visual Diff

Before/after screenshots and pixel diffs for every frontend PR, so reviewers see what changed and catch what broke.

focus.png from capture.mjs on examples/before and examples/after: the CTA restyle was intended, the shrunken pricing buttons were not.
focus.png from capture.mjs on examples/before and examples/after: the CTA restyle was intended, the shrunken pricing buttons were not.

Examples

Each example is a real run of the skill on a fixture in examples/. Nothing here is mocked up.

Example 1

A CTA restyle that quietly breaks the pricing buttons

YouAdd before/after screenshots to my PR and check for visual regressions.

The PR restyles the hero CTA as a gradient pill, but the same stylesheet edit drops the rule that made pricing-card buttons full width. The hero change is the intended one. The /pricing close-up shows every plan button shrinking to fit its label, which the PR never meant to do. On mobile the longer CTA also pushes "How it works" onto its own line, moving the rest of the page down 71px. /about stays at 0 pixels.

Close-up of /pricing at 375px, base vs head: the full-width 'Get started', 'Start free trial' and 'Talk to sales' buttons become shrink-wrapped pills.
focus.png for /pricing at 375px: the plan buttons lost their full width. The PR didn't set out to change this page.
Base, head and diff of the home page at 375px. The diff boxes the restyled CTA in red and washes everything below it in blue.
compare.png for / at 375px: the CTA change in red, then a blue band because the rest of the page only moved down 71px.
Result
4 changed (1 intended, 1 regression on /pricing at both widths), 2 unchanged
Command
node scripts/capture.mjs --base examples/before --head examples/after --pages /,/pricing,/about --full-page --label-base main --label-head feat/hero-cta
Input
before after
Captured 3 pages at 375 / 1280px (full page): **4 changed**, 0 minor, 2 unchanged. Base: main · Head: feat/hero-cta.

| Page | Width | Result | Pixels changed | Notes |
|---|---:|---|---:|---|
| `/` | 375px | **changed** | 3.08% (5.98% moved) | height 969 → 1039px; 2 changed regions; content from y≈482px moved down 71px |
| `/` | 1280px | **changed** | 0.99% (0.85% moved) | 1 changed region; content from y≈406px moved down 7px |
| `/pricing` | 375px | **changed** | 3.02% (3.53% moved) | height 1249 → 1271px; 3 changed regions; content from y≈555px moved down 7px; content from y≈886px moved down 16px |
| `/pricing` | 1280px | **changed** | 1.23% (0.20% moved) | 3 changed regions; content from y≈576px moved down 7px |
| `/about` | 375px | unchanged | 0% |  |
| `/about` | 1280px | unchanged | 0% |  |

Example 2

A design-token tweak too faint to see by eye

YouDid my theme token change affect anything I didn't expect?

One edit to tokens.css moves the neutrals from gray to slate (#6b7280 to #64748b for muted text, #e5e7eb to #e2e8f0 for borders) and rounds corners by 2px. affected-pages.mjs traced tokens.css to all four pages through their <link> tags and flagged it as a global change. The capture puts every app page in "minor" with the exact colour pair named and the faint pixels traced in amber. The sign-in page, which only uses the brand tokens, stays at 0 pixels, so the change is contained.

Base, head and diff of the dashboard at 375px. Base and head look identical. The diff traces muted text, card borders and table rules in amber.
compare.png for / at 375px: the two screenshots look the same to the eye. Amber marks every muted label, border and pill corner the token change touched.
Close-up of the dashboard nav and cards at up to 2x, labelled 'faint change (mostly #e5e7eb → #e2e8f0)'.
focus.png: each close-up is labelled with the dominant colour pair, so the PR note can say exactly which token moved.
Base, head and diff of the sign-in page at 375px with an empty diff panel and an UNCHANGED badge.
/login, which only uses the brand tokens, diffs at exactly 0 pixels.
Result
0 changed, 6 minor (faint #e5e7eb → #e2e8f0), 2 unchanged (/login)
Command
node scripts/capture.mjs --base examples/theme-tokens/before --head examples/theme-tokens/after --pages /,/invoices,/settings,/login --label-base main --label-head theme/slate-neutrals
Input
theme-tokens/before theme-tokens/after
Captured 4 pages at 375 / 1280px: **0 changed**, 6 minor, 2 unchanged. Base: main · Head: theme/slate-neutrals.

| Page | Width | Result | Pixels changed | Notes |
|---|---:|---|---:|---|
| `/` | 375px | minor | 0% | faint change in 12048 px (below the visible threshold: a colour tweak or a pale element's edge; amber in diff); most common: #e5e7eb → #e2e8f0 |
| `/` | 1280px | minor | 0% | faint change in 19190 px (below the visible threshold: a colour tweak or a pale element's edge; amber in diff); most common: #e5e7eb → #e2e8f0 |
| `/invoices` | 375px | minor | <0.01% | 2 changed regions; faint change in 12066 px (below the visible threshold: a colour tweak or a pale element's edge; amber in diff); most common: #e5e7eb → #e2e8f0 |
| `/invoices` | 1280px | minor | <0.01% | 2 changed regions; faint change in 19777 px (below the visible threshold: a colour tweak or a pale element's edge; amber in diff); most common: #e5e7eb → #e2e8f0 |
| `/settings` | 375px | minor | 0% | faint change in 10656 px (below the visible threshold: a colour tweak or a pale element's edge; amber in diff); most common: #e5e7eb → #e2e8f0 |
| `/settings` | 1280px | minor | 0% | faint change in 12590 px (below the visible threshold: a colour tweak or a pale element's edge; amber in diff); most common: #e5e7eb → #e2e8f0 |
| `/login` | 375px | unchanged | 0% |  |
| `/login` | 1280px | unchanged | 0% |  |

Example 3

A banner added, a card removed, and the page below just moved

YouVisual diff this branch. I swapped the onboarding checklist for a trial banner.

The PR adds a trial banner under the header on every page and removes the "Getting started" checklist from home. A plain pixel diff would paint everything below the banner red. Row alignment instead reports a 50px block added, 303px of content removed, and the rest of the page as moved: down 50px, then up 254px. The headline change stays at 0%, with added, removed and moved pixels counted separately. Reviewers can see that the projects and activity feed below are untouched. On /projects the only change is the banner. Everything under it is a blue band.

Base, head and diff of the home page at 375px. The diff shows the new banner in red, a dashed red bar where the checklist used to be, and a blue wash over everything below.
compare.png for / at 375px: red for the new banner, a dashed bar where the checklist was removed, and blue for content that only moved.
Close-ups labelled 'new block, 50px tall' (the trial banner in head, with a 'new block inserted here' marker on base) and '303px of content removed' (the Getting started checklist in base, gone in head).
focus.png: the added banner and the removed checklist, each with base and head side by side. Base marks where the banner was inserted.
Base, head and diff of /projects at 375px. Only the banner is red. The table below is washed blue.
/projects: only the banner changed. The table is identical, just 50px lower.
Result
1 block added + 303px removed on /; everything else only moved
Command
node scripts/capture.mjs --base examples/layout-shift/before --head examples/layout-shift/after --pages /,/projects --full-page --label-base main --label-head feat/trial-banner
Input
layout-shift/before layout-shift/after
Captured 2 pages at 375 / 1280px (full page): **4 changed**, 0 minor, 0 unchanged. Base: main · Head: feat/trial-banner.

| Page | Width | Result | Pixels changed | Notes |
|---|---:|---|---:|---|
| `/` | 375px | **changed** | 0% (4.13% added, 20.31% removed, 4.37% moved) | height 1212 → 958px; 50px-tall block added at y≈61px; 303px of content removed at y≈220px; content from y≈111px moved down 50px; content from y≈222px moved up 254px |
| `/` | 1280px | **changed** | 0% (5.56% added, 16.39% removed, 1.91% moved) | 50px-tall block added at y≈61px; 218px of content removed at y≈199px; content from y≈111px moved down 50px; content from y≈201px moved up 168px |
| `/projects` | 375px | **changed** | 0% (5.56% added, 2.38% moved) | 50px-tall block added at y≈61px; content from y≈111px moved down 50px |
| `/projects` | 1280px | **changed** | 0% (5.56% added, 0.97% moved) | 50px-tall block added at y≈61px; content from y≈111px moved down 50px |

About this skill

Code review is blind to how a frontend change looks. A one-line CSS edit can restyle the button you meant to change and quietly break three pages you didn't. This skill lets your agent show reviewers the difference: it screenshots the affected pages on the base branch and on your branch, pixel-diffs them at mobile and desktop widths, looks at the results, and writes a "Visual changes" section for the PR that separates what you meant to change from what might be a regression.

What it does

  1. Finds the pages to check. Maps changed files to routes for Next.js (app and pages router), Remix / React Router, SvelteKit, Nuxt, Astro, plain HTML and React Router SPAs, following imports from changed components and stylesheets up to the pages that use them.
  2. Brings up the "before" build in a temporary git worktree on its own port, with dependencies installed by your lockfile's package manager. Your working tree is never touched.
  3. Captures and diffs each page × width with Playwright and pixelmatch, after stabilizing: a virtual clock that freezes JS timers and animation frames identically on both sides, seeded randomness, frozen video/GIFs/animations, and waits for fonts, images and aria-busy. Unchanged pages diff at exactly 0 pixels on 116 of 120 tested pairs (tailwindcss.com, swiperjs.com, playwright.dev, animate.style, an Astro blog and a Next.js app), up from 13 of 32 live-site pairs differing before.
  4. Separates cause from cascade. Rows that only moved are recognised by row alignment and shown as a blue band, so a taller hero is one red box plus "content below y≈482 moved down 71px", not a wall of red. Removed and added blocks are named, faint colour tweaks below the visible threshold are flagged in amber, and focus.png shows each changed region side by side at up to 2x. Also reports size changes, new console errors and pages that start returning 404.
  5. Reviews the evidence. The agent reads every close-up and compare image, describes the changes in plain words, and flags anything that looks unintended.
  6. Writes the PR section with a table, close-ups and side-by-side images. Uploading images (gh --attach, an assets branch) or editing the PR only happens if you say yes.

Try it

add before/after screenshots to my PR
did my CSS refactor change anything visually?
visual diff this branch against main at 375, 768 and 1280
check /settings and /billing for visual regressions before I merge

Requirements

  • Node.js 18+ and git
  • Chromium via Playwright (installed by scripts/setup.sh, ~150 MB once), or a local Chrome/Edge as fallback
  • Your app's own toolchain (pnpm / yarn / npm / bun) to run the base build
  • Optional: gh CLI if you want the agent to update the PR for you
bash scripts/setup.sh

Examples

examples/ holds three worked examples, each a real capture.mjs run on a fictional fixture, with the images and summary.md excerpts listed in examples/examples.json (selected images in examples/output/):

  • hero-cta-regression (before/ → after/): an intended CTA restyle, plus a regression it causes on /pricing that focus.png makes obvious.
  • theme-token-refresh (theme-tokens/): a gray → slate token change too faint to see by eye, reported as minor with the colour pair named (#e5e7eb → #e2e8f0). The sign-in page correctly stays at 0 pixels.
  • banner-replaces-checklist (layout-shift/): a banner added and a card removed. The diff reports "50px-tall block added" and "303px of content removed" and shows the rest of the page as a blue moved band, not a regression.

Run the example

examples/ holds two versions of a tiny static site. The "after" version is a PR called Restyle hero CTA: the hero button becomes a gradient pill (intended), but the same stylesheet change drops the rule that made pricing-card buttons full width (unintended), and the longer CTA pushes the secondary button onto a new line on mobile.

node scripts/capture.mjs --base examples/before --head examples/after \
  --pages /,/pricing,/about --full-page \
  --label-base main --label-head feat/hero-cta

Output in ./.knack/pr-visual-diff/:

summary.md   summary.json
pages/home/375/{base,head,diff,compare,focus}.png
pages/home/1280/...   pages/pricing/375/...   pages/pricing/1280/...   pages/about/...

summary.md starts like this:

Page Width Result Pixels changed Notes
/ 375px changed 3.08% (5.98% moved) height 969 → 1039px; 2 changed regions; content from y≈482px moved down 71px
/ 1280px changed 0.99% (0.85% moved) 1 changed region; content from y≈406px moved down 7px
/pricing 375px changed 3.02% (3.53% moved) height 1249 → 1271px; 3 changed regions; content from y≈555px moved down 7px; content from y≈886px moved down 16px
/pricing 1280px changed 1.23% (0.20% moved) 3 changed regions; content from y≈576px moved down 7px
/about 375px unchanged 0%
/about 1280px unchanged 0%

…followed by, per changed pair, focus.png (the changed regions, base vs head, zoomed) and compare.png (base | head | diff). The pricing close-up shows the regression at a glance: the plan buttons lost their full width. Numbers vary slightly by OS font rendering.

Scripts

Script Purpose
affected-pages.mjs Changed files → candidate routes with confidence and reasons
worktree-serve.mjs start base in a temp worktree, serve head, list, stop --all
capture.mjs Screenshots, diffs, compare images, summary.md / summary.json
publish-assets.mjs Optional: print the gh --attach upload command, or host images on an orphan branch (dry run unless --push)

All support --help.

Limitations

  • Base and head must render the same data. If they talk to different databases, diffs show data, not UI. Mask data regions or point both at the same backend.
  • Logged-in pages need a Playwright storage-state file that you create by logging in yourself (npx playwright codegen --save-storage=auth.json <url>).
  • Route detection is heuristic. Config-based routers and workspace packages need you (or the agent) to pick pages by hand; dynamic routes need real parameter values.
  • Getting images into the PR: drag and drop (works everywhere), gh pr comment --attach (gh 2.99+, push access, github.com only), an assets branch, or your existing CI artifact hosting. See references/pr-images.md.
  • Determinism covers timers, requestAnimationFrame, Date, Math.random, CSS and JS Web Animations, video, GIFs and SMIL. Widgets driven by something else (one demo on tailwindcss.com in our tests), canvas/WebGL and third-party embeds can still differ: mask them. See references/stabilization.md for the measured flake rates.
  • Chromium only. Safari/Firefox rendering differences are out of scope.