Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
"name": "renderdiff",
"source": "./",
"description": "Visual-parity loop for coding agents doing website migrations: screenshot-diff your change against a local build of the base (or production) at several widths and explain every change down to the DOM element and CSS property."
"description": "Visual-parity loop for coding agents doing website migrations: screenshot-diff your change against a local build of the base (or production) at several widths and explain every change down to the DOM element and CSS property; `/renderdiff init` sets up a per-project skill."
}
]
}
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "renderdiff",
"description": "Visual-parity loop for coding agents doing website migrations: screenshot-diff your change against a local build of the base (or production) at several widths and explain every change down to the DOM element and CSS property.",
"description": "Visual-parity loop for coding agents doing website migrations: screenshot-diff your change against a local build of the base (or production) at several widths and explain every change down to the DOM element and CSS property; `/renderdiff init` sets up a per-project skill.",
"version": "0.13.0",
"author": { "name": "Timur Mashara" },
"repository": "https://github.com/tmashara/renderdiff"
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ the change, pixels plus `--computed` for CSS-only work) and reports a verdict, n
npx skills add tmashara/renderdiff # Codex, Cursor, others (skills.sh)
```

For a site you'll check more than once, `/renderdiff init` writes a `renderdiff-<project>`
skill: the deploy's build command, serve commands and ports, route lists, hooked states and
the masks a base-vs-base run proved necessary, so later runs start from there.

### In CI

`uses: tmashara/renderdiff@v0` checks a preview deployment or a build in the job against
Expand Down Expand Up @@ -113,7 +117,7 @@ renderdiff <left-origin> <right-origin> <route…> [options]
| `--left-storage-state <file>`, `--right-storage-state <file>` | That side's storage state, over `--storage-state`. One side alone is fine. |
| `--accept <reason>` | Record this run's differences as accepted, with a reason. See **Accepted differences and reruns**. |
| `--rerun-failed` | Rerun only what the last run in `--out` didn't find clean or accepted, keeping the rest. |
| `--only <route\|file>` | Run only the config's routes with this path (or pair), keeping their hooks and selectors; `'/a @sel'` narrows to that selector. Repeatable. A value naming an existing file (relative to cwd) is read as a route list, one per line. Not with positional routes or `--sitemap`; a value that matches no config route is an error. |
| `--only <route\|file>` | Run only the config's routes with this path (or pair), keeping their hooks and selectors; `'/a @sel'` narrows to that selector. Repeatable. A value naming an existing file (relative to cwd) is read as a route list, one per line, `#` lines skipped. Not with positional routes or `--sitemap`; a value that matches no config route is an error. |
| `--cache-right` | Replay the right side's network traffic from the last `--cache-right` run in `--out`, when it's under an hour old and covers every shot; otherwise load it live and record it. Keeps `<out>/.cache/` when the out dir is wiped. See **Accepted differences and reruns**. |
| `--no-recheck` | Don't reload a differing shot's right side to find content that varies per load. Off anyway under `--cache-right`. |
| `--computed` | Also diff the computed style of every rendered element and its `::before`/`::after`, and links' and controls' `:hover`/`:focus-visible`, and say per route whether the markup is identical apart from asset hashes and origin. Pixels still decide the score and exit code. See [docs/output.md](docs/output.md#computed-styles---computed). |
Expand Down
14 changes: 12 additions & 2 deletions skills/renderdiff/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: renderdiff
description: Visual-parity check for website migrations. Use when a framework migration, CSS or design-system refactor, template rewrite, CMS move or dependency/build upgrade should not change how pages look, to prove it by screenshot-diffing your change against a local build of the base (or production, or any reference) across viewport widths. Also for "did I break the layout", visual regression checks, pixel diffs of two URLs, or running the renderdiff CLI.
description: Visual-parity check for website migrations. Use when a framework migration, CSS or design-system refactor, template rewrite, CMS move or dependency/build upgrade should not change how pages look, to prove it by screenshot-diffing your change against a local build of the base (or production, or any reference) across viewport widths. Also for "did I break the layout", visual regression checks, pixel diffs of two URLs, running the renderdiff CLI, or setting up renderdiff for a project.
allowed-tools: Bash(npx renderdiff:*), Bash(npx renderdiff@latest:*), Bash(renderdiff:*), Read
---

Expand All @@ -13,7 +13,7 @@ npx renderdiff@latest http://localhost:3001 http://localhost:3002 / /about /pric
First origin = left = your change; second = right = the reference. `+` is only/more in your
change, `-` only/more in the reference. Default widths 1440, 1152, 896, 704, 375. First run:
`npx playwright install chromium`. Flags: `--help`. Details: [reference.md](reference.md),
the § named at each step.
the § named at each step. Setting up a project (`/renderdiff init`): [init.md](init.md).

## Pick the mode

Expand All @@ -33,6 +33,9 @@ Pixels decide the score and the exit code in every mode.
`npx serve -l 3002 dist`). Production as the reference is a later second pass. Routes:
every page the change can reach (`--sitemap`); `--dry-run` prints the pairs.
§ Reference and routes; moved page, component, sign-in, click state: § Scoping.
The project has a `renderdiff-<project>` skill: its build/serve commands, ports and config
replace this step and step 2's masks. None, and the project will be run more than once:
offer `/renderdiff init` ([init.md](init.md)) after this check.
2. **Baseline**: base vs base. Before changing code, the untouched change side against the
reference; if the change is already made, the base build served on two ports. Whatever
differs varies per load: mask it (a `varies` line prints the mask; `--mask-media` for
Expand Down Expand Up @@ -89,3 +92,10 @@ recorded them.
For an intended change (a redesign), run with `--threshold 0 --no-recheck` and
`report <out> --markdown pr.md` for the PR description's before/after tables; add
`--base-url <url>` once the out dir is uploaded, so the images render in the PR.

## Feedback

Something in renderdiff itself (CLI, report, this skill) cost time or was wrong: check
`gh issue list -R tmashara/renderdiff --state all` and drop what's already tracked. For the
rest, put an issue draft in chat (title, the run's `renderdiff --version`, routes × widths,
bullets ranked by time lost, each what happened and what you'd want). Never post it.
145 changes: 145 additions & 0 deletions skills/renderdiff/init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# renderdiff init

Writes a `renderdiff-<project>` skill: what this skill can't know about the repo (build, serve,
routes, states, noise), nothing it already says. § names are sections of
[reference.md](reference.md).

```
renderdiff-<project>/
SKILL.md the skeleton below
<app>/renderdiff.config.mjs every setting: built-in options, masks with reason, hooks; the why in comments
<app>/routes.txt grouped, a comment per group, # skipped with the reason
<app>/serve.json the host's URL rules, for `npx serve`
scripts/ only what the CLI and `npx serve` don't cover
```

Settings live in the config, not the SKILL.md: renderdiff rejects unknown config keys, so
drift fails loudly instead of being followed from stale prose.

## Ask (only these)

- **Where the skill lives:** in the repo (`.claude/skills/`, seen by the team and in every
worktree) or local (`~/.claude/skills/`). Not an untracked folder kept out by
`.git/info/exclude`: worktrees don't get it, and runs happen in worktrees.
- **Which apps**, if the repo has several.
- **Whether and where to publish reports**: they show every page; a public reports repo leaks
private ones.
- **The reference**, only if the base can't be built locally.
- Missing secrets or CMS data the build needs.

Don't ask what to serve with (the curl check decides) or about `data-renderdiff-mask` in the
source (product edits for a test).

## Steps

1. **Build.** Take the command from the real deploy (CI workflow, `vercel.json`
`buildCommand`, `netlify.toml`, env like `DEPLOY_ENV`), not from `package.json` alone.
Build the base in a `git worktree` and record the command that worked and its output dir.
2. **Serve.** `npx serve -l <port> -c <absolute path to <app>/serve.json> <dist>` (`-c` is
read against `<dist>`), the `serve.json` mirroring the host's clean URLs, trailing slashes
and redirects (§ Reference and routes). Curl a sample of routes against both and compare
status and final URL with the hosting rules; a script only if `serve` can't match them.
One port per side plus a second base port, per app. Stop with
`kill $(lsof -tiTCP:<port> -sTCP:LISTEN)`: `$!` is npx, not the server. Never `pkill`:
other runs share the machine.
3. **Routes.** The sitemap, plus built `*.html` it misses (404, thank-you, noindex), minus
`http-equiv="refresh"` stubs. Collections: one page per template. A skipped route is its
own line starting with `#`; a `# reason` after a path is read as part of the route.
4. **States**, from the source: menus, hamburger, tabs, tooltips, search, version selectors.
A plain path with `?state=<name>` (no pair, no `@sel`), mapped to a hook in the config
(below). The hook clicks with no `isVisible()` guard and ends waiting for the open state
(§ Scoping); a state that exists only at some widths returns early on the hook's `width`.
A mask for a state route needs its full path, query included, or no `urlPath`.
One component: `'/path @sel'`.
5. **Base vs base** on a sample (one page per template, every state): a list file for
`--only`, so hooks apply (routes on the command line replace the config's). The run finds
the noise (`varies` lines, `renderdiff probe`; `probe` runs only the top-level `before`,
so a state URL is probed closed), not a read of the source. Per noise, the first that
holds: a built-in (`maskMedia`, `freezeVideo`, `lottieFrame`, `hideSelectors`,
`blockUrlPatterns`), a hook that removes the cause on both sides, a mask with a `reason`
(§ Masks, accepts and thresholds). A page past the area ceiling (§ Errors): `@sel` on its
main element, else `# skipped`. Repeat until clean; time it and estimate the full run.
6. **Write the project skill** (skeleton below). Record `renderdiff --version`.
7. **Feedback**: ask whether to add the section below. No: leave it out.
8. **Check** each app with `--config renderdiff.config.mjs --dry-run` (every route read as
meant), then the sample's base-vs-base run from the written skill's own commands.
9. **Report**: what was created, what base vs base checked and on which sample, what not.

```js
import { readFileSync } from "node:fs";
const states = {
menu: async (page, { width }) => {
if (width < 1024) return;
await page.locator("#menu-button").click();
await page.locator("#menu[data-open]").waitFor({ timeout: 5_000 });
},
};
const routes = readFileSync(new URL("./routes.txt", import.meta.url), "utf8")
.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
.map((r) => {
const state = new URL(r, "http://x").searchParams.get("state");
if (state && !states[state]) throw new Error(`routes.txt: no hook for state=${state}`);
return state ? { left: r, before: states[state] } : r;
});
export default {
left: "http://localhost:4011",
right: "http://localhost:4010",
routes,
outDir: "/tmp/renderdiff-<project>-<app>",
};
```

## Project SKILL.md skeleton

The description names the project, so it never competes with `renderdiff`. It retells neither
the loop (modes, reading order, verdict) nor flags: link the § instead.

```markdown
---
name: renderdiff-<project>
description: <project> specifics for renderdiff runs (<apps>). Use with the `renderdiff` skill when a change to <project> should not change how its pages look.
---

# renderdiff on <project>

Load the `renderdiff` skill first and follow its loop, its Feedback included; this adds only
what is specific here. Written for renderdiff <version>. Settings: `<app>/renderdiff.config.mjs`.

## Build
<per app: command from the deploy, output dir, worktree notes, secrets>

## Serve
<command; table: app | change (left) | base (right) | second base; stop by port>

## Routes
<how routes.txt was made (sitemap + unlisted html − stubs, one page per template), to redo
when the site changes>

## Run
<per app: the command from <app>/, in the background; base vs base against the second base
port; shots and minutes for a full run>

## Ignore
<leftover noise that isn't masked, and why; mask reasons live in the configs>

## Cleanup
<stop servers by port; git worktree remove>

## Publish
<only if asked: where, how to shrink, privacy>
```

## Feedback section (with consent)

Added to the project skill as written:

```markdown
## Feedback

At the end of a run, before the final report, only if something cost time or was wrong:
propose one set of edits to this skill as a diff, and apply it only once the user approves.
Replace and delete, not only append; keep SKILL.md under ~150 lines. A new mask or hook goes
in only after a base-vs-base rerun (`--only` the affected routes) is clean. renderdiff newer
than the version above: propose removing what it now does built in. A workaround for a
renderdiff bug links its issue, so it can go once that's fixed.
```
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,8 @@ const HELP = `
keeping the rest. Same origins, routes and widths as that run.
--only <route|file> Run only the config's routes with this path (or pair), their
hooks and selectors kept; '/a @sel' narrows to that selector.
Repeatable. A value naming a file in cwd lists one per line.
Repeatable. A value naming a file in cwd lists one per line,
# lines skipped.
--cache-right Replay the right side's network from the last --cache-right run
in --out, if under an hour old and covering every shot; else load
it live and record it. The page still renders and is shot fresh.
Expand Down Expand Up @@ -479,7 +480,7 @@ function only(values: string[], routes: Route[]): Route[] {
.split(/\r?\n/)
.map((l) => l.trim())
.map((l, i) => [l, `--only ${v}: "${l}" (line ${i + 1})`] as const)
.filter(([l]) => l)
.filter(([l]) => l && !l.startsWith("#"))
: [[v, `--only ${v}`] as const];
if (file && !specs.length) throw new Error(`--only ${v} lists no routes`);
for (const [spec, shown] of specs) {
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/cli-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,14 @@ describe("help and version", () => {
if (!options.includes(f))
problems.push(`the README table lists ${f}, --help's Options don't`);

// yarn install in docs/case-study.md; wrangler and git in docs/output.md
// yarn install in docs/case-study.md; wrangler and git in docs/output.md; gh in skills/renderdiff/SKILL.md
const foreign = new Set([
"--immutable",
"--production-branch",
"--project-name",
"--branch",
"--depth",
"--state",
]);
for (const f of ["README.md", ...md("docs"), ...md("skills")])
for (const flag of flags(read(f)))
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/config-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ describe("--only", () => {

test("a file in cwd lists routes one per line", async () => {
const cwd = cwdOnly();
fs.writeFileSync(path.join(cwd, "routes.txt"), "/a @.x\n\n /b \n");
fs.writeFileSync(path.join(cwd, "routes.txt"), "# sample\n/a @.x\n\n /b \n# /c\n");
const r = await dryIn(cwd, "routes.txt");
assert.equal(r.code, 0, r.stderr);
assert.match(r.stdout, /^Routes: 2$/m);
Expand Down
Loading