styleproof 1.1.0 → 1.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/CHANGELOG.md +68 -1
- package/README.md +83 -620
- package/bin/styleproof-report.mjs +11 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +86 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.0]
|
|
11
|
+
|
|
12
|
+
Clearer reports: one section per screenshot.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **The report is organised by crop, not by surface.** Each changed region of the
|
|
17
|
+
page is its own section, headed by the element it is anchored on
|
|
18
|
+
(`` `who-grid` · 7 elements restyled ``), and its before|after screenshot is
|
|
19
|
+
followed by **only** the property changes that screenshot shows. A page with two
|
|
20
|
+
unrelated changes (say a restructured grid and a lone button) used to render one
|
|
21
|
+
screenshot, then another, then a single wall of tables you could not map back to
|
|
22
|
+
either image; now every table sits under the crop it belongs to, and crops read
|
|
23
|
+
top-to-bottom in page order.
|
|
24
|
+
- **Approval is per crop.** Because each crop is its own `###` section, the approval
|
|
25
|
+
checkbox the Action injects is per visual region — sign off the grid and the
|
|
26
|
+
button independently. A change that is identical across widths still collapses to
|
|
27
|
+
one section, as before.
|
|
28
|
+
- **The property tables fold under a toggle, behind a one-line essence.** The
|
|
29
|
+
screenshot and the approval checkbox always stay visible; below them a scannable
|
|
30
|
+
one-liner names the top deltas (and flags hover/focus/active changes, which a
|
|
31
|
+
static screenshot can't show), and the full before→after tables sit inside a
|
|
32
|
+
`<details>`. New `foldDetailsAt` report option: the row count at which tables fold
|
|
33
|
+
(default `0` = always; set `5` to keep small changes inline and fold only verbose
|
|
34
|
+
ones, `Infinity` to never fold).
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Two distinct changes that shared a representative surface could collide on one crop
|
|
39
|
+
image filename; crop images are now uniquely numbered across the whole report.
|
|
40
|
+
|
|
41
|
+
## [1.2.0]
|
|
42
|
+
|
|
43
|
+
Visual-review approval gate. StyleProof can now act as a per-PR review gate
|
|
44
|
+
("here is what changed visually — sign off if it's intentional") rather than only
|
|
45
|
+
a zero-diff refactor certifier.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **`require-approval` Action input.** Instead of failing the job on any diff, the
|
|
50
|
+
Action sets a `StyleProof` commit status: green when there are no visual changes,
|
|
51
|
+
red ("needs sign-off") until the changes are approved. The report comment gains
|
|
52
|
+
**one approval checkbox per change** — each distinct visual change is signed off
|
|
53
|
+
on its own, and the gate goes green only when **every** box is ticked.
|
|
54
|
+
- **`example/styleproof-approve.yml`** — a template `issue_comment` workflow (copy
|
|
55
|
+
to your default branch). As a write-access reviewer ticks the per-change boxes,
|
|
56
|
+
it updates the `StyleProof` status ("2 of 3 approved") and flips it green only at
|
|
57
|
+
full approval. Its trust model:
|
|
58
|
+
- acts only on a **human edit** of the **bot's own** report comment
|
|
59
|
+
(`comment.user.type == 'Bot'` and `sender.type == 'User'`), which excludes both
|
|
60
|
+
the Action's own comment upserts and any attacker-authored comment;
|
|
61
|
+
- **binds approval to the exact commit** the report was generated for (a
|
|
62
|
+
`<!-- styleproof-sha -->` marker), so a push after the report can never inherit
|
|
63
|
+
a green status — a new render re-opens the gate;
|
|
64
|
+
- **verifies write access** (`getCollaboratorPermissionLevel`, fails closed)
|
|
65
|
+
before moving the status. The marker is not the trust boundary — write access is.
|
|
66
|
+
- **`status-context` input** to rename the commit status (must match the approve
|
|
67
|
+
workflow + branch protection).
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- The report no longer appends its own "regenerate the baseline" footer — the
|
|
72
|
+
consumer (the Action, or your own wrapper) owns the call to action. This also
|
|
73
|
+
removes the duplicate footer the Action used to add. `fail-on-diff` (legacy
|
|
74
|
+
refactor mode) is unchanged and still the default.
|
|
75
|
+
|
|
10
76
|
## [1.1.0]
|
|
11
77
|
|
|
12
78
|
Report readability overhaul. No change to the capture format, the diff, or the
|
|
@@ -215,7 +281,8 @@ number)`), so each viewport band can capture at its own height. Default remains
|
|
|
215
281
|
- `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
|
|
216
282
|
property, and state that drifted (exit 1).
|
|
217
283
|
|
|
218
|
-
[Unreleased]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.
|
|
284
|
+
[Unreleased]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.2.0...HEAD
|
|
285
|
+
[1.2.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.1.0...v1.2.0
|
|
219
286
|
[1.1.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.0.0...v1.1.0
|
|
220
287
|
[1.0.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.7.0...v1.0.0
|
|
221
288
|
[0.7.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.6.0...v0.7.0
|
package/README.md
CHANGED
|
@@ -1,661 +1,124 @@
|
|
|
1
1
|
# StyleProof
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Know exactly what every PR changes visually, and sign it off.** StyleProof captures the browser's _computed_ styles (not pixels), diffs your PR's HEAD against its base branch, and posts a per-change report on the PR, so a styling change never ships without someone confirming it was intended.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/styleproof)
|
|
6
|
-
[](https://github.com/BenSheridanEdwards/styleproof/actions
|
|
7
|
-
[](https://github.com/BenSheridanEdwards/styleproof/actions)
|
|
7
|
+
[](https://github.com/BenSheridanEdwards/styleproof/blob/main/LICENSE)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
pseudo-element (`::before`/`::after`/`::marker`/`::placeholder`), and every forced
|
|
11
|
-
`:hover`/`:focus`/`:active` state, swept across your breakpoints, keyed by DOM
|
|
12
|
-
structure rather than class names. Then it diffs two captures and tells you the exact
|
|
13
|
-
element, property, and state that drifted, or certifies that nothing did.
|
|
9
|
+
## Why
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
and any refactor where "trust me, it's identical" isn't good enough.
|
|
11
|
+
Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:focus` / `:active`, can't see hidden or off-screen elements, can't reach between-breakpoint rules, and blur away sub-pixel drift. StyleProof reads the **computed style** of every element instead — every resolved longhand, every pseudo-element, the deltas `:hover` / `:focus` / `:active` apply (forced via CDP, no mouse), swept across each `@media` breakpoint.
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
home@1280: 1 element(s) differ
|
|
20
|
-
body > main:nth-child(2) > section:nth-child(5) > a:nth-child(1) (.cta)
|
|
21
|
-
border-bottom-color: rgb(95, 202, 219) → rgb(229, 231, 235)
|
|
22
|
-
border-bottom-style: none → solid
|
|
13
|
+
## What the gate does
|
|
23
14
|
|
|
24
|
-
|
|
25
|
-
border-color: rgb(95, 202, 219) → (state no longer changes it)
|
|
15
|
+
On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base branch, diffs them, and posts a Markdown comment:
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
- A summary line, then **one section per distinct change**, with a side-by-side before/after cropped screenshot and the property changes folded under a toggle.
|
|
18
|
+
- An **approval checkbox per change**, driving a `StyleProof` commit status: red until every change is signed off, green when there are none.
|
|
19
|
+
- No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
|
|
29
20
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## Why computed styles certify a refactor where pixels cannot
|
|
33
|
-
|
|
34
|
-
Pixel diffing is the right tool for _catching_ visual drift, but it cannot _certify_ a
|
|
35
|
-
refactor, because most of a stylesheet is invisible to a screenshot:
|
|
36
|
-
|
|
37
|
-
- **Hover, focus, and active states.** A deleted `:hover` rule renders identically
|
|
38
|
-
until a human points a mouse at it. This tool forces each pseudo-class through the
|
|
39
|
-
Chrome DevTools Protocol (`CSS.forcePseudoState`) and records exactly what each
|
|
40
|
-
state changes, including parent-state rules that restyle descendants. (`:focus`
|
|
41
|
-
forces `:focus` and `:focus-visible` together.)
|
|
42
|
-
- **Hidden elements.** A closed mobile menu is `display: none` in every screenshot,
|
|
43
|
-
but its panel, items, and animations still have computed styles, and they're
|
|
44
|
-
compared.
|
|
45
|
-
- **Between-breakpoint rules.** Screenshots sample two or three viewports. The style
|
|
46
|
-
map sweeps one width per `@media` band, so a dropped `max-width: 680px` override is
|
|
47
|
-
caught even if your screenshot widths never land in that band.
|
|
48
|
-
- **Sub-threshold drift.** Every pixel comparison needs a tolerance for antialiasing,
|
|
49
|
-
and a tolerance is a place for a real 1px change to hide. Computed values need no
|
|
50
|
-
tolerance: `13.5px` either equals `13.5px` or it doesn't.
|
|
51
|
-
- **Declared motion.** Transitions and animations are captured as declared longhands
|
|
52
|
-
(then frozen so every other value is a settled end state), so changing
|
|
53
|
-
`transition: all .2s` to `.3s` is a diff even though no still image could see it.
|
|
54
|
-
|
|
55
|
-
Screenshots and style maps complement each other: pixels catch what you forgot to
|
|
56
|
-
model, the style map certifies what pixels can't see. Use both.
|
|
21
|
+
A second mode certifies a refactor changed _nothing_: any diff fails the job (use it for CSS-to-Tailwind migrations, design-system swaps, build-tooling changes).
|
|
57
22
|
|
|
58
23
|
## Install
|
|
59
24
|
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Until the first npm release you can install straight from GitHub:
|
|
66
|
-
|
|
67
|
-
```sh
|
|
68
|
-
npm i -D github:BenSheridanEdwards/styleproof @playwright/test
|
|
25
|
+
```bash
|
|
26
|
+
npm install -D styleproof @playwright/test
|
|
27
|
+
npx playwright install chromium
|
|
69
28
|
```
|
|
70
29
|
|
|
71
|
-
|
|
72
|
-
install (`npx playwright install chromium`). The only runtime dependency is
|
|
73
|
-
[`pngjs`](https://www.npmjs.com/package/pngjs) (pure JS, no native build).
|
|
74
|
-
|
|
75
|
-
A runnable spec lives in [`example/`](example/).
|
|
30
|
+
Requires **Node ≥ 18** (ESM), **`@playwright/test` ≥ 1.40** (peer dep). Forced states are Chromium-only.
|
|
76
31
|
|
|
77
|
-
##
|
|
32
|
+
## Quickstart
|
|
78
33
|
|
|
79
|
-
**1.
|
|
80
|
-
state plus the viewport widths to sweep. `npx styleproof-init` scaffolds the file below
|
|
81
|
-
(with a `settle()` helper and a `playwright.config.ts` if you don't have one); or write
|
|
82
|
-
it by hand:
|
|
34
|
+
**1. Scaffold the capture spec** (`npx styleproof-init` writes `e2e/styleproof.spec.ts`), then describe your surfaces:
|
|
83
35
|
|
|
84
36
|
```ts
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
37
|
+
import { defineStyleMapCapture } from 'styleproof';
|
|
38
|
+
|
|
39
|
+
defineStyleMapCapture({
|
|
40
|
+
dir: process.env.STYLEMAP_DIR, // inert until set, so it lives safely beside other tests
|
|
41
|
+
surfaces: [
|
|
42
|
+
{
|
|
43
|
+
key: 'landing',
|
|
44
|
+
go: async (page) => {
|
|
45
|
+
await page.goto('/');
|
|
46
|
+
await page.waitForLoadState('networkidle');
|
|
47
|
+
await page.evaluate(() => document.fonts.ready);
|
|
48
|
+
},
|
|
49
|
+
widths: [1280, 768, 390], // one viewport per @media band
|
|
94
50
|
},
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
key: 'home-menu-open', // states matter: model them as surfaces
|
|
100
|
-
go: async (page) => {
|
|
101
|
-
await page.goto('/', { waitUntil: 'networkidle' });
|
|
102
|
-
await page.getByRole('button', { name: 'Menu' }).click();
|
|
103
|
-
},
|
|
104
|
-
widths: [390],
|
|
105
|
-
},
|
|
106
|
-
];
|
|
107
|
-
|
|
108
|
-
defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
The spec is **inert** unless `STYLEMAP_DIR` is set, so it costs nothing in a normal
|
|
112
|
-
test run.
|
|
113
|
-
|
|
114
|
-
**2. Capture a baseline and commit it** — **always against a production build**, dev
|
|
115
|
-
servers inject their own styles:
|
|
116
|
-
|
|
117
|
-
```sh
|
|
118
|
-
STYLEMAP_DIR=baseline npx playwright test styleproof # writes __stylemaps__/baseline/*.json.gz + *.png
|
|
119
|
-
git add e2e/__stylemaps__/baseline && git commit -m "stylemap baseline"
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
The `.json.gz` maps are small (a content-heavy page gzips to ~80 KB); the `.png`
|
|
123
|
-
full-page screenshots let the report crop changed regions later.
|
|
124
|
-
|
|
125
|
-
**3. Let CI diff every push** against that committed baseline:
|
|
126
|
-
|
|
127
|
-
```sh
|
|
128
|
-
STYLEMAP_DIR=ci npx playwright test styleproof
|
|
129
|
-
npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
An empty diff exits `0` (certified). A non-empty diff exits `1` and names every
|
|
133
|
-
element, property, and state that drifted. After an _intentional_ change, regenerate
|
|
134
|
-
the baseline and commit it with your diff — the gate is a ratchet, not a freeze.
|
|
135
|
-
|
|
136
|
-
## Core concepts
|
|
137
|
-
|
|
138
|
-
### StyleMap
|
|
139
|
-
|
|
140
|
-
A `StyleMap` is the JSON snapshot of one page state at one width. It has three layers:
|
|
141
|
-
|
|
142
|
-
- **`elements`** — every element's computed style, pruned against per-tag UA defaults
|
|
143
|
-
(measured live in a stylesheet-free iframe) so files stay small, plus its
|
|
144
|
-
pseudo-elements and its document-space bounding box (so reports can crop the
|
|
145
|
-
screenshot around it).
|
|
146
|
-
- **`states`** — for every interactive element (`a, button, input, textarea, select,
|
|
147
|
-
summary, [role="button"], [tabindex]`), the delta that `:hover`, `:focus` /
|
|
148
|
-
`:focus-visible`, and `:active` apply, forced via CDP with no real mouse or focus,
|
|
149
|
-
captured over the element's whole subtree so parent-state descendant rules are seen.
|
|
150
|
-
- **`defaults`** — the per-tag UA baseline used to prune `elements`, kept so the diff
|
|
151
|
-
can resolve a pruned property back to its default when only one side set it.
|
|
152
|
-
|
|
153
|
-
### Surfaces
|
|
154
|
-
|
|
155
|
-
A **surface** is one deterministic page state worth certifying. You give it a `key`, a
|
|
156
|
-
`go(page)` function that navigates and drives the page to that state (ending
|
|
157
|
-
_settled_: fonts loaded, entrance animations done), an optional `ignore` list of
|
|
158
|
-
selectors for nondeterministic regions, and the `widths` to sweep. Distinct states
|
|
159
|
-
(menu open, dialog open, selected tab, form error) are distinct surfaces.
|
|
160
|
-
|
|
161
|
-
### DOM-structure keys
|
|
162
|
-
|
|
163
|
-
Every element is keyed by its structural path, `body > nav:nth-child(3) >
|
|
164
|
-
a:nth-child(2)`, **never by class name**. A migration can rewrite every `class`
|
|
165
|
-
attribute freely while the map stays comparable. If the DOM itself changes, the diff
|
|
166
|
-
says so loudly under a `DOM` finding: a CSS-only refactor must not touch structure.
|
|
167
|
-
|
|
168
|
-
Custom properties (`--*`) are deliberately ignored: they are inputs, not outcomes.
|
|
169
|
-
Every visual effect of a variable lands in a real longhand that _is_ compared, so
|
|
170
|
-
renaming a token is invisible while changing what an element resolves to is not.
|
|
171
|
-
(This also silences Tailwind's `--tw-*` machinery.)
|
|
172
|
-
|
|
173
|
-
### Derived / reflow-noise filtering
|
|
174
|
-
|
|
175
|
-
The **certification differ** (`styleproof-diff`) compares everything, including
|
|
176
|
-
layout-derived longhands (`width`, `height`, `top`, `transform-origin`…), because a
|
|
177
|
-
reflow _is_ a change to certify. The **report** filters those by default: on a reflow
|
|
178
|
-
they change all the way up the ancestor chain (`body`, `main`, `section`…), and an
|
|
179
|
-
element whose _only_ changes are derived is a reflow casualty, not a styling change,
|
|
180
|
-
so it must not anchor a crop region (that would zoom the crop to the whole page).
|
|
181
|
-
Pass `includeLayoutNoise: true` to keep them in the report too.
|
|
182
|
-
|
|
183
|
-
### The readable report
|
|
184
|
-
|
|
185
|
-
When a diff is _intentional_, you want to look at it, not read a wall of longhands.
|
|
186
|
-
`styleproof-report` crops the before/after screenshots around the **outermost changed
|
|
187
|
-
element** (descendants fold into their ancestor, nearby regions merge), stitches each
|
|
188
|
-
pair into one labelled side-by-side image (grey bar = before, blue bar = after), and
|
|
189
|
-
writes the exact changes as a per-element table. It collapses the noise: shorthand
|
|
190
|
-
families (`padding: 26px 24px → 28px`), logical / `currentColor` duplicates dropped,
|
|
191
|
-
repeated tokens folded (`368px ×3`), identical sibling elements grouped (`×5`),
|
|
192
|
-
labelled by their semantic class, colours in their own cells so GitHub renders
|
|
193
|
-
swatches.
|
|
194
|
-
|
|
195
|
-

|
|
196
|
-
|
|
197
|
-
<sub>◀ before · after ▶ — grey bar = before, blue bar = after</sub>
|
|
198
|
-
|
|
199
|
-
> **`div.who-grid`**
|
|
200
|
-
>
|
|
201
|
-
> | Property | Before | After |
|
|
202
|
-
> | ----------------------- | -------------------- | -------------------- |
|
|
203
|
-
> | `grid-template-columns` | `368px ×3` | `548px ×2` |
|
|
204
|
-
> | `gap` | `1px` | `12px` |
|
|
205
|
-
> | `border-color` | `rgb(150, 172, 205)` | `rgb(229, 231, 235)` |
|
|
206
|
-
>
|
|
207
|
-
> **`h3`** ×5 · **`div.who`** ×3 …
|
|
208
|
-
|
|
209
|
-
The whole imaging pipeline is Playwright + Node, **no browser interaction**. Captures
|
|
210
|
-
save a full-page screenshot next to each map by default, so the committed baseline
|
|
211
|
-
carries both the facts and the pixels; generating a report never rebuilds the old code.
|
|
212
|
-
|
|
213
|
-
## API reference
|
|
214
|
-
|
|
215
|
-
Everything is exported from the package root:
|
|
216
|
-
|
|
217
|
-
```ts
|
|
218
|
-
import {
|
|
219
|
-
captureStyleMap,
|
|
220
|
-
saveStyleMap,
|
|
221
|
-
loadStyleMap,
|
|
222
|
-
defineStyleMapCapture,
|
|
223
|
-
diffStyleMaps,
|
|
224
|
-
diffStyleMapDirs,
|
|
225
|
-
findingLabel,
|
|
226
|
-
generateStyleMapReport,
|
|
227
|
-
summarizeProps,
|
|
228
|
-
prettyLabel,
|
|
229
|
-
} from 'styleproof';
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
| Export | Signature | Description |
|
|
233
|
-
| ------------------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
234
|
-
| `captureStyleMap` | `(page: Page, options?: CaptureOptions) => Promise<StyleMap>` | Capture the page's complete style map. Drive the page to the target state first; it reads whatever is in front of it. Records elements, pseudo-elements, forced `:hover`/`:focus`/`:active` deltas, declared motion, and each element's bounding box. |
|
|
235
|
-
| `saveStyleMap` | `(filePath: string, map: StyleMap) => void` | Write a map to disk; gzipped when the path ends in `.gz`. Creates parent dirs. |
|
|
236
|
-
| `loadStyleMap` | `(filePath: string) => StyleMap` | Read a map written by `saveStyleMap` (`.json` or `.json.gz`). |
|
|
237
|
-
| `defineStyleMapCapture` | `(opts: DefineOptions) => void` | Generate one Playwright test per surface × width, saving `<baseDir>/<dir>/<key>@<width>.json.gz` (+ `.png`). Skips entirely when `dir` is undefined, so the spec is inert in normal runs. Call at module top level in a `.spec.ts`. |
|
|
238
|
-
| `diffStyleMaps` | `(a: StyleMap, b: StyleMap) => Finding[]` | Structured diff of two maps for the same surface: DOM, style, and state findings. |
|
|
239
|
-
| `diffStyleMapDirs` | `(dirA: string, dirB: string) => { surfaces: SurfaceDiff[]; counts: DiffCounts }` | Diff every same-named capture between two directories. Throws if neither dir has any `.json(.gz)` captures. |
|
|
240
|
-
| `findingLabel` | `(path: string, cls: string) => string` | Human label: structural path plus a truncated `.class` hint (first 3 classes). |
|
|
241
|
-
| `generateStyleMapReport` | `(opts: ReportOptions) => ReportResult` | Crop the before/after screenshots around changed regions and write `report.md`, `report.json`, and `crops/*.png`. |
|
|
242
|
-
| `summarizeProps` | `(props: PropChange[]) => PropChange[]` | The report's property collapser: dedupe logical aliases, fold `currentColor` echoes, collapse shorthand families (`padding: 26px 24px`), round and run-length values. Exported for downstream report builders. |
|
|
243
|
-
| `prettyLabel` | `(path: string, cls: string) => string` | Label an element by its semantic marker class (`div.who-grid`) rather than its structural path. |
|
|
244
|
-
|
|
245
|
-
### Key exported types
|
|
246
|
-
|
|
247
|
-
```ts
|
|
248
|
-
type Rect = [number, number, number, number]; // document-space [x, y, w, h], rounded
|
|
249
|
-
|
|
250
|
-
type StyleMap = {
|
|
251
|
-
defaults: Record<string, Props>; // per-tag UA baseline
|
|
252
|
-
elements: Record<string, ElementEntry>; // keyed by structural path
|
|
253
|
-
states: Record<string, Record<string, Record<string, Props>>>; // path → state → subpath → props
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
type ElementEntry = {
|
|
257
|
-
tag: string;
|
|
258
|
-
cls: string;
|
|
259
|
-
rect?: Rect;
|
|
260
|
-
style: Props; // Record<string, string>
|
|
261
|
-
pseudo?: Record<string, Props>; // '::before' | '::after' | '::marker' | '::placeholder'
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
type CaptureOptions = {
|
|
265
|
-
ignore?: string[]; // selectors skipped with their subtrees
|
|
266
|
-
captureStates?: boolean; // capture forced :hover/:focus/:active deltas (default true)
|
|
267
|
-
maxInteractive?: number; // cap forced-state elements per surface (default 800)
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
type Surface = {
|
|
271
|
-
key: string; // unique file-name prefix
|
|
272
|
-
go: (page: Page) => Promise<void>;
|
|
273
|
-
ignore?: string[];
|
|
274
|
-
widths: number[]; // one per @media band
|
|
275
|
-
height?: number | ((width: number) => number); // default 800
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
type DefineOptions = {
|
|
279
|
-
surfaces: Surface[];
|
|
280
|
-
dir: string | undefined; // capture label; undefined = skip
|
|
281
|
-
baseDir?: string; // default '__stylemaps__'
|
|
282
|
-
screenshots?: boolean; // default true
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
type PropChange = { prop: string; before: string; after: string };
|
|
286
|
-
|
|
287
|
-
type Finding =
|
|
288
|
-
| { kind: 'dom'; path: string; cls: string; change: 'added' | 'removed' | 'retagged'; detail?: string }
|
|
289
|
-
| { kind: 'style'; path: string; cls: string; pseudo: string | null; props: PropChange[] }
|
|
290
|
-
| { kind: 'state'; path: string; cls: string; state: string; sub: string; props: PropChange[] };
|
|
291
|
-
|
|
292
|
-
type SurfaceDiff = { surface: string; missing?: 'before' | 'after'; findings: Finding[] };
|
|
293
|
-
type DiffCounts = { dom: number; style: number; state: number };
|
|
294
|
-
|
|
295
|
-
type ReportOptions = {
|
|
296
|
-
beforeDir: string;
|
|
297
|
-
afterDir: string;
|
|
298
|
-
outDir: string;
|
|
299
|
-
imageBaseUrl?: string; // prefix for image URLs in report.md (default: relative paths)
|
|
300
|
-
pad?: number; // padding around changed rects (default 24)
|
|
301
|
-
minWidth?: number; // default 320
|
|
302
|
-
minHeight?: number; // default 180
|
|
303
|
-
maxHeight?: number; // crops clamped to this (default 1600)
|
|
304
|
-
maxCrops?: number; // regions per surface before collapsing to one (default 6)
|
|
305
|
-
includeLayoutNoise?: boolean; // keep size/position-derived longhands (default false)
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
type ReportResult = {
|
|
309
|
-
changedSurfaces: number;
|
|
310
|
-
totalFindings: number;
|
|
311
|
-
reportMdPath: string;
|
|
312
|
-
reportJsonPath: string;
|
|
313
|
-
};
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
```ts
|
|
317
|
-
// Example: programmatic use, same engines as the CLIs.
|
|
318
|
-
const map = await captureStyleMap(page, { ignore: ['.live-feed'] });
|
|
319
|
-
saveStyleMap('maps/home@1280.json.gz', map);
|
|
320
|
-
const before = loadStyleMap('maps/home@1280.json.gz');
|
|
321
|
-
|
|
322
|
-
const findings = diffStyleMaps(before, map);
|
|
323
|
-
const { surfaces, counts } = diffStyleMapDirs('maps/before', 'maps/after');
|
|
324
|
-
generateStyleMapReport({ beforeDir: 'maps/before', afterDir: 'maps/after', outDir: 'report' });
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
## CLI reference
|
|
328
|
-
|
|
329
|
-
The three bins are installed on your PATH (run via `npx` or an npm script). They read
|
|
330
|
-
the built `dist/`, so they work from the installed package out of the box. Each takes
|
|
331
|
-
`-h`/`--help`.
|
|
332
|
-
|
|
333
|
-
### `styleproof-init` — scaffold a capture spec
|
|
334
|
-
|
|
335
|
-
```
|
|
336
|
-
styleproof-init [--dir <path>] [--base-url <url>] [--force]
|
|
51
|
+
],
|
|
52
|
+
});
|
|
337
53
|
```
|
|
338
54
|
|
|
339
|
-
|
|
340
|
-
| ------------------ | ------------------------ | ----------------------------------------------------------------------- |
|
|
341
|
-
| `--dir <path>` | `e2e/styleproof.spec.ts` | Where to write the starter spec (with a `settle()` helper + a Surface). |
|
|
342
|
-
| `--base-url <url>` | `http://localhost:3000` | `baseURL` for a generated `playwright.config.ts` (only if none exists). |
|
|
343
|
-
| `--force` | off | Overwrite the spec if it already exists. Idempotent without it. |
|
|
344
|
-
|
|
345
|
-
Exit `0` on success (or nothing to do), `2` on a usage error. An existing
|
|
346
|
-
`playwright.config.ts` is never touched.
|
|
347
|
-
|
|
348
|
-
### `styleproof-diff` — the certification gate
|
|
349
|
-
|
|
350
|
-
```
|
|
351
|
-
styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>]
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
| Flag | Default | Description |
|
|
355
|
-
| ------------------------------------ | ------- | --------------------------------------------------------------------------------------------- |
|
|
356
|
-
| `<beforeDir>` | — | Directory of baseline `.json(.gz)` captures (required). |
|
|
357
|
-
| `<afterDir>` | — | Directory of fresh `.json(.gz)` captures (required). |
|
|
358
|
-
| `--max N` (or `--max=N`) | `40` | Max diff lines printed per surface before truncating. (The GitHub Action passes `--max=200`.) |
|
|
359
|
-
| `--json <file>` (or `--json=<file>`) | — | Also write the structured `{ counts, surfaces }` result to `<file>`. |
|
|
360
|
-
|
|
361
|
-
| Exit code | Meaning |
|
|
362
|
-
| --------- | ---------------------------------------------------------------------------------------------- |
|
|
363
|
-
| `0` | Identical — every computed style, pseudo-element, and state matches. **Certified.** |
|
|
364
|
-
| `1` | Differences found (DOM, style, and/or state). |
|
|
365
|
-
| `2` | Usage or capture error (bad args, missing dir, no captures, non-finite `--max`, unknown flag). |
|
|
366
|
-
|
|
367
|
-
### `styleproof-report` — the reviewable visual report
|
|
368
|
-
|
|
369
|
-
```
|
|
370
|
-
styleproof-report <beforeDir> <afterDir> --out <dir> [options]
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
| Flag | Default | Description |
|
|
374
|
-
| -------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
375
|
-
| `<beforeDir>` | — | Baseline captures (`.json.gz` + `.png` for crops). |
|
|
376
|
-
| `<afterDir>` | — | Fresh captures (`.json.gz` + `.png` for crops). |
|
|
377
|
-
| `--out <dir>` (or `--out=<dir>`) | `styleproof-report` | Output directory: writes `report.md`, `report.json`, `crops/*.png`. |
|
|
378
|
-
| `--image-base-url <url>` (or `=<url>`) | relative paths | Prefix for image URLs in `report.md` (e.g. a raw GitHub URL). Omit to keep relative paths that render from a committed file. |
|
|
379
|
-
| `--pad <px>` | `24` | Padding around the changed rects when cropping. |
|
|
380
|
-
| `--max-crops <n>` | `6` | Max crop regions per surface before collapsing to one union crop. |
|
|
381
|
-
| `--min-width <px>` | `320` | Minimum crop width, for context around tiny changes. |
|
|
382
|
-
| `--min-height <px>` | `180` | Minimum crop height. |
|
|
383
|
-
| `--include-layout-noise` | off | Keep size/position-derived longhands (`height`, `width`, `transform-origin`, `top`…) in the report instead of filtering them. |
|
|
384
|
-
|
|
385
|
-
| Exit code | Meaning |
|
|
386
|
-
| --------- | ----------------------------------------------------------------------------- |
|
|
387
|
-
| `0` | No changes — an empty report was written. |
|
|
388
|
-
| `1` | Report generated (one or more changed surfaces). |
|
|
389
|
-
| `2` | Usage error (wrong arg count, unknown/non-numeric flag, or capture/IO error). |
|
|
390
|
-
|
|
391
|
-
Numeric flags also accept the `--flag=value` form. The remaining `maxHeight` knob
|
|
392
|
-
(crop height clamp, default 1600) is available through the programmatic
|
|
393
|
-
`generateStyleMapReport` API.
|
|
394
|
-
|
|
395
|
-
## GitHub Action
|
|
396
|
-
|
|
397
|
-
The repo ships a composite action that diffs against your committed baseline and, on
|
|
398
|
-
changes, commits a compact report to an orphan branch and posts it to the PR. It
|
|
399
|
-
fails the job while changes are unapproved; **approving = regenerating the committed
|
|
400
|
-
baseline** and pushing it with the PR. The comment updates in place on every push and
|
|
401
|
-
flips to ✓ when clean.
|
|
55
|
+
**2. Wire CI to capture base and head, then hand both to the Action:**
|
|
402
56
|
|
|
403
57
|
```yaml
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
- name: Style-map report
|
|
408
|
-
uses: BenSheridanEdwards/styleproof@v1
|
|
409
|
-
with:
|
|
410
|
-
baseline-dir: e2e/__stylemaps__/baseline
|
|
411
|
-
fresh-dir: e2e/__stylemaps__/ci
|
|
412
|
-
# report-branch: styleproof-reports # default (orphan; created on first run)
|
|
413
|
-
# inline-images: auto # auto | always | never
|
|
414
|
-
# fail-on-diff: 'true' # default
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
### Inputs
|
|
418
|
-
|
|
419
|
-
| Input | Required | Default | Description |
|
|
420
|
-
| --------------- | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
421
|
-
| `baseline-dir` | yes | — | Directory with the committed baseline captures (`.json.gz` + `.png`). |
|
|
422
|
-
| `fresh-dir` | yes | — | Directory with the freshly captured maps to compare. |
|
|
423
|
-
| `report-branch` | no | `styleproof-reports` | Orphan branch that stores reports (created on first run). One `pr-<n>/` folder per PR; never pruned. |
|
|
424
|
-
| `inline-images` | no | `auto` | `auto` \| `always` \| `never`. `auto` embeds composites in the comment for public repos and links the report for private repos. |
|
|
425
|
-
| `github-token` | no | `${{ github.token }}` | Token used to push the report branch and post the comment. |
|
|
426
|
-
| `fail-on-diff` | no | `'true'` | Fail the job when differences are found. |
|
|
427
|
-
|
|
428
|
-
### Outputs
|
|
429
|
-
|
|
430
|
-
| Output | Description |
|
|
431
|
-
| ------------ | ------------------------------------------------------------------- |
|
|
432
|
-
| `changed` | `"true"` when any computed style, pseudo-element, or state changed. |
|
|
433
|
-
| `report-url` | Blob URL of the committed report (when changed). |
|
|
434
|
-
|
|
435
|
-
### Why two image modes (and why it's automatic)
|
|
436
|
-
|
|
437
|
-
The two ways an image can appear on a PR have **opposite** privacy behaviour, so
|
|
438
|
-
`inline-images: auto` picks per repo:
|
|
439
|
-
|
|
440
|
-
| Placement | How GitHub fetches it | Private repo |
|
|
441
|
-
| ----------------------------------------------------- | -------------------------------------- | --------------------------------------------------- |
|
|
442
|
-
| **comment body** `` | anonymously, via the Camo proxy | private URL **404s → broken**; needs a public URL |
|
|
443
|
-
| **committed file** (`report.md` + relative `crops/…`) | through **your authenticated session** | **renders inline** — like a private README's images |
|
|
58
|
+
# .github/workflows/styleproof.yml
|
|
59
|
+
name: StyleProof
|
|
60
|
+
on: pull_request
|
|
444
61
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
-
|
|
448
|
-
|
|
62
|
+
jobs:
|
|
63
|
+
styleproof:
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
permissions:
|
|
66
|
+
contents: write # push the report branch
|
|
67
|
+
pull-requests: write # post/update the comment
|
|
68
|
+
statuses: write # set the StyleProof status
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/checkout@v4
|
|
71
|
+
with:
|
|
72
|
+
fetch-depth: 0 # need the base branch too
|
|
449
73
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
74
|
+
# capture the base branch
|
|
75
|
+
- run: git checkout ${{ github.event.pull_request.base.sha }}
|
|
76
|
+
- run: npm ci && npm run build && (npm run serve &) # your framework's build + serve
|
|
77
|
+
- run: npx wait-on http://localhost:3000
|
|
78
|
+
- run: STYLEMAP_DIR=base npx playwright test e2e/styleproof.spec.ts
|
|
453
79
|
|
|
454
|
-
|
|
80
|
+
# capture the PR head
|
|
81
|
+
- run: git checkout ${{ github.event.pull_request.head.sha }}
|
|
82
|
+
- run: npm ci && npm run build && (npm run serve &)
|
|
83
|
+
- run: npx wait-on http://localhost:3000
|
|
84
|
+
- run: STYLEMAP_DIR=head npx playwright test e2e/styleproof.spec.ts
|
|
455
85
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
├── pr-9/ report.md + crops/*-composite.png
|
|
463
|
-
└── pr-12/ …
|
|
86
|
+
# report + gate
|
|
87
|
+
- uses: BenSheridanEdwards/styleproof@v1
|
|
88
|
+
with:
|
|
89
|
+
baseline-dir: base
|
|
90
|
+
fresh-dir: head
|
|
91
|
+
require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
|
|
464
92
|
```
|
|
465
93
|
|
|
466
|
-
- **
|
|
467
|
-
`…/blob/styleproof-reports/pr-9/report.md` is permanent for the life of the PR.
|
|
468
|
-
Reports are **never pruned**; per-run history lives in the branch's git commits.
|
|
469
|
-
- **Compact.** Only the **composite** is committed (alpha dropped, max deflate,
|
|
470
|
-
adaptive filtering): ~30–90 KB per change, a few hundred KB per PR.
|
|
471
|
-
- **Concurrency-safe.** Different PRs touch different folders; a rejected push just
|
|
472
|
-
rebase-replays (the action retries).
|
|
473
|
-
- **Reclaiming history.** Git keeps every overwritten image, so a busy repo's branch
|
|
474
|
-
history grows. The _tree_ (what serves the links) stays small; to shrink `.git`,
|
|
475
|
-
squash the orphan branch when it's quiet — current reports keep their URLs:
|
|
476
|
-
```sh
|
|
477
|
-
git checkout --orphan tmp styleproof-reports && git commit -qm "squash reports" \
|
|
478
|
-
&& git branch -M tmp styleproof-reports && git push -f origin styleproof-reports
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
## CI recipes
|
|
482
|
-
|
|
483
|
-
Every recipe is the same shape: produce a **production build**, serve it, point
|
|
484
|
-
`BASE_URL` (or your Playwright `baseURL`) at it, capture, diff against the committed
|
|
485
|
-
baseline. Use the Action step from above to also post a PR comment.
|
|
486
|
-
|
|
487
|
-
### Next.js
|
|
94
|
+
**3. Copy [`example/styleproof-approve.yml`](https://github.com/BenSheridanEdwards/styleproof/blob/main/example/styleproof-approve.yml) to `.github/workflows/` on your default branch** — GitHub only runs `issue_comment` workflows from there, so the checkboxes do nothing until it's merged.
|
|
488
95
|
|
|
489
|
-
|
|
490
|
-
- uses: actions/checkout@v4
|
|
491
|
-
- uses: actions/setup-node@v4
|
|
492
|
-
with: { node-version: 20, cache: npm }
|
|
493
|
-
- run: npm ci
|
|
494
|
-
- run: npx playwright install --with-deps chromium
|
|
495
|
-
- run: npm run build
|
|
496
|
-
- run: npx next start -p 3000 &
|
|
497
|
-
- run: npx wait-on http://localhost:3000
|
|
498
|
-
- run: BASE_URL=http://localhost:3000 STYLEMAP_DIR=ci npx playwright test styleproof
|
|
499
|
-
- run: npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
|
|
500
|
-
```
|
|
96
|
+
**4. Require the `StyleProof` status** in branch protection. Now an unsigned visual change can't merge.
|
|
501
97
|
|
|
502
|
-
|
|
98
|
+
> Capture both sides in the **same environment** (same machine, same env vars): if env vars change _what renders_, base and head will diff on DOM no PR touched.
|
|
503
99
|
|
|
504
|
-
|
|
505
|
-
- run: npm ci && npx playwright install --with-deps chromium
|
|
506
|
-
- run: npm run build # → dist/
|
|
507
|
-
- run: npx vite preview --port 4173 & # serves the production build
|
|
508
|
-
- run: npx wait-on http://localhost:4173
|
|
509
|
-
- run: BASE_URL=http://localhost:4173 STYLEMAP_DIR=ci npx playwright test styleproof
|
|
510
|
-
- run: npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
|
|
511
|
-
```
|
|
100
|
+
## Reference
|
|
512
101
|
|
|
513
|
-
|
|
102
|
+
**Action `BenSheridanEdwards/styleproof@v1`** — key inputs:
|
|
514
103
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
-
|
|
518
|
-
-
|
|
519
|
-
-
|
|
520
|
-
-
|
|
521
|
-
-
|
|
522
|
-
```
|
|
104
|
+
| Input | Default | Purpose |
|
|
105
|
+
| ------------------ | ------------ | -------------------------------------------------------------------------- |
|
|
106
|
+
| `baseline-dir` | _required_ | Base-branch captures. |
|
|
107
|
+
| `fresh-dir` | _required_ | PR-head captures to compare. |
|
|
108
|
+
| `require-approval` | `false` | Review-gate mode: set the `StyleProof` status instead of failing. |
|
|
109
|
+
| `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
|
|
110
|
+
| `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
|
|
523
111
|
|
|
524
|
-
|
|
112
|
+
Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `inline-images`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/styleproof/blob/main/action.yml).
|
|
525
113
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
|
|
532
|
-
```
|
|
114
|
+
**CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
|
|
115
|
+
|
|
116
|
+
- `styleproof-init` — scaffold the capture spec (and a starter `playwright.config.ts` if none exists).
|
|
117
|
+
- `styleproof-diff <beforeDir> <afterDir>` — the certify gate; exits `1` on any difference.
|
|
118
|
+
- `styleproof-report <beforeDir> <afterDir> --out <dir>` — render the diff to a Markdown report with before/after crops.
|
|
533
119
|
|
|
534
|
-
|
|
535
|
-
paths (`page.goto('/')`).
|
|
536
|
-
|
|
537
|
-
## Baselines and the env-parity gotcha
|
|
538
|
-
|
|
539
|
-
A baseline is the committed `before` of your refactor and the certified state CI
|
|
540
|
-
compares against. **Capture it in the same environment CI uses.** Anything that
|
|
541
|
-
changes what renders must match between the baseline capture and CI's fresh capture:
|
|
542
|
-
|
|
543
|
-
- **Feature flags** that show or hide a panel.
|
|
544
|
-
- **API tokens** that gate a section (e.g. a live-data widget only present when a key
|
|
545
|
-
is set).
|
|
546
|
-
- **Env-dependent copy** (a `mailto:` line built from an env var, a conditional embed).
|
|
547
|
-
|
|
548
|
-
If your local `.env` makes the page render _more_ than CI will, baselines captured
|
|
549
|
-
locally can never pass on the runner: the extra elements exist on one side only and
|
|
550
|
-
show up as DOM findings. The fix is to capture the baseline with the **same** env the
|
|
551
|
-
runner has (typically no `.env.local`), then commit it. After an _intentional_ style
|
|
552
|
-
change, regenerate the committed baseline from a production build in that same
|
|
553
|
-
environment and commit it with your diff.
|
|
554
|
-
|
|
555
|
-
## Determinism
|
|
556
|
-
|
|
557
|
-
Captures read whatever is in front of them, so the page must be **settled and
|
|
558
|
-
repeatable** before `captureStyleMap` runs. The differ has zero tolerance, so any
|
|
559
|
-
nondeterminism becomes a false diff. In your surface's `go`:
|
|
560
|
-
|
|
561
|
-
- **Fonts.** `await page.evaluate(() => document.fonts.ready)` before capturing —
|
|
562
|
-
unloaded fonts change `font-family` fallbacks and metrics.
|
|
563
|
-
- **Animations / transitions.** The capture freezes them so every value is a settled
|
|
564
|
-
end state, but _entrance_ animations driven by JS or IntersectionObserver must
|
|
565
|
-
already have finished. Scroll the page to trigger reveals, then wait; or inject CSS
|
|
566
|
-
forcing your reveal classes to their final values
|
|
567
|
-
(`.reveal{opacity:1!important;transform:none!important}`).
|
|
568
|
-
- **Scroll-reveal.** Walk the page top to bottom (see `example/styleproof.spec.ts`'s
|
|
569
|
-
`settle` helper) so every observer fires, then scroll back to `0`.
|
|
570
|
-
- **Live / nondeterministic regions.** List them in `ignore` (live feeds, ads,
|
|
571
|
-
third-party embeds, timestamps); the elements and their subtrees are skipped.
|
|
572
|
-
- **Same build state.** Layout-derived values are part of the map by design, so if
|
|
573
|
-
text content differs between captures, expect diffs. Capture the same build.
|
|
574
|
-
|
|
575
|
-
## Limitations
|
|
576
|
-
|
|
577
|
-
- **Chromium only for forced-state capture.** The `:hover`/`:focus`/`:active` deltas
|
|
578
|
-
use CDP (`CSS.forcePseudoState`), which is Chromium-only. Base element and
|
|
579
|
-
pseudo-element capture work in any Playwright browser.
|
|
580
|
-
- **Same machine, same browser version for before/after.** Computed values are far
|
|
581
|
-
less platform-sensitive than pixels, but font metrics can still differ across OSes
|
|
582
|
-
and browser builds. Capture both sides on the same runner image.
|
|
583
|
-
- **No shadow-DOM piercing.** Capture walks `document.querySelectorAll('body *')`; it
|
|
584
|
-
does not descend into shadow roots, so a refactor inside a web component's shadow
|
|
585
|
-
tree would be falsely certified identical. Capture emits a one-time warning counting
|
|
586
|
-
the shadow hosts it skipped, so the gap is loud, not silent.
|
|
587
|
-
- **No iframe piercing.** Iframe content (same- or cross-origin) is not traversed for
|
|
588
|
-
the same reason; same-origin frames are counted in the same warning. To certify a
|
|
589
|
-
frame, point a separate surface at its document directly.
|
|
590
|
-
- **Layout-derived values are included** (used track sizes, element heights, offsets).
|
|
591
|
-
This is intentional for the certification differ, but it means a content or reflow
|
|
592
|
-
change produces diffs; the report filters these unless `includeLayoutNoise: true`.
|
|
593
|
-
- **Forced-state capture is O(interactive elements × 3 states).** A content-heavy page
|
|
594
|
-
takes a few seconds per surface.
|
|
595
|
-
|
|
596
|
-
## Troubleshooting
|
|
597
|
-
|
|
598
|
-
| Symptom | Likely cause / fix |
|
|
599
|
-
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
600
|
-
| `npx playwright test styleproof` does nothing / spec skipped | `STYLEMAP_DIR` is unset. The capture spec is inert by design until you set it (`STYLEMAP_DIR=before …`). |
|
|
601
|
-
| `no .json(.gz) captures found in …` | You diffed before capturing, or pointed at the wrong dir. Check `<baseDir>/<label>/`. |
|
|
602
|
-
| `styleproof: interactive-element count skew …; skipping forced … capture` | The DOM changed between the CDP query and the page evaluate (e.g. a late-rendering widget). The base + pseudo capture still succeed; only the forced-state layer is skipped for that surface. Settle the surface fully, or `ignore` the unstable region, then re-run. |
|
|
603
|
-
| Capture warns about shadow hosts / iframes | Styles inside shadow roots and frames are not captured (see Limitations). Point a separate surface at the frame's document, or accept the gap. |
|
|
604
|
-
| Diffs you didn't expect, all `width`/`height`/`top`/… | A real reflow (content or layout changed). The differ keeps these; use the _report_ (which filters them) to see the styling intent, or capture the identical build state. |
|
|
605
|
-
| Baseline passes locally, fails in CI with DOM findings | Env parity: your local env renders elements CI doesn't (or vice versa). Capture the baseline in CI's environment — see _Baselines and the env-parity gotcha_. |
|
|
606
|
-
| Diffs every run, fonts-related | Fonts weren't ready at capture. Await `document.fonts.ready` (and any web-font load) in `go`. |
|
|
607
|
-
| Private-repo PR comment shows no images, only a link | Expected: GitHub can't render images in a private comment body. Click the link — the committed report renders the crops inline through your session. |
|
|
608
|
-
| `styleproof-diff: command not found` from a fresh clone | The bins import the built `dist/`. Run `npm run build` first (the published npm package ships a prebuilt `dist/`). |
|
|
609
|
-
|
|
610
|
-
## How this compares to pixel snapshot tools
|
|
611
|
-
|
|
612
|
-
| | **StyleProof** | **Percy / Chromatic** | **Playwright `toHaveScreenshot`** |
|
|
613
|
-
| -------------------------------------------------------------------------- | ------------------------------- | ------------------------- | --------------------------------- |
|
|
614
|
-
| Compares | computed CSS longhands | rendered pixels | rendered pixels |
|
|
615
|
-
| Certifies invisible state (hover/focus/active, hidden, between-breakpoint) | **yes** | no | no |
|
|
616
|
-
| Tolerance needed | **none** (exact values) | antialiasing threshold | `maxDiffPixels` threshold |
|
|
617
|
-
| Cross-browser / cross-OS fidelity | values only (no real render) | **strong** (real renders) | good |
|
|
618
|
-
| Catches what it wasn't told to model | no (model your surfaces) | **yes** (whole frame) | yes (the frame) |
|
|
619
|
-
| Hosted dashboard / approvals UI | git-based (commit the baseline) | **yes** (SaaS) | local/CI files |
|
|
620
|
-
| Cost | free, MIT, self-hosted | paid SaaS (free tiers) | free |
|
|
621
|
-
|
|
622
|
-
They solve different halves of the problem. Pixel tools _catch_ drift you didn't think
|
|
623
|
-
to check, across real browser renders. The style map _certifies_ that a refactor
|
|
624
|
-
changed nothing, including the states and rules a screenshot can never see, with no
|
|
625
|
-
tolerance to hide a real change in. **Run both:** screenshots for discovery, the style
|
|
626
|
-
map for proof.
|
|
627
|
-
|
|
628
|
-
## Battle-tested: what it caught
|
|
629
|
-
|
|
630
|
-
This tool was extracted from a real CSS-to-Tailwind migration (~680 lines of bespoke
|
|
631
|
-
CSS across four stylesheets, certified to zero diff). Every one of these was caught by
|
|
632
|
-
the style map and invisible or ambiguous to pixels:
|
|
633
|
-
|
|
634
|
-
- **A base-layer reset eating button borders.** `button { border: none }` in the
|
|
635
|
-
global stylesheet meant `border` + `border-color` utilities (width and colour only)
|
|
636
|
-
rendered _no border at all_. Every bordered button needed an explicit
|
|
637
|
-
`border-solid`. The diff named all of them.
|
|
638
|
-
- **`grid-cols-2` is not `1fr 1fr`.** Tailwind's `repeat(2, minmax(0, 1fr))` removes
|
|
639
|
-
the min-content floor. One panel had been quietly overflowing its grid track by 8px;
|
|
640
|
-
the utility version clamped it, reflowing 50 elements. On `display: none` elements
|
|
641
|
-
the two forms even serialize differently.
|
|
642
|
-
- **`outline-none` is not `outline: none`.** Tailwind's utility is a 2px transparent
|
|
643
|
-
outline (an accessibility affordance). The forced-`:focus` capture flagged three new
|
|
644
|
-
longhands the original never set.
|
|
645
|
-
- **Shorthand resets.** `border-bottom: none` resets style _and_ colour to initial;
|
|
646
|
-
zeroing just the width (`border-b-0`) leaves the preflight's gray `solid` behind.
|
|
647
|
-
Same story for `hover:shadow-*` (ring placeholders), `rounded-full` (`9999px` vs
|
|
648
|
-
`50%`), `items-start` (`flex-start` vs `start`), and named font utilities (dropped
|
|
649
|
-
fallbacks from the stack).
|
|
650
|
-
- **A dropped `:hover` rule on a link** that every screenshot tool sailed past, because
|
|
651
|
-
nothing hovers in a screenshot.
|
|
652
|
-
|
|
653
|
-
## Contributing
|
|
654
|
-
|
|
655
|
-
Issues and PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, the
|
|
656
|
-
build/test loop, and the release process. Changes are recorded in
|
|
657
|
-
[CHANGELOG.md](CHANGELOG.md).
|
|
120
|
+
A programmatic API (`captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, …) is also exported. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/styleproof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/styleproof/tree/main/example) workflows.
|
|
658
121
|
|
|
659
122
|
## License
|
|
660
123
|
|
|
661
|
-
MIT © Ben Sheridan-Edwards
|
|
124
|
+
MIT © Ben Sheridan-Edwards
|
|
@@ -21,6 +21,8 @@ options:
|
|
|
21
21
|
--image-base-url <url> prefix for image URLs in report.md (default: relative)
|
|
22
22
|
--pad <px> padding around changed rects when cropping (default: 24)
|
|
23
23
|
--max-crops <n> max crop regions per surface before collapsing (default: 6)
|
|
24
|
+
--fold-details-at <n> row count at which a crop's property tables fold under a
|
|
25
|
+
<details> toggle (default: 0 = always; 'Infinity' = never)
|
|
24
26
|
--min-width <px> minimum crop width, for context (default: 320)
|
|
25
27
|
--min-height <px> minimum crop height, for context (default: 180)
|
|
26
28
|
--include-layout-noise keep size/position-derived longhands (height, width,
|
|
@@ -36,6 +38,7 @@ const args = [];
|
|
|
36
38
|
const flags = { out: 'styleproof-report', imageBaseUrl: '' };
|
|
37
39
|
let pad;
|
|
38
40
|
let maxCrops;
|
|
41
|
+
let foldDetailsAt;
|
|
39
42
|
let minWidth;
|
|
40
43
|
let minHeight;
|
|
41
44
|
let includeLayoutNoise = false;
|
|
@@ -52,6 +55,8 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
52
55
|
else if (a.startsWith('--pad=')) pad = Number(a.slice(6));
|
|
53
56
|
else if (a === '--max-crops') maxCrops = Number(argv[++i]);
|
|
54
57
|
else if (a.startsWith('--max-crops=')) maxCrops = Number(a.slice(12));
|
|
58
|
+
else if (a === '--fold-details-at') foldDetailsAt = Number(argv[++i]);
|
|
59
|
+
else if (a.startsWith('--fold-details-at=')) foldDetailsAt = Number(a.slice(18));
|
|
55
60
|
else if (a === '--min-width') minWidth = Number(argv[++i]);
|
|
56
61
|
else if (a.startsWith('--min-width=')) minWidth = Number(a.slice(12));
|
|
57
62
|
else if (a === '--min-height') minHeight = Number(argv[++i]);
|
|
@@ -78,6 +83,11 @@ for (const [name, val] of [
|
|
|
78
83
|
process.exit(2);
|
|
79
84
|
}
|
|
80
85
|
}
|
|
86
|
+
// foldDetailsAt allows Infinity ("never fold"), so it gets a NaN-only check.
|
|
87
|
+
if (foldDetailsAt !== undefined && Number.isNaN(foldDetailsAt)) {
|
|
88
|
+
console.error('--fold-details-at must be a number (or Infinity)');
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
81
91
|
|
|
82
92
|
let result;
|
|
83
93
|
try {
|
|
@@ -88,6 +98,7 @@ try {
|
|
|
88
98
|
imageBaseUrl: flags.imageBaseUrl || undefined,
|
|
89
99
|
pad,
|
|
90
100
|
maxCrops,
|
|
101
|
+
foldDetailsAt,
|
|
91
102
|
minWidth,
|
|
92
103
|
minHeight,
|
|
93
104
|
includeLayoutNoise,
|
package/dist/report.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export type ReportOptions = {
|
|
|
25
25
|
maxHeight?: number;
|
|
26
26
|
/** Max crop regions per surface before collapsing into one union crop (default 6). */
|
|
27
27
|
maxCrops?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Row count at which a crop's property tables fold under a `<details>` toggle
|
|
30
|
+
* (default 0 = always fold; the essence line and screenshot stay visible). Set
|
|
31
|
+
* to e.g. 5 to keep small changes inline and fold only verbose ones, or
|
|
32
|
+
* `Infinity` to never fold.
|
|
33
|
+
*/
|
|
34
|
+
foldDetailsAt?: number;
|
|
28
35
|
/**
|
|
29
36
|
* Include size/position-derived longhands (height, width, transform-origin…)
|
|
30
37
|
* in the report. Off by default: on a reflow they change up the whole ancestor
|
package/dist/report.js
CHANGED
|
@@ -337,6 +337,18 @@ function groupTitle(findings) {
|
|
|
337
337
|
parts.push(`${n(restyled.size, 'element')} restyled`);
|
|
338
338
|
return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
|
|
339
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* A crop's heading: the element it's anchored on, then what happened inside it —
|
|
342
|
+
* `` `who-grid` · 5 elements restyled ``. Naming the anchor is what ties the
|
|
343
|
+
* table of changes below to the screenshot above it.
|
|
344
|
+
*/
|
|
345
|
+
function regionHeading(regionPaths, findings) {
|
|
346
|
+
const anchors = [...regionPaths].sort((a, b) => a.split(' > ').length - b.split(' > ').length);
|
|
347
|
+
const clsFor = (p) => findings.find((f) => f.path === p)?.cls ?? '';
|
|
348
|
+
const head = prettyLabel(anchors[0] ?? '', clsFor(anchors[0] ?? ''));
|
|
349
|
+
const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
|
|
350
|
+
return `${label} · ${groupTitle(findings)}`;
|
|
351
|
+
}
|
|
340
352
|
// A diff state row whose value is one of these placeholders means "this state
|
|
341
353
|
// has no effect here" — meaningless to show, so render it as an em dash.
|
|
342
354
|
const STATE_PLACEHOLDER = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)']);
|
|
@@ -425,6 +437,59 @@ function renderElements(findings, maxElements = 40) {
|
|
|
425
437
|
}
|
|
426
438
|
return out;
|
|
427
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* A scannable one-liner of what a crop changed, shown ABOVE the folded tables so a
|
|
442
|
+
* reviewer can judge without expanding: its top property deltas with values, the
|
|
443
|
+
* rest as a count, and a flag when the change reaches into hover/focus/active — the
|
|
444
|
+
* one kind of change a static before|after screenshot can't show.
|
|
445
|
+
*/
|
|
446
|
+
function changeEssence(findings) {
|
|
447
|
+
const verbs = [];
|
|
448
|
+
for (const c of ['added', 'removed', 'retagged']) {
|
|
449
|
+
const k = findings.filter((f) => f.kind === 'dom' && f.change === c).length;
|
|
450
|
+
if (k)
|
|
451
|
+
verbs.push(`${k} ${c}`);
|
|
452
|
+
}
|
|
453
|
+
const rows = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props)));
|
|
454
|
+
const top = rows.slice(0, 3).map((r) => `\`${r.prop}\` ${cell(r.before)} → ${cell(r.after)}`);
|
|
455
|
+
const more = rows.length > top.length ? `+${rows.length - top.length} more` : '';
|
|
456
|
+
const line = [...verbs, ...top, more].filter(Boolean).join(' · ') || '_see changes_';
|
|
457
|
+
return findings.some((f) => f.kind === 'state') ? `${line} _· incl. hover/focus/active_` : line;
|
|
458
|
+
}
|
|
459
|
+
/** Plain-text `<summary>` affordance — GitHub renders markdown inside `<summary>`
|
|
460
|
+
* literally, so no backticks or bold here. */
|
|
461
|
+
function foldSummary(findings) {
|
|
462
|
+
const n = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props))).length;
|
|
463
|
+
if (!n)
|
|
464
|
+
return 'Show details';
|
|
465
|
+
return n === 1 ? 'Show the property change' : `Show all ${n} property changes`;
|
|
466
|
+
}
|
|
467
|
+
/** Render a crop's changes: the essence line, then the property tables — folded
|
|
468
|
+
* under a toggle once they would be a wall (the screenshot and approval checkbox
|
|
469
|
+
* above always stay visible). Blank lines around the table block are mandatory or
|
|
470
|
+
* GitHub prints the tables as literal text. `foldAt` is the row count at which the
|
|
471
|
+
* tables collapse; ≤ 0 folds always, Infinity never. */
|
|
472
|
+
function renderCropChanges(findings, foldAt) {
|
|
473
|
+
const tables = renderElements(findings);
|
|
474
|
+
if (!tables.length)
|
|
475
|
+
return [];
|
|
476
|
+
const rows = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props))).length;
|
|
477
|
+
// Small enough to read at a glance: the tables speak for themselves, no essence
|
|
478
|
+
// line (it would just echo a one- or two-row table).
|
|
479
|
+
if (rows < foldAt)
|
|
480
|
+
return tables;
|
|
481
|
+
// Folded: the essence line is the visible stand-in for what the toggle hides.
|
|
482
|
+
return [
|
|
483
|
+
'',
|
|
484
|
+
changeEssence(findings),
|
|
485
|
+
'',
|
|
486
|
+
'<details>',
|
|
487
|
+
`<summary>${foldSummary(findings)}</summary>`,
|
|
488
|
+
...tables,
|
|
489
|
+
'',
|
|
490
|
+
'</details>',
|
|
491
|
+
];
|
|
492
|
+
}
|
|
428
493
|
// Computed values that follow from an element's box size or position rather than
|
|
429
494
|
// its styling. On any reflow they change all the way up the ancestor chain
|
|
430
495
|
// (body, main, section…), so an element whose ONLY changes are these is a reflow
|
|
@@ -455,7 +520,7 @@ const DERIVED_PROPS = new Set([
|
|
|
455
520
|
const hasRealChange = (f) => f.kind === 'dom' || f.props.some((p) => !DERIVED_PROPS.has(p.prop));
|
|
456
521
|
const stripDerived = (f) => f.kind === 'dom' ? f : { ...f, props: f.props.filter((p) => !DERIVED_PROPS.has(p.prop)) };
|
|
457
522
|
export function generateStyleMapReport(opts) {
|
|
458
|
-
const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, } = opts;
|
|
523
|
+
const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, foldDetailsAt = 0, } = opts;
|
|
459
524
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
460
525
|
const { surfaces } = diffStyleMapDirs(beforeDir, afterDir);
|
|
461
526
|
fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
|
|
@@ -513,13 +578,10 @@ export function generateStyleMapReport(opts) {
|
|
|
513
578
|
`across ${changeGroups.length} distinct change(s) in ${surfaceCount} surface(s).`);
|
|
514
579
|
}
|
|
515
580
|
let totalFindings = 0;
|
|
581
|
+
let cropSeq = 0;
|
|
516
582
|
for (const cg of changeGroups) {
|
|
517
583
|
const { sd, findings: surfaceFindings } = cg.rep;
|
|
518
584
|
totalFindings += surfaceFindings.length;
|
|
519
|
-
md.push('', `### ${groupTitle(surfaceFindings)}`);
|
|
520
|
-
md.push('', cg.surfaces.length > 1
|
|
521
|
-
? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceList(cg.surfaces)}_`
|
|
522
|
-
: `_${formatSurfaceList(cg.surfaces)}_`);
|
|
523
585
|
const mapA = loadStyleMap(findCapture(beforeDir, sd.surface));
|
|
524
586
|
const mapB = loadStyleMap(findCapture(afterDir, sd.surface));
|
|
525
587
|
const pngA = readPng(path.join(beforeDir, `${sd.surface}.png`));
|
|
@@ -535,23 +597,33 @@ export function generateStyleMapReport(opts) {
|
|
|
535
597
|
})),
|
|
536
598
|
];
|
|
537
599
|
}
|
|
600
|
+
// Read top-to-bottom: one section per crop, in page order.
|
|
601
|
+
const topY = (g) => (visible(g.after) ? g.after.y : visible(g.before) ? g.before.y : Infinity);
|
|
602
|
+
groups.sort((a, b) => topY(a) - topY(b));
|
|
603
|
+
const surfaceList = cg.surfaces.length > 1
|
|
604
|
+
? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceList(cg.surfaces)}_`
|
|
605
|
+
: `_${formatSurfaceList(cg.surfaces)}_`;
|
|
538
606
|
const surfaceJson = {
|
|
539
607
|
surfaces: cg.surfaces,
|
|
540
608
|
representative: sd.surface,
|
|
541
609
|
regions: [],
|
|
542
610
|
};
|
|
543
|
-
let n = 0;
|
|
544
611
|
for (const g of groups) {
|
|
545
|
-
|
|
612
|
+
cropSeq++;
|
|
613
|
+
// Exactly the findings whose element lives inside THIS crop, so the tables
|
|
614
|
+
// sit directly under the screenshot that shows them — never a wall of
|
|
615
|
+
// changes spanning several crops with no way to tell which is which.
|
|
616
|
+
const regionFindings = surfaceFindings.filter((f) => g.paths.some((root) => f.path === root || f.path.startsWith(root + ' > ')));
|
|
617
|
+
md.push('', `### ${regionHeading(g.paths, regionFindings)}`, '', surfaceList);
|
|
546
618
|
const region = visible(g.after) ? g.after : g.before;
|
|
547
619
|
let images = {};
|
|
548
620
|
if (region && pngA && pngB) {
|
|
549
621
|
// Same crop dimensions on both sides so the pair reads as a pair.
|
|
550
622
|
const w = Math.max(minWidth, visible(g.before) ? g.before.w : 0, visible(g.after) ? g.after.w : 0);
|
|
551
623
|
const h = Math.min(maxHeight, Math.max(minHeight, visible(g.before) ? g.before.h : 0, visible(g.after) ? g.after.h : 0));
|
|
552
|
-
// Path-safe stem:
|
|
553
|
-
//
|
|
554
|
-
const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${
|
|
624
|
+
// Path-safe, report-unique stem: `hero@1280` → `hero-1280-3` so relative
|
|
625
|
+
// image links resolve cleanly and two crops never collide on one filename.
|
|
626
|
+
const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
|
|
555
627
|
const before = cropPng(pngA, visible(g.before) ? g.before : region, w, h);
|
|
556
628
|
const after = cropPng(pngB, visible(g.after) ? g.after : region, w, h);
|
|
557
629
|
const composite = compositePair(before, after);
|
|
@@ -559,7 +631,7 @@ export function generateStyleMapReport(opts) {
|
|
|
559
631
|
writePng(path.join(outDir, `${stem}-after.png`), after);
|
|
560
632
|
writePng(path.join(outDir, `${stem}-composite.png`), composite);
|
|
561
633
|
images = { before: `${stem}-before.png`, after: `${stem}-after.png`, composite: `${stem}-composite.png` };
|
|
562
|
-
// One side-by-side image per
|
|
634
|
+
// One side-by-side image per crop: clean inline render, single upload.
|
|
563
635
|
md.push('', `})`, '', `<sub>◀ before · after ▶ — ${sd.surface}</sub>`);
|
|
564
636
|
}
|
|
565
637
|
else if (!region) {
|
|
@@ -568,10 +640,11 @@ export function generateStyleMapReport(opts) {
|
|
|
568
640
|
else {
|
|
569
641
|
md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
|
|
570
642
|
}
|
|
643
|
+
// What this crop changed: a scannable essence line, then the property
|
|
644
|
+
// tables — folded under a toggle once they'd be a wall (foldDetailsAt).
|
|
645
|
+
md.push(...renderCropChanges(regionFindings, foldDetailsAt));
|
|
571
646
|
surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
|
|
572
647
|
}
|
|
573
|
-
// The findings, grouped per element, rendered once for the whole group.
|
|
574
|
-
md.push(...renderElements(surfaceFindings));
|
|
575
648
|
surfaceJson.findings = surfaceFindings;
|
|
576
649
|
json.push(surfaceJson);
|
|
577
650
|
}
|
|
@@ -579,9 +652,6 @@ export function generateStyleMapReport(opts) {
|
|
|
579
652
|
md.push('', `### \`${p.sd.surface}\``, '', `⚠️ captured only in the **${p.sd.missing === 'before' ? 'after' : 'before'}** set — re-run both captures.`);
|
|
580
653
|
json.push({ surface: p.sd.surface, missing: p.sd.missing });
|
|
581
654
|
}
|
|
582
|
-
if (surfaces.length) {
|
|
583
|
-
md.push('', '---', '_To accept these changes, regenerate the committed baseline from this build and commit it with your diff._');
|
|
584
|
-
}
|
|
585
655
|
const reportMdPath = path.join(outDir, 'report.md');
|
|
586
656
|
const reportJsonPath = path.join(outDir, 'report.json');
|
|
587
657
|
fs.writeFileSync(reportMdPath, md.join('\n') + '\n');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Certify CSS refactors with the browser's computed styles: capture every resolved longhand, pseudo-element, and forced hover/focus/active state, then diff before against after.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|