Visual Acceptance
A coding agent can write the UI. This skill makes it prove the UI is right.
Reference design → real browser → visual inspection → diagnosis → code fix → re-render → objective verification.
The whole point is to replace "this should be close enough now" with a measurement: a browser screenshot, a pixel comparison, layout and runtime evidence, and an explicit status of PASS, FAIL, PARTIAL or BLOCKED.
The one rule
Never report a visual result you did not measure. If the numbers do not pass, the status is not PASS, no matter how the page looks to you. Conversely, do not chase a PASS by weakening the test (see Hard rules).
Setup
The scripts in this skill need Node 20+ and Playwright with Chromium:
cd <skill-dir> # the folder that contains this SKILL.md
npm install # installs playwright, pixelmatch, pngjs
npx playwright install chromium
If the machine cannot download Chromium, the tool falls back to an installed Chrome or Edge channel; it reports BLOCKED only when no browser can start.
Inputs
Ask for the two things the loop needs if they are not already in the request, then proceed:
- The reference design - a PNG/JPG/WebP export, or a Figma frame the user exported. A Figma URL alone is only usable if a Figma connector or
FIGMA_ACCESS_TOKEN is available; otherwise ask for a PNG export of the frame.
- The target - a URL (
http://localhost:3000, or any deployed URL) and, when it is not the current directory, the project root so source files can be located.
Everything else has defaults: the viewport is inferred from the reference image, dev servers are started or reused automatically, artifacts go to <project>/.visual-acceptance/.
Quick start
# 1. Make sure the page is reachable (start the dev server, or let the audit do it).
node <skill-dir>/scripts/visual-audit.mjs \
--reference ./design/home.png \
--url http://localhost:3000 \
--project .
# 2. Read the report, fix the biggest difference, run the same command again.
cat .visual-acceptance/runs/<run-id>/report.md
Exit codes are part of the contract: 0 PASS, 1 FAIL or PARTIAL, 2 BLOCKED.
Supported inputs:
- reference: PNG, JPG/JPEG, WebP, GIF, AVIF (non-PNG is decoded by the browser)
- target: any URL, plus
--route /pricing
- viewport:
--viewport 390x844 or a preset (mobile-small, mobile, tablet, desktop, desktop-large); repeatable
- several references:
--reference desktop.png --reference mobile.png (paired with viewports), or design.png@390x844 to bind one reference to a viewport
--full-page for long designs, --selector .hero to compare a single element
.visual-acceptance.json, visual-acceptance.config.json or .visual-acceptance/config.json for anything you would otherwise repeat
The loop
Run this loop deliberately. Each step produces evidence that the next step consumes.
- Render the baseline. Run the audit unchanged. This gives the first real numbers: pixel mismatch, diff regions, layout issues, runtime errors, failed assets.
- Inspect the artifacts, not just the numbers. Open
diff.png, overlay.png and regions.png. The overlay (50% reference, 50% actual) is the fastest way to see what moved. Read report.md for the region list and the largest failures.
- Read the DOM evidence (
dom.json) for the elements inside the largest diff regions: bounding boxes, font, size, line height, colour, radius, padding, margin, overflow, object-fit. This is what turns "the hero looks off" into "h1 is 32px but should be 40px".
- Read the runtime evidence (
console.json, network.json, layout.json) before touching CSS. A 404 image, a failed webfont or an uncaught exception explains many "visual" problems and must be fixed first.
- Locate the source. Search the project for the class name, test id, copy text or component name from the DOM evidence. Fix the component or its stylesheet, not global CSS.
- Write down the hypothesis you are acting on, one sentence: "Header is 12px taller than the design because the logo row uses padding instead of a fixed height."
- Fix in priority order (see below) and change as few things as possible, so the next run attributes cause to effect.
- Re-render and re-measure with the same command. The session records the iteration, the files that changed and the verdict against the best result so far.
- React to the verdict.
IMPROVED → continue. UNCHANGED → the hypothesis was wrong; stop and re-diagnose instead of tweaking the same value again. REGRESSED → roll back that iteration (node <skill-dir>/scripts/rollback.mjs --to-best) and try a different hypothesis.
- Stop at the acceptance rules, not at a feeling. When every enforced check passes, report PASS with the numbers. When the iteration budget or the improvement floor is reached without passing, report PARTIAL with the remaining issues.
Two consecutive iterations that move the metric by less than minImprovement (default 0.5% of pixels) mean the loop is stuck: stop and report PARTIAL rather than grinding.
Fix priority order
Always fix in this order; skipping ahead is how agents spend five iterations on a shadow while the layout is wrong.
- missing or broken assets (images, fonts, icons)
- page structure and container widths
- major positioning and grid/flex layout
- spacing (padding, margin, gap)
- typography (family, size, weight, line height, letter spacing, wrapping)
- image geometry (
object-fit, object-position, aspect ratio)
- colours and backgrounds
- borders, radii, shadows
- sub-pixel details
Reading the report
report.json and report.md contain the same data. The per-target metrics are:
| Metric |
Meaning |
pixelMismatch |
Share of compared pixels that differ beyond pixelThreshold, excluding anti-aliased edges and masked regions |
meanPixelDelta |
Average colour distance over the whole page; catches a large area that drifts slightly below the per-pixel threshold |
horizontalOverflow, elementOverflow |
Content wider than the viewport |
clippedText, zeroSize, overlappingInteractive, unexpectedHidden |
Layout defects that change what the user can see or click |
consoleErrors, pageErrors |
Uncaught exceptions and console.error output |
failedAssets, failedFonts, failedResources |
Broken images, failed webfonts, failed or 4xx/5xx requests (/favicon.ico is ignored by default) |
stability |
1 when two captures of the same page differed: the pixel numbers cannot be trusted until the dynamic element is frozen or ignored |
maskCoverage |
Share of the page excluded from the comparison; above 35% a PASS is meaningless and above 60% the run fails |
Pixel mismatch measures rendering, not meaning: a page can render the wrong content perfectly or the right layout with a substituted font. Treat the metrics, the diff regions, the DOM evidence and the runtime log as one signal set, and say which one supports each conclusion.
A reference exported at 2x (Figma's default) is detected and compared at its own
scale: the audit renders at the same device pixel ratio, so the comparison stays
1:1 and the numbers mean what they say. If you force browser.deviceScaleFactor,
the reference is resampled instead, and the report warns that resampling alone
costs 1-2% of pixels on text.
Thresholds are configured, never invented at runtime:
{
"acceptance": {
"pixelMismatch": 0.03,
"meanPixelDelta": 0.02,
"horizontalOverflow": 0,
"consoleErrors": 0,
"failedAssets": 0,
"stability": 0
},
"criticalRegions": [
{ "name": "hero-cta", "selector": ".cta", "pixelMismatch": 0.01 }
]
}
Critical regions matter for small, important elements: a wrong button radius or icon is a fraction of a percent of the page and will pass a page-wide threshold, so pin the region you actually care about.
Dynamic content
Unstable pages cannot be measured. Before blaming the implementation:
- the tool already freezes
Date.now() and Math.random(), disables CSS animations/transitions and carets, pauses videos, waits for fonts, images and network to settle, and verifies that two captures match
- if a region still changes, ignore the container that holds it, not the changing text node:
--ignore '[data-testid="clock"]' or --mask x,y,w,h
- a run that reports
stability: 1 is not a PASS, whatever the pixel number says
Multi-viewport and responsive work
- several viewports in one run:
--viewport 1440x900 --viewport 390x844 with matching references
- a desktop fix that breaks mobile is a REGRESSION and is reported as one; always re-check every active viewport after changing layout code
- check
horizontalOverflow first on narrow viewports: overflow is a hard failure, not a pixel difference
- the reference image decides the default viewport: a 390x844 reference is rendered at 390x844, not at desktop size
Best-score protection and rollback
Every run appends an iteration to .visual-acceptance/session.json with its metrics, status, changed files and a git diff. The audit refuses to treat a worse result as progress and prints the rollback command.
node <skill-dir>/scripts/rollback.mjs --list # iterations, metrics, changed files
node <skill-dir>/scripts/rollback.mjs --to-best # undo the iterations after the best one
node <skill-dir>/scripts/rollback.mjs --iteration 2 --dry-run
Rollback restores only the files those iterations changed, from snapshots taken before each iteration. It never runs git reset, git clean or git checkout, never touches the index, and skips any file whose current content is not what the iteration produced unless --force is given. Note that this also protects edits made during the session: they are restored to the session baseline, so run it deliberately.
Statuses
Report exactly one of these, with the evidence attached:
- PASS - every enforced check passed for every target.
- FAIL - the page rendered and was measured, and at least one enforced check failed.
- PARTIAL - the loop stopped (iteration budget or no further improvement) before passing; list the remaining differences.
- BLOCKED - the audit could not produce trustworthy evidence: no reachable page, no browser, an unreadable or dimension-less reference, an authentication wall, a required backend that is down. State the blocking condition; never convert it into a PASS or a FAIL.
When nothing passes, report the remaining mismatch, the threshold it missed and the regions involved. Do not describe a failure as "very close".
Hard rules
These are not negotiable, and they exist because each one has been used to fake a result:
- never modify, regenerate or replace the reference image to make a run pass
- never widen
pixelMismatch, pixelThreshold or an acceptance rule during a session to turn a FAIL into a PASS
- never mask a large region, hide elements, or delete content to raise the score; masking above 35% is reported, above 60% fails the run
- never disable the stability check to accept an animated page
- never
git reset --hard, git clean -fd, git checkout ., git stash, force push, publish or deploy
- never overwrite pre-existing uncommitted user changes; the audit records them at session start and rollback is per-file and hash-guarded
- never edit files outside the project root, and never touch credentials,
~/.codex, or system configuration
- only stop processes the audit started; reuse a running dev server instead of killing anything on a port
Auth state lives in .visual-acceptance/auth.json (Playwright storageState), is added to .gitignore automatically, and must never be logged, printed or committed. Console, network and report artifacts are redacted (cookies, authorization headers, tokens, passwords) before they are written.
Git safety
Before the first iteration, note git status output. Pre-existing changes are reported in report.json (session.preExistingChanges) and must survive the whole session. When your fix is complete, report the changed files and let the user commit.
References
Read only what the current problem needs:
- references/workflow.md - the full loop in detail, with the exact commands and report fields per step
- references/visual-debugging.md - how to read diff regions, overlays and mismatch numbers, and how to turn them into a hypothesis
- references/responsive.md - breakpoint work, overflow diagnosis, multi-viewport runs
- references/typography.md - font loading, fallbacks, line-height, letter-spacing, wrapping differences
- references/image-debugging.md - crop,
object-fit, aspect ratio, broken and blocking assets
- references/browser-runtime.md - console, network, storage state, dev-server and port handling
- references/safety.md - rollback semantics, artifact layout, redaction, and what must never be automated
Scripts
| Script |
Purpose |
scripts/visual-audit.mjs |
Full loop: render, compare, diagnose, report, record iteration |
scripts/capture.mjs |
Screenshot + evidence only, no reference needed |
scripts/compare.mjs |
Compare two image files (no browser needed for PNG pairs) |
scripts/inspect-dom.mjs |
DOM, boxes and computed styles |
scripts/inspect-runtime.mjs |
Console, page errors, failed requests and assets |
scripts/stabilize.mjs |
Is the page deterministic between captures? |
scripts/report.mjs |
Re-render or summarise an existing run |
scripts/rollback.mjs |
Undo the file changes of recorded iterations |
Each script accepts --help. Config schema: schema/visual-acceptance.schema.json.
Ready-made starting points for the five common jobs (spacing, typography, image
crop, responsive overflow, multi-viewport) are in examples/.
1---2name: visual-acceptance3description: Render a page in a real browser, compare it against a reference design image (PNG/JPG/WebP or a Figma export), then diagnose, fix and verify the difference with pixel, layout, DOM and runtime evidence. Use when asked to make a page match a screenshot, implement a design pixel-perfectly, fix visual or responsive differences from a mockup, run visual QA or a visual regression check against localhost, or check that frontend code matches Figma. Not for backend-only work, API behaviour, or accessibility audits on their own.4---56# Visual Acceptance78A coding agent can write the UI. This skill makes it prove the UI is right.910Reference design → real browser → visual inspection → diagnosis → code fix → re-render → objective verification.1112The whole point is to replace "this should be close enough now" with a measurement: a browser screenshot, a pixel comparison, layout and runtime evidence, and an explicit status of PASS, FAIL, PARTIAL or BLOCKED.1314## The one rule1516Never report a visual result you did not measure. If the numbers do not pass, the status is not PASS, no matter how the page looks to you. Conversely, do not chase a PASS by weakening the test (see [Hard rules](#hard-rules)).1718## Setup1920The scripts in this skill need Node 20+ and Playwright with Chromium:2122```bash23cd <skill-dir> # the folder that contains this SKILL.md24npm install # installs playwright, pixelmatch, pngjs25npx playwright install chromium26```2728If the machine cannot download Chromium, the tool falls back to an installed Chrome or Edge channel; it reports BLOCKED only when no browser can start.2930## Inputs3132Ask for the two things the loop needs if they are not already in the request, then proceed:33341. **The reference design** - a PNG/JPG/WebP export, or a Figma frame the user exported. A Figma URL alone is only usable if a Figma connector or `FIGMA_ACCESS_TOKEN` is available; otherwise ask for a PNG export of the frame.352. **The target** - a URL (`http://localhost:3000`, or any deployed URL) and, when it is not the current directory, the project root so source files can be located.3637Everything else has defaults: the viewport is inferred from the reference image, dev servers are started or reused automatically, artifacts go to `<project>/.visual-acceptance/`.3839## Quick start4041```bash42# 1. Make sure the page is reachable (start the dev server, or let the audit do it).43node <skill-dir>/scripts/visual-audit.mjs \44 --reference ./design/home.png \45 --url http://localhost:3000 \46 --project .4748# 2. Read the report, fix the biggest difference, run the same command again.49cat .visual-acceptance/runs/<run-id>/report.md50```5152Exit codes are part of the contract: `0` PASS, `1` FAIL or PARTIAL, `2` BLOCKED.5354Supported inputs:5556- reference: PNG, JPG/JPEG, WebP, GIF, AVIF (non-PNG is decoded by the browser)57- target: any URL, plus `--route /pricing`58- viewport: `--viewport 390x844` or a preset (`mobile-small`, `mobile`, `tablet`, `desktop`, `desktop-large`); repeatable59- several references: `--reference desktop.png --reference mobile.png` (paired with viewports), or `design.png@390x844` to bind one reference to a viewport60- `--full-page` for long designs, `--selector .hero` to compare a single element61- `.visual-acceptance.json`, `visual-acceptance.config.json` or `.visual-acceptance/config.json` for anything you would otherwise repeat6263## The loop6465Run this loop deliberately. Each step produces evidence that the next step consumes.66671. **Render the baseline.** Run the audit unchanged. This gives the first real numbers: pixel mismatch, diff regions, layout issues, runtime errors, failed assets.682. **Inspect the artifacts, not just the numbers.** Open `diff.png`, `overlay.png` and `regions.png`. The overlay (50% reference, 50% actual) is the fastest way to see what moved. Read `report.md` for the region list and the largest failures.693. **Read the DOM evidence** (`dom.json`) for the elements inside the largest diff regions: bounding boxes, font, size, line height, colour, radius, padding, margin, overflow, `object-fit`. This is what turns "the hero looks off" into "h1 is 32px but should be 40px".704. **Read the runtime evidence** (`console.json`, `network.json`, `layout.json`) before touching CSS. A 404 image, a failed webfont or an uncaught exception explains many "visual" problems and must be fixed first.715. **Locate the source.** Search the project for the class name, test id, copy text or component name from the DOM evidence. Fix the component or its stylesheet, not global CSS.726. **Write down the hypothesis** you are acting on, one sentence: "Header is 12px taller than the design because the logo row uses padding instead of a fixed height."737. **Fix in priority order** (see below) and change as few things as possible, so the next run attributes cause to effect.748. **Re-render and re-measure** with the same command. The session records the iteration, the files that changed and the verdict against the best result so far.759. **React to the verdict.** `IMPROVED` → continue. `UNCHANGED` → the hypothesis was wrong; stop and re-diagnose instead of tweaking the same value again. `REGRESSED` → roll back that iteration (`node <skill-dir>/scripts/rollback.mjs --to-best`) and try a different hypothesis.7610. **Stop at the acceptance rules, not at a feeling.** When every enforced check passes, report PASS with the numbers. When the iteration budget or the improvement floor is reached without passing, report PARTIAL with the remaining issues.7778Two consecutive iterations that move the metric by less than `minImprovement` (default 0.5% of pixels) mean the loop is stuck: stop and report PARTIAL rather than grinding.7980## Fix priority order8182Always fix in this order; skipping ahead is how agents spend five iterations on a shadow while the layout is wrong.83841. missing or broken assets (images, fonts, icons)852. page structure and container widths863. major positioning and grid/flex layout874. spacing (padding, margin, gap)885. typography (family, size, weight, line height, letter spacing, wrapping)896. image geometry (`object-fit`, `object-position`, aspect ratio)907. colours and backgrounds918. borders, radii, shadows929. sub-pixel details9394## Reading the report9596`report.json` and `report.md` contain the same data. The per-target metrics are:9798| Metric | Meaning |99| --- | --- |100| `pixelMismatch` | Share of compared pixels that differ beyond `pixelThreshold`, excluding anti-aliased edges and masked regions |101| `meanPixelDelta` | Average colour distance over the whole page; catches a large area that drifts slightly below the per-pixel threshold |102| `horizontalOverflow`, `elementOverflow` | Content wider than the viewport |103| `clippedText`, `zeroSize`, `overlappingInteractive`, `unexpectedHidden` | Layout defects that change what the user can see or click |104| `consoleErrors`, `pageErrors` | Uncaught exceptions and `console.error` output |105| `failedAssets`, `failedFonts`, `failedResources` | Broken images, failed webfonts, failed or 4xx/5xx requests (`/favicon.ico` is ignored by default) |106| `stability` | 1 when two captures of the same page differed: the pixel numbers cannot be trusted until the dynamic element is frozen or ignored |107| `maskCoverage` | Share of the page excluded from the comparison; above 35% a PASS is meaningless and above 60% the run fails |108109Pixel mismatch measures rendering, not meaning: a page can render the wrong content perfectly or the right layout with a substituted font. Treat the metrics, the diff regions, the DOM evidence and the runtime log as one signal set, and say which one supports each conclusion.110111A reference exported at 2x (Figma's default) is detected and compared at its own112scale: the audit renders at the same device pixel ratio, so the comparison stays1131:1 and the numbers mean what they say. If you force `browser.deviceScaleFactor`,114the reference is resampled instead, and the report warns that resampling alone115costs 1-2% of pixels on text.116117Thresholds are configured, never invented at runtime:118119```json120{121 "acceptance": {122 "pixelMismatch": 0.03,123 "meanPixelDelta": 0.02,124 "horizontalOverflow": 0,125 "consoleErrors": 0,126 "failedAssets": 0,127 "stability": 0128 },129 "criticalRegions": [130 { "name": "hero-cta", "selector": ".cta", "pixelMismatch": 0.01 }131 ]132}133```134135Critical regions matter for small, important elements: a wrong button radius or icon is a fraction of a percent of the page and will pass a page-wide threshold, so pin the region you actually care about.136137## Dynamic content138139Unstable pages cannot be measured. Before blaming the implementation:140141- the tool already freezes `Date.now()` and `Math.random()`, disables CSS animations/transitions and carets, pauses videos, waits for fonts, images and network to settle, and verifies that two captures match142- if a region still changes, ignore the **container** that holds it, not the changing text node: `--ignore '[data-testid="clock"]'` or `--mask x,y,w,h`143- a run that reports `stability: 1` is not a PASS, whatever the pixel number says144145## Multi-viewport and responsive work146147- several viewports in one run: `--viewport 1440x900 --viewport 390x844` with matching references148- a desktop fix that breaks mobile is a REGRESSION and is reported as one; always re-check every active viewport after changing layout code149- check `horizontalOverflow` first on narrow viewports: overflow is a hard failure, not a pixel difference150- the reference image decides the default viewport: a 390x844 reference is rendered at 390x844, not at desktop size151152## Best-score protection and rollback153154Every run appends an iteration to `.visual-acceptance/session.json` with its metrics, status, changed files and a git diff. The audit refuses to treat a worse result as progress and prints the rollback command.155156```bash157node <skill-dir>/scripts/rollback.mjs --list # iterations, metrics, changed files158node <skill-dir>/scripts/rollback.mjs --to-best # undo the iterations after the best one159node <skill-dir>/scripts/rollback.mjs --iteration 2 --dry-run160```161162Rollback restores only the files those iterations changed, from snapshots taken before each iteration. It never runs `git reset`, `git clean` or `git checkout`, never touches the index, and skips any file whose current content is not what the iteration produced unless `--force` is given. Note that this also protects edits made *during* the session: they are restored to the session baseline, so run it deliberately.163164## Statuses165166Report exactly one of these, with the evidence attached:167168- **PASS** - every enforced check passed for every target.169- **FAIL** - the page rendered and was measured, and at least one enforced check failed.170- **PARTIAL** - the loop stopped (iteration budget or no further improvement) before passing; list the remaining differences.171- **BLOCKED** - the audit could not produce trustworthy evidence: no reachable page, no browser, an unreadable or dimension-less reference, an authentication wall, a required backend that is down. State the blocking condition; never convert it into a PASS or a FAIL.172173When nothing passes, report the remaining mismatch, the threshold it missed and the regions involved. Do not describe a failure as "very close".174175## Hard rules176177These are not negotiable, and they exist because each one has been used to fake a result:178179- never modify, regenerate or replace the reference image to make a run pass180- never widen `pixelMismatch`, `pixelThreshold` or an acceptance rule during a session to turn a FAIL into a PASS181- never mask a large region, hide elements, or delete content to raise the score; masking above 35% is reported, above 60% fails the run182- never disable the stability check to accept an animated page183- never `git reset --hard`, `git clean -fd`, `git checkout .`, `git stash`, force push, publish or deploy184- never overwrite pre-existing uncommitted user changes; the audit records them at session start and rollback is per-file and hash-guarded185- never edit files outside the project root, and never touch credentials, `~/.codex`, or system configuration186- only stop processes the audit started; reuse a running dev server instead of killing anything on a port187188Auth state lives in `.visual-acceptance/auth.json` (Playwright `storageState`), is added to `.gitignore` automatically, and must never be logged, printed or committed. Console, network and report artifacts are redacted (cookies, authorization headers, tokens, passwords) before they are written.189190## Git safety191192Before the first iteration, note `git status` output. Pre-existing changes are reported in `report.json` (`session.preExistingChanges`) and must survive the whole session. When your fix is complete, report the changed files and let the user commit.193194## References195196Read only what the current problem needs:197198- [references/workflow.md](references/workflow.md) - the full loop in detail, with the exact commands and report fields per step199- [references/visual-debugging.md](references/visual-debugging.md) - how to read diff regions, overlays and mismatch numbers, and how to turn them into a hypothesis200- [references/responsive.md](references/responsive.md) - breakpoint work, overflow diagnosis, multi-viewport runs201- [references/typography.md](references/typography.md) - font loading, fallbacks, line-height, letter-spacing, wrapping differences202- [references/image-debugging.md](references/image-debugging.md) - crop, `object-fit`, aspect ratio, broken and blocking assets203- [references/browser-runtime.md](references/browser-runtime.md) - console, network, storage state, dev-server and port handling204- [references/safety.md](references/safety.md) - rollback semantics, artifact layout, redaction, and what must never be automated205206## Scripts207208| Script | Purpose |209| --- | --- |210| `scripts/visual-audit.mjs` | Full loop: render, compare, diagnose, report, record iteration |211| `scripts/capture.mjs` | Screenshot + evidence only, no reference needed |212| `scripts/compare.mjs` | Compare two image files (no browser needed for PNG pairs) |213| `scripts/inspect-dom.mjs` | DOM, boxes and computed styles |214| `scripts/inspect-runtime.mjs` | Console, page errors, failed requests and assets |215| `scripts/stabilize.mjs` | Is the page deterministic between captures? |216| `scripts/report.mjs` | Re-render or summarise an existing run |217| `scripts/rollback.mjs` | Undo the file changes of recorded iterations |218219Each script accepts `--help`. Config schema: [schema/visual-acceptance.schema.json](schema/visual-acceptance.schema.json).220221Ready-made starting points for the five common jobs (spacing, typography, image222crop, responsive overflow, multi-viewport) are in [examples/](examples/README.md).