renderdiff 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,80 +1,79 @@
1
1
  # renderdiff
2
2
 
3
- Screenshot diff between two origins.
3
+ **Did my migration change how the site renders? A visual-parity check for coding agents.**
4
4
 
5
- Give it a list of routes and two base URLs. It screenshots each route on both sides at
6
- several viewport widths, pixel-diffs the pairs, and writes a scored HTML report with
7
- left / right / diff side by side, plus `results.json` and crops of the changes, sized to stay
8
- legible to coding agents.
5
+ renderdiff screenshots the same routes on two running copies of a site (your branch on
6
+ localhost, and production) at five viewport widths and pixel-diffs each pair. For every
7
+ change it tells you where on the page it is, which DOM element and CSS property is behind
8
+ it, and what text differs. It also writes small crops sized for an LLM's context. It refuses
9
+ to score what it can't trust: blank pages, error pages, pages still moving. A clean result
10
+ means the pixels really matched.
9
11
 
10
- Useful for the "did I break anything?" question a test suite can't answer: a framework
11
- migration, a CSS refactor, a dependency bump, a staging-vs-production check.
12
+ ### Who it's for
12
13
 
13
- ```
14
- renderdiff http://localhost:3000 https://example.com / /about /pricing
14
+ Developers who hand a **large website migration** to a coding agent (Claude Code, Codex,
15
+ Cursor), and the agents themselves. The goal of such a migration is *no visible change*, and
16
+ the agent needs a cheap, trustworthy loop: change → `renderdiff` → explain every difference
17
+ → repeat.
18
+
19
+ **Use it for:**
20
+
21
+ - framework migrations (e.g. Gatsby → Next.js, Vue 2 → 3, jQuery templates → React)
22
+ - CSS or design-system refactors (Sass → Tailwind, BEM → CSS modules, a token swap)
23
+ - template or component rewrites, CMS moves, static-site-generator swaps
24
+ - dependency and build upgrades (bundler, PostCSS, font loading)
25
+ - pages that moved during the migration (`/pricing::/plans`), and one component at a time (`--selector`)
26
+
27
+ **Don't use it for:**
28
+
29
+ - intentional redesigns: when everything is meant to change, the score says nothing
30
+ - cross-browser testing (Chromium only) or stored-baseline snapshot tests (both origins must be live)
31
+ - discovering routes: you list them; there is no crawler
32
+ - component libraries in isolation; Storybook-based tools fit that better
33
+
34
+ ### 30-second quickstart
35
+
36
+ ```bash
37
+ npx playwright install chromium # once per machine
38
+ npx renderdiff@latest http://localhost:3000 https://example.com / /about /pricing
15
39
  ```
16
40
 
17
- On a terminal it prints a line per shot as it lands, then the worst scores and the paths
18
- to `report.html` and `results.json`. Piped — to a file, CI or a coding agent — it prints
19
- one summary at the end instead, listing only what differs:
41
+ **Left (first origin) is your change; right (second) is the reference.** Piped, which is
42
+ what an agent sees, renderdiff prints only what differs:
20
43
 
21
44
  ```
22
45
  Routes: 3
23
- clean: /
46
+ clean: / /pricing
24
47
  /about clean @1152,896,704,375
25
48
  @1440 97.41% h-24 band -24@y196 aligned 100.00% text -"Pricing"
26
49
  dom -nav > a[href="/pricing"] "Pricing"
27
50
  crops about.1440.c1.png
28
- /pricing
29
- @1440,1152,896,704,375 99.12%–99.87% 1 reg
30
- dom @1440 div.plans > a.cta margin-left 24px→16px
31
- crops pricing.1440.c1.png pricing.1152.c1.png pricing.896.c1.png pricing.704.c1.png pricing.375.c1.png
32
- renderdiff: 2 of 3 routes differ (6 of 15 shots), 0 errors, worst 97.41% /about@1440 — /Users/you/site/renderdiff-out/results.json
51
+ renderdiff: 1 of 3 routes differ (1 of 15 shots), 0 errors, worst 97.41% /about@1440 — ./renderdiff-out/results.json
33
52
  ```
34
53
 
35
- A row covers the widths that agree on everything but the score (shown as a range): height
36
- delta (`h`), bands (`+` rows only left has, `-` rows only right has, at `y`; `~` when the
37
- widths put it in different places), aligned score, region count, unstable pixels and up to
38
- three text-diff lines (`-"x"` only on the right, `+"x"` only on the left). Under a row,
39
- `dom` names up to three elements behind the changes (see **Elements** below; `@w` when the
40
- row covers several widths: the first one's), and `crops` lists that row's crop files,
41
- relative to the directory `results.json` is in. Errors print in
42
- full, grouped across the widths that share a message. Clean means not one pixel differs
43
- and none went unmeasured as unstable; a route with unstable pixels gets its own row. With
44
- `--threshold`, the verdict line ends `, B below 97.00%`. `--progress` gets the per-shot
45
- output when piped, `--no-progress` the summary on a terminal.
46
-
47
- ## Install
48
-
49
- ```bash
50
- npm install -g renderdiff
51
- npx playwright install chromium # once, if you don't already have it
52
- ```
54
+ Read as: on `/about` at 1440px, your change is 24px shorter (`h-24`) because 24 rows that
55
+ only the reference has (`band -24`) are missing at y=196. With those rows cut out the page
56
+ matches exactly (`aligned 100.00%`). The missing rows are the "Pricing" nav link, which
57
+ exists only on the reference (`-`). Open `renderdiff-out/about.1440.c1.png` to see it.
53
58
 
54
- Requires Node >= 22.18. If the bundled Chromium won't launch, renderdiff falls back to an
55
- installed Google Chrome on both sides and prints a line saying so; `meta.browser` in
56
- `results.json` records which browser ran.
59
+ **Without `--threshold`, a diff still exits 0**, so read the summary.
57
60
 
58
- ### Agent skill
61
+ ### For coding agents
59
62
 
60
- [`skills/renderdiff/SKILL.md`](./skills/renderdiff/SKILL.md) teaches a coding agent to use
61
- renderdiff without fooling itself: run it with `npx renderdiff@latest`, read the summary
62
- it prints, then the text diff, the bands and the region crops, and give a verdict that says
63
- what it looked at — not just a score. The longer reference (triage, rationalizations,
64
- config, gotchas) sits next to it in `reference.md` and is read only when needed.
65
-
66
- In Claude Code, as a plugin:
63
+ Install the skill so the agent runs the loop and reports a verdict, not a bare score:
67
64
 
68
65
  ```
69
- /plugin marketplace add tmashara/renderdiff
66
+ /plugin marketplace add tmashara/renderdiff # Claude Code
70
67
  /plugin install renderdiff@renderdiff
68
+ npx skills add tmashara/renderdiff # Codex, Cursor, others (skills.sh)
71
69
  ```
72
70
 
73
- For other agents (Codex, Cursor and so on), with [skills](https://skills.sh):
71
+ ### Requirements
74
72
 
75
- ```bash
76
- npx skills add tmashara/renderdiff
77
- ```
73
+ Node >= 22.18 and Chromium (`npx playwright install chromium`). `npm install -g renderdiff`
74
+ installs it globally. If the bundled Chromium won't launch, renderdiff falls back to an
75
+ installed Google Chrome on both sides and says so; `meta.browser` in `results.json` records
76
+ which ran.
78
77
 
79
78
  ## Usage
80
79
 
@@ -84,7 +83,7 @@ renderdiff <left-origin> <right-origin> <route…> [options]
84
83
 
85
84
  | Argument or flag | |
86
85
  | --- | --- |
87
- | `<origin>` | An argument starting with `http://` or `https://`. The first is left, the second right; a third is an error. |
86
+ | `<origin>` | An argument starting with `http://` or `https://`. The first is left (your change), the second right (the reference); a third is an error. |
88
87
  | `<route>` | A literal path, e.g. `/about`, or a pair `/pricing::/plans` (see below). |
89
88
  | `--left <url>` | Left origin, instead of the first positional one. Giving both is an error. |
90
89
  | `--right <url>` | Right origin, instead of the second positional one. |
@@ -97,246 +96,136 @@ renderdiff <left-origin> <right-origin> <route…> [options]
97
96
  | `--left-selector <sel>`, `--right-selector <sel>` | That side's selector, over `--selector`. One without the other side (or `--selector`) is an error. |
98
97
  | `--storage-state <file>` | Start both sides with this Playwright storage state (cookies, localStorage), e.g. signed in. Relative to cwd. Over the config's `storageState`. |
99
98
  | `--left-storage-state <file>`, `--right-storage-state <file>` | That side's storage state, over `--storage-state`. One side alone is fine. |
99
+ | `--accept <reason>` | Record this run's differences as accepted, with a reason. See **Accepted differences and reruns**. |
100
+ | `--rerun-failed` | Rerun only what the last run in `--out` didn't find clean or accepted, keeping the rest. |
100
101
  | `--dry-run` | Print the URL pairs and exit. No browser. |
101
102
  | `--open` | Open the report when the run finishes. |
102
103
  | `--progress` | A line per shot as it finishes, then the worst scores. The default on a terminal; piped, renderdiff prints one compact summary at the end. |
103
104
  | `--no-progress` | The compact summary even on a terminal. |
104
105
  | `-h, --help` / `-v, --version` | |
105
106
 
106
- Routes are **literal paths** — there is no route discovery and globs are rejected. Each
107
+ Routes are **literal paths**: there is no route discovery and globs are rejected. Each
107
108
  route is loaded on both origins, unless it's a pair.
108
109
 
109
- Default widths are `1440, 1152, 896, 704, 375` — one inside each common responsive band
110
- rather than on a breakpoint boundary, so a layout switch never flaps between captures.
111
-
112
- Exit codes: `0` no route errored and none scored below `--threshold` (without one, a diff
113
- still exits 0) · `1` a route scored below `--threshold` · `2` bad arguments, bad config, or an
114
- origin that isn't responding · `3` a route errored, so it went unmeasured, or the run
115
- crashed (e.g. no Chromium). `1` wins over `3` when both happen.
116
-
117
- ## Comparing different routes or components
118
-
119
- When a migration moved a page, give the route as a pair: `left::right`.
110
+ Default widths are `1440, 1152, 896, 704, 375`: each sits mid-way inside a common breakpoint
111
+ range rather than on a boundary, so a layout switch never flaps between runs.
120
112
 
121
- ```
122
- renderdiff http://localhost:3000 https://example.com /pricing::/plans /about::/company/about
123
- ```
124
-
125
- `/pricing::/plans` loads `/pricing` on the left and `/plans` on the right. The split is
126
- on the first `::` followed by `/`, so `/q?x=std::vector` and `/wiki/Talk:Foo` stay single
127
- routes, and `pricing::plans` (no slash) is a literal path; `--dry-run` shows how each
128
- route was read. Each side is normalized on its own, a pair with identical sides is the
129
- plain route, and an empty side (`/pricing::`) is an error.
130
-
131
- A pair is named `/pricing::/plans` in stdout, `results.json` (`url`, plus `left` and
132
- `right`) and the report; its images are `pricing~plans.{width}.*.png`. A mask whose
133
- `urlPath` is either side's path is painted on both sides. In a config file, a route can
134
- also be an object, `{ left: "/pricing", right: "/plans" }`, where `right` defaults to
135
- `left`.
113
+ | Exit | Meaning |
114
+ | --- | --- |
115
+ | `0` | No route errored and none scored below `--threshold`. Without one, a diff still exits 0. |
116
+ | `1` | A route scored below `--threshold`. Wins over `3` when both happen. |
117
+ | `2` | Bad arguments, bad config, or an origin that isn't responding. |
118
+ | `3` | A route errored, so it went unmeasured, or the run crashed (e.g. no Chromium). |
136
119
 
137
- To compare one component instead of the whole page, give a selector:
120
+ ### Moved pages, components, interactions, sign-in
138
121
 
139
122
  ```
123
+ renderdiff http://localhost:3000 https://example.com /pricing::/plans
140
124
  renderdiff http://localhost:3000 https://example.com /pricing --selector "[data-test=plans]"
141
- renderdiff http://localhost:3000 https://example.com /pricing --left-selector .plans-v2 --right-selector .plans
142
125
  ```
143
126
 
144
- Any Playwright selector works, `xpath=` and `>>` chains included, and it's never split on
145
- `::`. It must match **exactly one** element on each side; otherwise the route errors at once
146
- with the count (`selector ".card" matched 6 elements on left (/pricing), expected 1`). The
147
- shot, the score, the regions and the text diff are that element's, in its own pixels; a
148
- mask is painted where it overlaps the element.
149
-
150
- A CLI selector applies to every route and replaces the config's. In a config file a route
151
- object takes `selector: ".plans"` or `selector: { left: ".plans-v2", right: ".plans" }`;
152
- to compare several components of one page, list the route once per selector. Such a route
153
- is `/pricing [.plans]` in the summary and the report, keeps `/pricing @1440` on a
154
- `--progress` row with the selector on the line under it, has `selector` in `results.json`
155
- (`{ left, right }`, or `null` for a whole page), and its images are
156
- `pricing@_plans.{width}.*.png`.
157
-
158
- Things to know:
159
-
160
- - The whole page still loads and settles first, so a component costs about what its page does.
161
- - It's shot at scroll 0 with the rest of the page, so a fixed or sticky header over it is
162
- in the shot. Hide it with `hideSelectors`.
163
- - An element inside a shorter scroll container includes whatever paints below the container.
164
- - A transformed element is shot at its axis-aligned bounding box.
165
- - Pseudo-elements (`::before`) are rejected by Playwright; elements inside iframes can't be
166
- reached. Open shadow roots are pierced.
167
- - A selector that matches an element with no box (`display: none`) or one off the page is
168
- an error. The render gate stays page-level: a textless element on a rendered page is fine.
169
-
170
- ## Interactions and signed-in pages
171
-
172
- When the state worth comparing is behind a click (an open menu, a dismissed modal both
173
- sides show), give the route a `before` hook in the config. It runs on each side after the
174
- page loads and its HTTP status is checked, and before renderdiff settles, masks and shoots
175
- it, with the clock and seeded `Math.random` already in force:
176
-
177
- ```ts
178
- export default {
179
- routes: [
180
- { left: "/", before: async (page) => { await page.click("[aria-label=Menu]"); } },
181
- ],
182
- // For every route without its own `before` (which replaces it), CLI routes included.
183
- before: async (page, { side, route, width }) => {
184
- const accept = page.getByRole("button", { name: "Accept" });
185
- if (await accept.isVisible()) await accept.click();
186
- },
187
- };
188
- ```
189
-
190
- `side` is `"left"` or `"right"`, `route` the route's name (`/a` or `/a::/b`), `width` the
191
- viewport width. Use `side` only where the markup legitimately differs (the button has a
192
- different selector on each side), never to make the sides look alike. A hook that throws
193
- is a route error, `before hook failed on left: …`, and isn't retried. A route that ran one
194
- has `"hook": true` in `results.json` and a `before hook` badge on its report card.
195
- Actions in a hook use Playwright's 30s default timeout, so check before clicking what may
196
- not be there (as above); `hideSelectors` and `extraCss` apply after the hook.
197
-
198
- For pages behind a login, save a Playwright storage state once and pass it:
127
+ A pair `left::right` loads `/pricing` on the left and `/plans` on the right. `--selector`
128
+ compares one element, which must match exactly once per side (`--left-selector` /
129
+ `--right-selector` when the markup differs). A state behind a click takes a config `before`
130
+ hook. For pages behind a login:
199
131
 
200
132
  ```
201
133
  npx playwright codegen --save-storage=auth.json https://example.com/login
202
134
  renderdiff http://localhost:3000 https://example.com /account --storage-state auth.json
203
135
  ```
204
136
 
205
- `--left-storage-state` / `--right-storage-state` give each side its own file, and the
206
- config takes `storageState: "auth.json"` or `{ left, right }`, relative to the config file.
207
- A missing or non-JSON file, or one that isn't a storage state, exits 2 before the browser starts. The file's contents are never
208
- printed; `meta.storageState` and the report name only each side's file. It holds live
209
- session tokens: **never commit it**, and add it to `.gitignore`. The out dir then holds
210
- signed-in screenshots, and their text in `results.json`, so don't publish or commit that
211
- either; add the out dir to `.gitignore` too.
137
+ `auth.json` holds live session tokens: **never commit it**, and keep the out dir (signed-in
138
+ screenshots and text) out of git too. Details: [docs/scoping.md](https://github.com/tmashara/renderdiff/blob/main/docs/scoping.md).
212
139
 
213
140
  ## Config file
214
141
 
215
- Flags cover a one-off run. A `renderdiff.config.ts` (or `.mjs`) in the working directory
216
- covers a repo you diff repeatedly, and expresses the things flags can't: **masks** for
217
- content that legitimately drifts, extra selectors to hide, extra URLs to block.
142
+ Flags cover a one-off run. A `renderdiff.config.{ts,mts,js,mjs}` in the working directory
143
+ covers a repo you diff repeatedly, and expresses what flags can't: **masks** for content
144
+ that legitimately drifts, extra selectors to hide, extra URLs to block, `before` hooks.
218
145
 
219
146
  ```ts
220
147
  export default {
221
- left: "http://localhost:3000",
222
- right: "https://example.com",
223
- routes: [
224
- "/",
225
- "/about",
226
- "/pricing::/plans",
227
- { left: "/blog", right: "/posts" },
228
- { left: "/pricing", selector: "[data-test=plans]" },
229
- ],
148
+ left: "http://localhost:3000", // your change
149
+ right: "https://example.com", // the reference
150
+ routes: ["/", "/about", "/pricing::/plans", { left: "/pricing", selector: "[data-test=plans]" }],
230
151
  widths: [1440, 896, 375],
231
152
  threshold: 0.97,
232
-
233
153
  hideSelectors: [".site-cookie-banner"],
234
154
  blockUrlPatterns: ["**://*.my-tracker.example/**"],
235
-
236
- masks: [
237
- { selector: "[data-star-count]", reason: "live star count", urlPath: "/pricing" },
238
- ],
239
-
240
- storageState: "auth.json", // relative to this file; never commit it
155
+ masks: [{ selector: "[data-star-count]", reason: "live star count", urlPath: "/pricing" }],
241
156
  };
242
157
  ```
243
158
 
244
- A mask needs a `reason` and exactly one of `selector` (a Playwright selector) or
245
- `locate: (page) => Locator`, the escape hatch for what a selector can't say. Without
246
- `urlPath` it applies to every route.
247
-
248
- See [`renderdiff.config.example.ts`](./renderdiff.config.example.ts) for every field, annotated.
159
+ **Precedence: CLI flag > config file > built-in default.** Every field is annotated in
160
+ [`renderdiff.config.example.ts`](./renderdiff.config.example.ts). `export default { … }`
161
+ needs no import; `import type { RenderdiffConfig } from "renderdiff"` is for editor types
162
+ only.
249
163
 
250
- **Precedence: CLI flag > config file > built-in default.**
164
+ ## Reading the output
251
165
 
252
- `export default { … }` needs no import. `import type { RenderdiffConfig } from "renderdiff"`
253
- with `satisfies RenderdiffConfig` is for editor types only; Node erases it, so the file loads
254
- without renderdiff installed in that project.
166
+ **Signs always point at your change:** `+` = only in or more in the left (your change);
167
+ `-` = only in or more in the right (the reference).
255
168
 
256
- ## How it reads
257
-
258
- The score is the share of pixels that match, per route per width. Green above 99.5%,
259
- amber to 97%, red below. It is a triage signal, not a verdict — open the report and
260
- look at the diff column.
261
-
262
- Under each score, `--progress` output and the report card say where to look:
169
+ | Token | Meaning |
170
+ | --- | --- |
171
+ | `97.41%` | Share of measured pixels that match. A range (`99.12%–99.87%`) when one row covers several widths. A triage signal, not a verdict. |
172
+ | `@1440,1152` | The widths a row covers. They agree on height and width delta, bands, aligned score and text; score and region count are ranges. |
173
+ | `h-24` / `w+8` | Height / width delta, left − right. The smaller image is padded with magenta, and the padding counts against the score on purpose. |
174
+ | `band -24@y196` | 24 rows only the reference has, at y=196 (`+`: only your change has them). `@y~196`: the row's widths put it at different y; the first width's is shown. |
175
+ | `aligned 100.00%` | The score with the bands cut out. 100% means the band is the whole difference. |
176
+ | `unaligned` | Heights differ but too few rows match to find bands. Use the height delta and regions. |
177
+ | `3 reg` | Regions: connected patches of differing pixels. A missing link barely moves the score, but it is still a region. |
178
+ | `unstable 1,204px` | Pixels that kept changing between shots of the same load (a ticker, a carousel). Excluded from the score, so the route is never clean. |
179
+ | `text -"Pricing"` | A line of visible text only the reference has (`+`: only your change). Up to three, then `(+N more)`. |
180
+ | `dom sel prop a→b` | Element on both sides whose computed `prop` differs, left value first. |
181
+ | `dom +sel` / `-sel` | Element only on the left / only on the right. |
182
+ | `dom sel x+8` | It moved without its own style changing; the neighbour or parent listed after it is the cause. |
183
+ | `dom sel` | On both sides and unchanged in style or position, so its own pixels changed (an image or canvas). |
184
+ | `crops …` | Up to 3 PNGs per comparison, bands first, relative to `results.json`. Left is on top or on the left, and the change is boxed in red. |
185
+ | `ERROR …` | Unmeasured, which is neither a pass nor a fail. Exits 3. |
186
+ | `accepted:` / `stale accepted:` | Shots whose every difference was accepted, and accepted entries the run no longer saw. See below. |
187
+ | `rerun:` | With `--rerun-failed`: how many shots were rerun and how many reused. |
188
+
189
+ Read text first, then `dom`, then the crops for anything `dom` doesn't explain. Full
190
+ detail, including the `--progress` and report forms: [docs/output.md](https://github.com/tmashara/renderdiff/blob/main/docs/output.md).
191
+
192
+ ## Accepted differences and reruns
193
+
194
+ A migration starts with differences that already exist between the untouched local build
195
+ and production. Record them once, then change code and re-check only what's red:
263
196
 
264
197
  ```
265
- [3/8] 97.14% /pricing @1440 (height -24px)
266
- band -24px at y=196 (right)
267
- aligned 100.00%
268
- text: 0 added, 1 removed
198
+ renderdiff http://localhost:3000 https://example.com / /about --accept "differs before the migration"
199
+ # …change code…
200
+ renderdiff http://localhost:3000 https://example.com / /about --rerun-failed
269
201
  ```
270
202
 
271
- - **Regions** are connected patches of differing pixels, on a 16px grid, largest first. On
272
- a long page a missing link barely moves the score; it is still a region. Only the area
273
- both sides cover is searched: padding is reported by the height delta and the bands. With
274
- bands, regions come from the realigned rows, so content a band only pushed down isn't
275
- one (above, the band is the whole difference, so there are none). The
276
- card lists the top five with their pixel counts; up to three crops cover the bands first,
277
- then the largest regions.
278
- - **Bands** are rows only one side has, found by aligning the two images row by row. `-24px
279
- at y=196 (right)` means the right side has 24 rows there that the left lacks — signed like
280
- the height delta. When rows of identical content repeat, the band's `y` can slide along
281
- them, so treat it as approximate. `aligned` is the score over the rows the alignment pairs,
282
- i.e. what the page would score if the bands were cut out; it only shows when there are
283
- bands. Pages of different heights that share too few identical rows say `rows too
284
- different to align`. A horizontal shift is never a band. Alignment works on exact row
285
- matches, so it gives up when the edits total more than 2,000 rows (a single inserted or
286
- removed block aligns at any height), or when sub-threshold shade noise touches every row.
287
- - **Elements** name what is under the first five bands and regions, up to three each, read
288
- from the live pages right after the shot. Each is a CSS selector unique on its side, so
289
- `--selector` takes it. `-sel "text"` exists only on the right, `+sel` only on the left;
290
- `sel margin-left 24px→16px` is on both, with the computed properties that differ, left
291
- first; `sel x+8` moved without a style of its own changing, so the cause is a neighbour or
292
- its parent, which is listed after it when one of them changed; a bare `sel` is on both
293
- sides with nothing compared changed or moved, so its own pixels did (an image's source, a
294
- canvas). An element without text is only paired when its box is the same size on both
295
- sides; `rightSelector` is set when the right side was found by its text under another
296
- selector. A band names only what one side added, not what it pushed down, so it can name
297
- nothing; what fixed chrome covers isn't named. Probing gives up after 5s, leaving the
298
- elements out. A computed
299
- value is a symptom: find the rule that set it. Shadow DOM and iframes aren't searched.
300
- - **Text** is a line diff of each side's `document.body.innerText` (the element's, with a
301
- selector). Left is the changed version: *added* lines are on the left only, *removed* lines on the right only. The card
302
- lists the first 20. Masked elements are left out. Unmasked text that changes per load
303
- (a ticker, a clock) shows up here whenever the two sides differed at capture time, even
304
- when its pixels were painted out as unstable.
305
-
306
- **Magenta means padding.** When one side is taller or wider, the shorter image is padded
307
- with magenta, and that padding counts against the score on purpose: a page 400px shorter
308
- than the one it's compared against has not reached parity. The `height ±Npx` badge on the
309
- row names the cause.
310
-
311
- **Errors are not failures.** A route that threw produced no measurement, so it is
312
- neither a pass nor a fail. It's listed separately in the report — and it still exits non-zero
313
- (`3`), because an unmeasured route is not a verified one.
203
+ `--accept "<reason>"` writes every difference the run saw to `renderdiff.accepted.json` (next
204
+ to the config, or in cwd); commit it. Later, a shot whose every difference matches an entry is
205
+ listed on an `accepted:` line and counts neither as differing nor toward `--threshold`;
206
+ anything new on the same page still shows. `--rerun-failed` reruns only what the last run in
207
+ `--out` didn't find clean or accepted and merges it back; it exits 2 if the run differs from
208
+ the previous one. Matching rules, what can still hide, and stale entries:
209
+ [docs/accepted.md](https://github.com/tmashara/renderdiff/blob/main/docs/accepted.md).
314
210
 
315
211
  ## Output
316
212
 
317
213
  Everything lands in the out dir (`./renderdiff-out` by default):
318
214
 
319
- - `report.html` — the scored report.
320
- - `{slug}.{width}.{left,right,diff}.png` — full-page shots and the diff. `/` slugs to
215
+ - `report.html`: the scored report.
216
+ - `{slug}.{width}.{left,right,diff}.png`: full-page shots and the diff. `/` slugs to
321
217
  `index`, `/blog/foo` to `blog-foo`, other characters to `_`, a pair `/a::/b` to `a~b`; a
322
218
  clash gets a `-2` suffix.
323
- - `{slug}.{width}.c{n}.png` — up to three crops per comparison: bands (rows only one side
324
- has), tallest first, then the largest regions. Each shows left and right at native scale,
325
- padded by 48px and at least 320px wide, with a 1px red box around the change; on the side
326
- that lacks a band's rows, a 2px red seam is inserted where they would be, hiding nothing.
327
- Left is always on top (`stacked`, for wide changes) or on the left (`side`, for tall
328
- ones). A crop stays within 1,568px on its long edge and 1,568 image tokens
329
- (⌈w/28⌉·⌈h/28⌉) and is never scaled below 0.75×; a change too big for that keeps its top
330
- rows (or left columns) and is marked `clipped`. Height padding is never cropped.
331
- - `results.json` — everything above as data, written on every run, errors included, one
219
+ - `{slug}.{width}.c{n}.png`: up to three crops per comparison, left on top or on the left,
220
+ change boxed in red.
221
+ - `results.json`: everything above as data, written on every run, errors included, one
332
222
  result per line, scores rounded down to four decimals (so a shot that differs never
333
- reads as `1`). Its types (`Results` and friends) are exported
334
- from the package.
223
+ reads as `1`). Its types (`Results` and friends) are exported from the package.
335
224
 
336
225
  ```jsonc
337
226
  {
338
227
  "meta": {
339
- "tool": "renderdiff", "version": "0.2.0", "generatedAt": "…",
228
+ "tool": "renderdiff", "version": "0.3.0", "generatedAt": "…",
340
229
  "left": "http://localhost:3000", "right": "https://example.com",
341
230
  "widths": [1440, 375], "threshold": null,
342
231
  "clock": "…", // the instant both sides' clocks start at
@@ -363,7 +252,7 @@ Everything lands in the out dir (`./renderdiff-out` by default):
363
252
  "text": "Pricing" // "rightSelector" when the right one differs
364
253
  }]
365
254
  }],
366
- "text": { "added": [], "removed": ["Pricing"] },
255
+ "text": { "added": [], "removed": ["Pricing"] }, // added: left only; removed: right only
367
256
  "images": { "left": "about.1440.left.png", "right": "about.1440.right.png", "diff": "about.1440.diff.png" },
368
257
  "crops": [{
369
258
  "path": "about.1440.c1.png", "kind": "band", // or "region"
@@ -377,114 +266,32 @@ Everything lands in the out dir (`./renderdiff-out` by default):
377
266
  "summary": {
378
267
  "compared": 1, "errors": 1, "mean": 0.9714,
379
268
  "worst": { "url": "/about", "width": 1440, "score": 0.9714 },
380
- "belowThreshold": 0
269
+ "belowThreshold": 0, // accepted shots aside
270
+ "accepted": 0, // shots whose every difference was accepted
271
+ "stale": [] // accepted entries the run measured but didn't see
381
272
  }
382
273
  }
383
274
  ```
384
275
 
385
276
  Paths are relative to the out dir. Results are in route-then-width order.
386
277
 
387
- ## What it does to make two loads comparable
388
-
389
- - **Refuses to start** unless both origins answer a HEAD request. Two error pages diffed
390
- against each other score ~100%, which is the likeliest way a tool like this lies to you.
391
- - **Refuses a shot** it can't trust: an HTTP status of 400 or above on either side is an
392
- error naming the status. A route where *both* sides look blank — under 200 characters of
393
- text and no visible images, SVG, canvas, video, iframes or form controls — errors instead
394
- of quietly scoring as a match. If only one side is blank, the diff shows it.
395
- - **Blocks analytics, consent and A/B tooling** at the network layer on both sides —
396
- GTM, GA, Segment, Intercom, Hotjar, LinkedIn, Meta, OneTrust, CookieLaw, VWO. Blocked
397
- rather than hidden, because A/B tools rewrite arbitrary DOM and CSS can't pre-empt an
398
- experiment you haven't seen. Blocking must stay symmetric: doing it on one side only
399
- is itself a diff.
400
- - **Pins what varies between loads**, identically on both sides: the clock starts at one
401
- instant per run (floored to the minute) on every load and then runs, `Math.random` is
402
- seeded, timezone (UTC), locale (en-US), colour scheme
403
- (light) and `prefers-reduced-motion` are fixed, and videos are paused at frame 0. A clock
404
- that shows seconds can still tick between the two sides and may need a mask.
405
- - **Kills animation, transitions and caret blink**, and hides common consent banners and
406
- dev toolbars.
407
- - **Settles the page** — makes native lazy images eager, scrolls every viewport-height
408
- slice a frame at a time so script lazy loaders fire, waits for every image to decode,
409
- `document.fonts.ready` and up to 3s for the network to go 250ms with nothing in flight
410
- (server-sent events and beacons don't count), then re-injects the stylesheet to
411
- catch late-injected chrome.
412
- - **Shoots until stable** — repeats the screenshot until two in a row match (at most 3; 2
413
- for tiled pages). Pixels still changing between the last two shots of the same load are
414
- noise from that origin: they're painted magenta on both sides, reported per comparison
415
- as `unstable`, and excluded from the measurement — the score is over the remaining
416
- pixels. A route more than 25% unstable errors as `page did not settle`.
417
- - **Isolates state** — one browser context per origin, so cookies and localStorage never
418
- cross (a storage state file seeds each context on its own). Both sides of a route load at
419
- the same time.
420
- - **Retries once** per shot; a live site can time out on a cold edge.
421
- - **Stitches tall pages.** A single screenshot past 16,384px can come out truncated or
422
- wrapped (Chrome repeats the top of the page; recent headless Chromium does not), so taller
423
- pages, and element shots taller than that, are captured as tiles through CDP and stitched
424
- back together. Masks are
425
- painted onto the final image from each element's bounding box, so they apply the same
426
- way on both paths. Past 40,000px it errors — that's a broken layout, not a page.
427
-
428
- Every run's report ends with a "What was neutralized" section listing the actual block
429
- patterns, mask reasons and pinned settings in effect, so the report can't drift from the
430
- config.
431
-
432
- ## Development
278
+ ## Why a clean result is trustworthy
433
279
 
434
- ```bash
435
- npm install # builds via the prepare script
436
- npm run build # tsc → dist/
437
- npm run typecheck # src + tests, no emit
438
- npm install -g . # install this working copy globally
439
- ```
440
-
441
- Source is TypeScript in `src/`, compiled to ESM in `dist/`. Import specifiers keep their
442
- `.ts` extension in source (`rewriteRelativeImportExtensions` rewrites them on emit), so
443
- `node src/cli.ts` also runs directly.
280
+ - **Refuses to start** unless both origins answer: two error pages would score ~100%.
281
+ - **Refuses a shot** with an HTTP status ≥ 400, or where both sides look blank.
282
+ - **Blocks analytics, consent and A/B tooling** at the network layer, on both sides.
283
+ - **Pins what varies between loads**: clock, `Math.random`, timezone, locale, colour scheme, reduced motion, video frames.
284
+ - **Kills animation, transitions and caret blink**, and hides common consent banners and dev toolbars.
285
+ - **Settles the page**: lazy images, fonts and network idle before the shot.
286
+ - **Shoots until stable**, and excludes pixels still moving as `unstable`.
287
+ - **Isolates state** per origin, **retries once** per shot, and **stitches tall pages** past 16,384px.
444
288
 
445
- ## Tests
289
+ Each run's report ends with a "What was neutralized" section listing what was in effect.
290
+ Full detail: [docs/how-it-works.md](https://github.com/tmashara/renderdiff/blob/main/docs/how-it-works.md).
446
291
 
447
- ```bash
448
- npm test # unit tier — no browser, ~4s
449
- npm run test:visual # drives Chromium over the fixture corpus, ~40s
450
- npm run test:all
451
- ```
292
+ ## Contributing
452
293
 
453
- Built on `node:test`, no test dependencies. Running them needs a Chromium for the visual tier:
454
- `npx playwright install chromium`.
455
-
456
- The suite is black-box: it spawns `node src/cli.ts` and reads results back from stdout,
457
- `report.html`, `results.json`, and the written PNGs. Nothing under `src/` is imported at
458
- runtime (only the `results.json` types), so what is asserted is what a user gets. The output
459
- formats it depends on are parsed in exactly one place, `tests/helpers/parse.ts`.
460
-
461
- **`tests/unit/`** — argument parsing, exit codes, route normalization, and config discovery,
462
- validation and precedence. All of it through `--dry-run`, which exits before the browser.
463
-
464
- **`tests/visual/`** — real captures against `tests/fixtures/`, a two-origin site served on
465
- ephemeral ports. Three groups:
466
-
467
- - *Equivalence* — six pairs of pages that are visually identical but built with different CSS:
468
- flex against grid, floats against flex, CSS tables against grid, transform-centring against
469
- `place-items`, inline-block against flex. These assert a **zero-pixel** diff, not a
470
- threshold. They are the corpus's central claim: the score tracks pixels, not markup, so a
471
- refactor that changed nothing visible reads as nothing changed.
472
- - *Controls* — an 8px shift, an extra section, one recoloured box, one changed margin. These
473
- must score low. Without them a tool that returned 100% unconditionally would pass the
474
- suite. They also pin the elements each change names.
475
- - *Guard rails* — one per defence above: the blank-page refusal (and a short sign-in page
476
- that must not be refused), the 18,000px tiled capture with masks in both tiles, animation
477
- stilling, the shared clock start and seeded randomness, the unstable-pixel check, lazy-image
478
- settling (native and script-driven, 4,800px below the fold), consent hiding, masks,
479
- network blocking, `before` hooks, storage state, and naming fixed chrome over what it
480
- covers. Masks
481
- and blocking each run twice, with and without their config, so the mechanism is shown doing
482
- something rather than a page merely happening to match.
483
-
484
- Fixture authoring rules — integer-pixel geometry above all — are in
485
- [`tests/fixtures/pages/README.md`](./tests/fixtures/pages/README.md), and
486
- `tests/unit/fixtures.test.ts` enforces what can be enforced. Read them before adding a pair:
487
- a fractional box is the difference between a clean comparison and sub-pixel noise.
294
+ Development and tests: [CONTRIBUTING.md](https://github.com/tmashara/renderdiff/blob/main/CONTRIBUTING.md).
488
295
 
489
296
  ## License
490
297