Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

visual-diff — replay an agent-authored UI flow across revisions; pixels say where changed, the DOM says what changed

visual-diff

npm versionCIGitHub Actionnodelicense

Replay an agent-authored UI workflow against one or more revisions of a frontend, capture full evidence per step, compute annotated visual and semantic diffs between any two runs, and review them in a live local report where a human leaves feedback the agent reads back.

The package is @beprajwal/visual-diff; the binary is vdiff.

  • Pixels say where changed, the DOM says what changed. A pixel diff finds changed regions, each region is hit-tested against a DOM snapshot to name the responsible element, and those elements are tree-diffed for the specific property change.
  • Flows are declarative YAML with a closed step vocabulary, so two versions of a workflow can be compared structurally without executing either one.
  • Runs are append-only and git-anchored, so any two runs are comparable and a missing point is offered as a backfill rather than an error.
  • The report never executes anything. It appends structured JSON feedback to a file; an agent decides what to do with it.

Quickstart (no install)

Everything runs through npx. Nothing is installed into your project, and nothing is downloaded until you ask for it — the package depends on playwright-core, so npx @beprajwal/visual-diff --help costs one small download rather than a browser bundle.

cd your-project
npx @beprajwal/visual-diff install claude-code # write the visual-diff skill + /vdiff commands into .claude/
npx @beprajwal/visual-diff init # scaffold .visual-diff/config.yaml, gitignore rules, a flow
npx @beprajwal/visual-diff install-browser # one-time Chromium download (the only network step)# edit .visual-diff/config.yaml (your dev command) and .visual-diff/flows/example.yaml
npx @beprajwal/visual-diff run example # replay the flow against the working tree
npx @beprajwal/visual-diff run example --at HEAD~1
npx @beprajwal/visual-diff diff example # findings for the last two runs
npx @beprajwal/visual-diff serve --open # live local report; hand the URL to a human

install <target> takes --dir <path> to target another directory, --force to overwrite files it wrote before that you have since edited, and --dry-run to print what it would write. The agent harnesses are claude-code, codex, opencode and pi; github-actions writes CI workflows instead of skills (see On a pull request). An unrecognised target exits 2 and lists what is supported, and vdiff install --list prints every target with the exact files it would write.

Requires Node 20 or newer.

Install it properly

If you would rather not go through npx every time, install it globally. That puts vdiff on your PATH, so every command in this README works exactly as written, with no prefix.

npm install -g @beprajwal/visual-diff
vdiff install claude-code
vdiff install-browser # one-time Chromium download
vdiff init # scaffold .visual-diff/config.yaml, gitignore rules, example flow

To pin the version per project instead — so everyone on the team and CI run the same one — add it as a dev dependency. The binary lands in node_modules/.bin, which npm run scripts already have on their PATH; from an interactive shell reach it with npx vdiff.

npm install --save-dev @beprajwal/visual-diff
npx vdiff install claude-code

Or start from the skills

The agent skills also install straight off this repo with the open skills CLI, for any harness it supports:

npx skills add beprajwal/visual-diff

That path ships the skills alone; the visual-diff skill tells the agent to reach the CLI through npx @beprajwal/visual-diff until it is installed. vdiff install <harness> remains the fuller install — it composes per-harness frontmatter and the /vdiff slash commands.

The four core commands

vdiff run <flow> [--at <ref>] # replay a flow at the working tree or a historical revision
vdiff diff <flow> [base] [head] # compute findings for a pair (defaults: N-1 vs N)
vdiff serve [--open] # live local report: filmstrip, side-by-side, findings, feedback
vdiff feedback [--json] [--ack] # pull the human comments left in the report

Every command accepts --json and emits a single envelope object on stdout, which is the agent-facing API. Exit codes: 0 success, 1 run or replay failure, 2 config or spec error, 3 an opt-in gate tripped. vdiff diff exits 0 even when findings exist — findings are information, not a gate — and 3 is reachable only from vdiff comment --fail-on, which nothing sets by default.

Supporting commands: vdiff install <target>, vdiff init, vdiff flow new|check <name>, vdiff runs <flow>, vdiff pin|prune <run>, vdiff install-browser.

On a pull request

npx @beprajwal/visual-diff install github-actions # writes .github/workflows/visual-diff{,-baseline}.yml

That is the whole setup. The pull-request workflow replays each flow at the merge-base and at the head, diffs them, uploads the evidence, and leaves one comment per flow that it updates in place on every push. The check stays green: findings are reported, not enforced, until you set fail-on: high or fail-on: any in the workflow.

The pipeline itself lives in a composite action (beprajwal/visual-diff@v<version>) rather than in the file you just installed, so a fix reaches you on the next version bump. The installed workflows are yours — edit them, and a re-install preserves your edits and says so.

- uses: beprajwal/visual-diff@v0.8.0with:
flows: checkout search # default: every flow in .visual-diff/flowsfail-on: none # none | high | anybaseline: auto # auto | cache | replaypublish-branch: ''# set it to embed screenshots in the commentcli: ''# e.g. `npx vdiff` to use the version pinned in package.json

Two commands do the rendering, and both work on their own, in any CI system or none:

vdiff comment <flow> [base] [head] # the diff as markdown: stdout, or --out <file>
vdiff export<flow> [base] [head] # a bundle: findings.json, comment.md, report.html, images/

export --html inline makes report.html self-contained — every image embedded as a data: URI, so the one file is the whole report and can be mailed, attached, or served from anywhere that takes a single object. --html both keeps the linked page and writes report.inline.html beside it. The action forwards this as its html: input.

Neither posts, pushes or uploads anything, and neither takes a token — the CLI renders, the action transports. Two consequences worth knowing before you read a comment and wonder:

  • Screenshots need a URL. GitHub cannot render an image out of a workflow artifact, so by default the comment carries the tables and links to the artifact. Nominate publish-branch and the action pushes that pull request's diff images to it, which is what makes them embeddable.
  • The base side is the merge-base, replayed at that revision with that revision's flow spec — not the base branch tip, which would report other people's changes as yours. visual-diff-baseline.yml caches runs from your default branch so most pull requests restore the base side instead of replaying it; delete that workflow and every pull request replays, which is slower and identical.

The design is in docs/superpowers/specs/2026-08-11-ci-mode-design.md, including what CI mode deliberately still does not do: there is no hosted report and no baseline-approval workflow.

A flow spec

version: 1flow: checkoutbaseUrl: http://localhost:5173viewports: [1280x800, 390x844]network: { mode: replay, har: checkout.har }steps:
- id: cartgoto: /cartwaitFor: "[data-test=cart-list]"mask: ["[data-test=order-date]"]
- id: pay-formclick: "[data-test=pay]"waitFor: "text=Payment"

Step ids are stable and load-bearing: diffs align runs by id, never by index. .visual-diff/flows/ and .visual-diff/config.yaml must be committed; runs, diffs, cache and feedback are ignored.

Flows behind a login

Every replay runs in a clean browser context. Two ways to get past a login screen, neither of which puts a credential in a committed file:

# .visual-diff/config.yaml — a Playwright storage state (cookies + localStorage) every context# starts from. Relative to the project root; it is a session, so it lives in the untracked part# of .visual-diff/. A historical replay reads its flow from git and its session from this file.browser:
storageState: .visual-diff/auth/state.json
# .visual-diff/flows/login.yaml — or log in as a step. `${VAR}` in a fill value is read from the# environment at replay time; the flow keeps the reference, the recorded HAR has the value# scrubbed, and `vdiff run` refuses to start if a referenced variable is unset.steps:
- id: sign-ingoto: /loginfill: { "[name=email]": "${VDIFF_EMAIL}", "[name=password]": "${VDIFF_PASSWORD}" }click: "[type=submit]"waitFor: "[data-test=account-menu]"shoot: false

The storage-state file is what Playwright's context.storageState({ path }) writes after a login; an existing Playwright auth setup project produces one already, and npx playwright open --save-storage=.visual-diff/auth/state.json <url> produces one by hand. meta.json records authenticated: true on runs that used it. Cookies are bound to a host, so a session captured against app.lvh.me needs the flow's baseUrl written as http://app.lvh.me:$PORT — spawn mode then reaches the dev server through that host rather than 127.0.0.1.

Development

The repo is managed with pnpm, pinned by packageManager in package.json — run corepack enable once and the right version is used automatically.

pnpm install
pnpm test# everything
pnpm test:unit # colocated unit + golden tests, no browser
pnpm typecheck
pnpm build # clean dist + tsc emit + report UI bundle + skills + executable bin

pnpm is the development package manager only. Nothing about the published artifact changes: the package still lives on the npm registry, npx @beprajwal/visual-diff still works, and consumers can install it with any client. Two steps stay on npm deliberately — npm pack and npm publish — because the tarball under test must be the one the registry serves, and npm's trusted publishing is what signs the release (see the comments in .github/workflows/release.yml). Consumers using pnpm are handled independently: src/runner/deps.ts ranks pnpm-lock.yaml first when replaying a project, and the composite action detects it too.

The workspace is declared in pnpm-workspace.yaml, and it lists fixtures/app only. fixtures/storefront is deliberately left out: the dogfood pipeline points vdiff at it as if it were a stranger's project and lets the tool install its dependencies, which is the code path every real consumer takes.

Bump a version with pnpm version <patch|minor|major> (npm version behaves identically — both run the lifecycle script and commit the three files it touches) — the version lifecycle script runs scripts/sync-version.mjs, which is the only thing that should ever write TOOL_VERSION in src/version.ts and the version input default in action.yml. Editing package.json by hand skips it, and the release then fails on src/version.test.ts after publishing nothing.

build empties dist/ first. tsc only ever adds to its outDir, so without that step the compiled remains of a deleted module stay on disk and ship to every consumer — the published tree has to stay a function of the source tree.

The runtime dependency is playwright-core; playwright is a devDependency only, because the published package must not make an npx user download browsers before the CLI can print its help. vdiff install-browser fetches Chromium on demand, and the two packages share one browser registry, so a browser installed either way is found by both.

jpeg-js is there for one reason: a Playwright trace stores its screenshots as JPEG, and every other layer of this tool reads a shot as screenshot.png — the store names the file, the diff engine decodes it with pngjs, the report serves it. vdiff e2e converts each frame once at ingest (src/e2e/image.ts), which needs a JPEG decoder; Node ships none and pngjs only encodes PNG. It is pure JavaScript with no dependencies of its own and no install script, so it does not reintroduce the postinstall playwright was dropped for.

The skills live in skills/ as plain markdown — manifest.json naming the ids, one <id>/SKILL.md each. pnpm build:skills copies that tree to dist/skills/ so it ships with the CLI, and fails the build if the manifest names a skill that is not on disk. A harness plugin is only an envelope around this markdown, which is why the markdown is what the package carries.

The composite action is action.yml at the repository root. tests/packaging/action.test.ts parses it alongside the workflows the installer writes and asserts they agree — every input a workflow passes is an input the action declares, and the version it pins is this build's. What a test cannot do is run a composite action, so .github/workflows/dogfood-action.yml does: dispatch it and the packed tarball runs the whole pipeline against fixtures/storefront, capturing a baseline, restoring it from the cache with the runs directory deleted, diffing a real overlay commit, and checking the bundle it produced. It is workflow_dispatch only, for the same reason the slow-path job is.

The README artwork lives in assets/: logo.svg is the mark (also good as the repository avatar and social preview), cover.svg is the banner, and node scripts/render-assets.mjs rasterises both to the PNGs the README embeds. The README points at PNGs, not the SVGs, because npm rewrites relative image paths to raw.githubusercontent.com, which serves SVG as text/plain — an SVG banner renders on GitHub and breaks on the npm page. assets/ is development-only and is not published.

npm pack runs the build (prepack) and produces the tarball a consumer actually gets; tests/packaging/pack.test.ts asserts its shape — executable bin with a shebang, .d.ts present, no sourcemaps, the skills present, and no compiled file without a source file behind it.

Design

The authoritative design document is docs/superpowers/specs/2026-08-08-visual-diff-design.md, with the build breakdown in docs/superpowers/plans/2026-08-08-visual-diff-implementation-plan.md. src/types.ts is the single shared contract every module codes against.

License

MIT

About

diff for your ui changes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages