From 5c5e3c37488f2a7bc2b0f7ca5be7de5d239799be Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:17:22 +0100 Subject: [PATCH 1/2] Skill: /renderdiff init writes a per-project skill; renderdiff feedback as issue drafts Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 4 + skills/renderdiff/SKILL.md | 14 +++- skills/renderdiff/init.md | 128 ++++++++++++++++++++++++++++++++ tests/unit/cli-args.test.ts | 3 +- 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 skills/renderdiff/init.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0412b2f..9c37d91 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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." } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6767732..33aef16 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/README.md b/README.md index ce1affa..9f354c2 100644 --- a/README.md +++ b/README.md @@ -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-` +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 diff --git a/skills/renderdiff/SKILL.md b/skills/renderdiff/SKILL.md index 485614f..65ae07d 100644 --- a/skills/renderdiff/SKILL.md +++ b/skills/renderdiff/SKILL.md @@ -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 --- @@ -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 @@ -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-` 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: + [init.md](init.md) generates one (`/renderdiff init`). 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 @@ -89,3 +92,10 @@ recorded them. For an intended change (a redesign), run with `--threshold 0 --no-recheck` and `report --markdown pr.md` for the PR description's before/after tables; add `--base-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. diff --git a/skills/renderdiff/init.md b/skills/renderdiff/init.md new file mode 100644 index 0000000..86b0706 --- /dev/null +++ b/skills/renderdiff/init.md @@ -0,0 +1,128 @@ +# renderdiff init + +Writes a `renderdiff-` 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-/ + SKILL.md the skeleton below, ~150 lines at most + /renderdiff.config.mjs every setting: built-in options, masks with reason, hooks; the why in comments + /routes.txt grouped, a comment per group, # skipped with the reason + 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 -c ../serve.json `, 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: -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. +4. **States**, from the source: menus, hamburger, tabs, tooltips, search, version selectors. + Map `?state=` to a hook in the config itself (below); the hook clicks with no + `isVisible()` guard and ends waiting for the open state (§ Scoping). One component: + `'/path @sel'`. +5. **Base vs base** on a sample (one page per template, every state). The run finds the + noise (`varies` lines, `renderdiff probe`), 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 goes under `# 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, every state with its hook), then the 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) => { + 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"); + return state ? { left: r, before: states[state] } : r; + }); +export default { left: "http://localhost:4011", right: "http://localhost:4010", routes }; +``` + +## 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- +description: specifics for renderdiff runs (). Use with the `renderdiff` skill when a change to should not change how its pages look. +--- + +# renderdiff on + +Load the `renderdiff` skill first and follow its loop; this adds only what is specific here. +Written for renderdiff . Settings: `/renderdiff.config.mjs`. + +## Build + + +## Serve + + +## Run +/, in the background; base vs base against the second base +port; shots and minutes for a full run> + +## Ignore + + +## Cleanup + + +## Publish + +``` + +## 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. +``` diff --git a/tests/unit/cli-args.test.ts b/tests/unit/cli-args.test.ts index d4aba70..1fc46ff 100644 --- a/tests/unit/cli-args.test.ts +++ b/tests/unit/cli-args.test.ts @@ -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))) From 103c7ccc0f7e65d1f1c9283b807a6ad3954d8d5d Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:21:27 +0100 Subject: [PATCH 2/2] init: fixes from review; --only files skip # lines Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- skills/renderdiff/SKILL.md | 2 +- skills/renderdiff/init.md | 61 ++++++++++++++++++++++------------ src/cli.ts | 5 +-- tests/unit/config-load.test.ts | 2 +- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9f354c2..9bd8758 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ renderdiff [options] | `--left-storage-state `, `--right-storage-state ` | That side's storage state, over `--storage-state`. One side alone is fine. | | `--accept ` | 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 ` | 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 ` | 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 `/.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). | diff --git a/skills/renderdiff/SKILL.md b/skills/renderdiff/SKILL.md index 65ae07d..67b53e0 100644 --- a/skills/renderdiff/SKILL.md +++ b/skills/renderdiff/SKILL.md @@ -35,7 +35,7 @@ Pixels decide the score and the exit code in every mode. § Reference and routes; moved page, component, sign-in, click state: § Scoping. The project has a `renderdiff-` 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: - [init.md](init.md) generates one (`/renderdiff init`). + 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 diff --git a/skills/renderdiff/init.md b/skills/renderdiff/init.md index 86b0706..de1ccfb 100644 --- a/skills/renderdiff/init.md +++ b/skills/renderdiff/init.md @@ -6,9 +6,10 @@ routes, states, noise), nothing it already says. § names are sections of ``` renderdiff-/ - SKILL.md the skeleton below, ~150 lines at most + SKILL.md the skeleton below /renderdiff.config.mjs every setting: built-in options, masks with reason, hooks; the why in comments /routes.txt grouped, a comment per group, # skipped with the reason + /serve.json the host's URL rules, for `npx serve` scripts/ only what the CLI and `npx serve` don't cover ``` @@ -34,35 +35,41 @@ source (product edits for a test). 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 -c ../serve.json `, 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: -sTCP:LISTEN)`: `$!` is npx, not the server. Never - `pkill`: other runs share the machine. +2. **Serve.** `npx serve -l -c /serve.json> ` (`-c` is + read against ``), 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: -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. + `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. - Map `?state=` to a hook in the config itself (below); the hook clicks with no - `isVisible()` guard and ends waiting for the open state (§ Scoping). One component: - `'/path @sel'`. -5. **Base vs base** on a sample (one page per template, every state). The run finds the - noise (`varies` lines, `renderdiff probe`), not a read of the source. Per noise, the first - that holds: a built-in (`maskMedia`, `freezeVideo`, `lottieFrame`, `hideSelectors`, + A plain path with `?state=` (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 goes under `# skipped`. - Repeat until clean; time it and estimate the full run. + (§ 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, every state with its hook), then the base-vs-base run from the written skill's own - commands. + 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) => { + menu: async (page, { width }) => { + if (width < 1024) return; await page.locator("#menu-button").click(); await page.locator("#menu[data-open]").waitFor({ timeout: 5_000 }); }, @@ -71,9 +78,15 @@ 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 }; +export default { + left: "http://localhost:4011", + right: "http://localhost:4010", + routes, + outDir: "/tmp/renderdiff--", +}; ``` ## Project SKILL.md skeleton @@ -89,8 +102,8 @@ description: specifics for renderdiff runs (). Use with the `ren # renderdiff on -Load the `renderdiff` skill first and follow its loop; this adds only what is specific here. -Written for renderdiff . Settings: `/renderdiff.config.mjs`. +Load the `renderdiff` skill first and follow its loop, its Feedback included; this adds only +what is specific here. Written for renderdiff . Settings: `/renderdiff.config.mjs`. ## Build @@ -98,6 +111,10 @@ Written for renderdiff . Settings: `/renderdiff.config.mjs`. ## Serve +## Routes + + ## Run /, in the background; base vs base against the second base port; shots and minutes for a full run> diff --git a/src/cli.ts b/src/cli.ts index 31628de..2b17097 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -268,7 +268,8 @@ const HELP = ` keeping the rest. Same origins, routes and widths as that run. --only 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. @@ -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) { diff --git a/tests/unit/config-load.test.ts b/tests/unit/config-load.test.ts index c319340..560b3a3 100644 --- a/tests/unit/config-load.test.ts +++ b/tests/unit/config-load.test.ts @@ -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);