styleproof 3.20.0 → 4.0.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 +192 -0
- package/README.md +437 -321
- package/bin/styleproof-capture.mjs +4 -0
- package/bin/styleproof-diff.mjs +192 -27
- package/bin/styleproof-init.mjs +75 -7
- package/bin/styleproof-map.mjs +23 -9
- package/bin/styleproof-report.mjs +6 -0
- package/dist/capture-url.js +5 -0
- package/dist/capture.d.ts +59 -0
- package/dist/capture.js +127 -5
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/coverage.d.ts +7 -0
- package/dist/data-residue.d.ts +70 -0
- package/dist/data-residue.js +105 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/map-store.d.ts +37 -1
- package/dist/map-store.js +103 -10
- package/dist/report.d.ts +1 -4
- package/dist/report.js +100 -353
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +63 -9
- package/example/styleproof-approve.yml +108 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -12,23 +12,20 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
|
|
|
12
12
|
## Contents
|
|
13
13
|
|
|
14
14
|
- [Why](#why)
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
17
|
-
- [What you own](#what-you-own)
|
|
15
|
+
- [How it works](#how-it-works)
|
|
16
|
+
- [Quickstart](#quickstart)
|
|
18
17
|
- [What the PR gets](#what-the-pr-gets)
|
|
19
|
-
- [
|
|
20
|
-
- [What a
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
18
|
+
- [Two modes: review or certify](#two-modes-review-or-certify)
|
|
19
|
+
- [What a green certifies](#what-a-green-certifies)
|
|
20
|
+
- [Coverage: what you own, what's discovered](#coverage-what-you-own-whats-discovered)
|
|
21
|
+
- [Declaring surfaces](#declaring-surfaces)
|
|
22
|
+
- [Deterministic by default](#deterministic-by-default)
|
|
23
|
+
- [Any styling system, real breakpoints](#any-styling-system-real-breakpoints)
|
|
24
24
|
- [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel)
|
|
25
|
-
- [Install](#install)
|
|
26
|
-
- [Quickstart](#quickstart)
|
|
27
25
|
- [Forks and Dependabot](#forks-and-dependabot)
|
|
28
26
|
- [Optional: content layer](#optional-content-layer-advisory)
|
|
29
27
|
- [Optional: React component layer](#optional-react-component-layer-advisory)
|
|
30
28
|
- [Optional: selective remap](#optional-selective-remap-advisory)
|
|
31
|
-
- [Newly-added elements show their full style](#newly-added-elements-show-their-full-style)
|
|
32
29
|
- [Reference](#reference)
|
|
33
30
|
- [Blocking without branch protection](#blocking-without-branch-protection)
|
|
34
31
|
- [Contributing](#contributing)
|
|
@@ -41,42 +38,21 @@ layout, or hidden/open UI states and you want CI to say whether the browser's
|
|
|
41
38
|
rendered styles actually changed. Unit and e2e tests prove behavior; StyleProof
|
|
42
39
|
proves the visual contract for the states you declared.
|
|
43
40
|
|
|
44
|
-
It
|
|
45
|
-
|
|
46
|
-
- certifying a "no visual change" refactor such as CSS Modules to Tailwind;
|
|
47
|
-
- reviewing intentional visual changes with exact before/after style evidence;
|
|
48
|
-
- catching accidental changes that only appear at one breakpoint;
|
|
49
|
-
- catching open-state regressions in dialogs, dropdowns, listboxes, popovers, and
|
|
50
|
-
toasts;
|
|
51
|
-
- failing CI when a required route, component, or UI state exists but has no
|
|
52
|
-
capture.
|
|
41
|
+
It catches:
|
|
53
42
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
in both at once — captured and expected together, with nothing to keep in sync.
|
|
63
|
-
The guard then fails only on genuine divergence between the two: a dynamic route
|
|
64
|
-
(pre-excluded, so its reason surfaces), a hand-maintained registry, or a route
|
|
65
|
-
dropped from `surfaces` while still in `expected`.
|
|
66
|
-
- **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
|
|
67
|
-
reconciles it against the _rendered nav_ (the route universe for such an app),
|
|
68
|
-
both directions — a new linked route with no `expected` entry fails, and an
|
|
69
|
-
`expected` route the nav stopped linking fails. This runs inside the capture, so
|
|
70
|
-
it fires when you capture (unlike the Next guard, which runs in your plain suite).
|
|
71
|
-
- **Other frameworks:** point `expected` at your own route registry.
|
|
72
|
-
- **Modals, dropdowns, toasts:** guarded only for the state keys you enumerate in
|
|
73
|
-
`expected` (e.g. `dashboard-dialog-open`) — nothing discovers UI states for you.
|
|
43
|
+
- a button recoloured by a token, utility class, CSS module, inline style, or
|
|
44
|
+
design-system change;
|
|
45
|
+
- a layout shift at one breakpoint but not another;
|
|
46
|
+
- a dropped `:hover`, `:focus`, or `:active` style;
|
|
47
|
+
- a modal, menu, listbox, popover, sheet, or toast whose open state changed;
|
|
48
|
+
- a supposedly no-op refactor, such as CSS-to-Tailwind, that changed rendered
|
|
49
|
+
output;
|
|
50
|
+
- a required route, component, or UI state that exists but has no capture.
|
|
74
51
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
the baseline once merged.
|
|
52
|
+
The end-to-end catch contract lives in
|
|
53
|
+
[docs/what-it-catches.md](docs/what-it-catches.md).
|
|
78
54
|
|
|
79
|
-
##
|
|
55
|
+
## How it works
|
|
80
56
|
|
|
81
57
|
1. A **surface** is one UI state to certify: a route, tab, modal-open state,
|
|
82
58
|
dropdown-open state, toast-visible state, loading state, etc.
|
|
@@ -94,37 +70,123 @@ can see the change, but the gate compares browser-computed CSS: resolved
|
|
|
94
70
|
longhands, pseudo-elements, layout boxes, motion longhands, and forced
|
|
95
71
|
`:hover`/`:focus`/`:active` deltas.
|
|
96
72
|
|
|
97
|
-
|
|
73
|
+
Maps travel via the SHA-keyed `styleproof-maps` branch (or a CI artifact for
|
|
74
|
+
forks) — **never as files committed to the PR branch**. Committed maps show up
|
|
75
|
+
as changed files in every review, and because every PR writes the same paths,
|
|
76
|
+
each merge forces every other open PR to rebase. `.styleproof/` and
|
|
77
|
+
`stylemaps/` are gitignored to keep that door shut.
|
|
78
|
+
|
|
79
|
+
## Quickstart
|
|
80
|
+
|
|
81
|
+
### 0. Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install -D styleproof @playwright/test
|
|
85
|
+
npx playwright install chromium
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Requires **Node ≥ 18** (ESM), **`@playwright/test` ≥ 1.40** (peer dep). Forced states are Chromium-only.
|
|
98
89
|
|
|
99
|
-
|
|
100
|
-
design-system change.
|
|
101
|
-
- A layout shift at one breakpoint but not another.
|
|
102
|
-
- A dropped `:hover`, `:focus`, or `:active` style.
|
|
103
|
-
- A modal, menu, listbox, popover, sheet, or toast whose open state changed.
|
|
104
|
-
- A supposedly no-op refactor, such as CSS-to-Tailwind, that changed rendered
|
|
105
|
-
output.
|
|
90
|
+
### 1. Scaffold the gate
|
|
106
91
|
|
|
107
|
-
|
|
92
|
+
```bash
|
|
93
|
+
npx styleproof-init
|
|
94
|
+
```
|
|
108
95
|
|
|
109
|
-
|
|
110
|
-
states that matter:
|
|
96
|
+
`styleproof-init` detects your app and wires **surface discovery** for you — there is nothing to hand-list:
|
|
111
97
|
|
|
112
|
-
- routes and
|
|
113
|
-
-
|
|
114
|
-
- loading/loaded/empty/error states belong in `liveStates`;
|
|
115
|
-
- component catalogs can be wired through `discoverComponentFiles`;
|
|
116
|
-
- required-but-not-yet-captured states belong in `expected`, where the coverage
|
|
117
|
-
guard fails until they are captured or explicitly excluded with a reason.
|
|
98
|
+
- **Next.js** — it discovers your routes (`app/` + `pages/`) at run time and derives _both_ the captured surfaces and the coverage guard from them, so a route you add later is captured automatically, never a guard failure.
|
|
99
|
+
- **Any other app** — it scaffolds a **nav crawl**: StyleProof loads `/`, reads the rendered `<a href>` links, and captures every same-origin surface they point to. The surface set _is_ the nav, so it can't drift from it.
|
|
118
100
|
|
|
119
|
-
|
|
120
|
-
Next.js routes, crawl real nav links, inventory component files, harvest
|
|
121
|
-
one-step variants, click safe popups, detect breakpoints, and flag live or
|
|
122
|
-
volatile regions. You review the app-specific leftovers; StyleProof keeps the
|
|
123
|
-
mechanical inventory from drifting.
|
|
101
|
+
Either way the generated spec runs as-is. It also wires everything around it so the gate behaves the same locally and in CI:
|
|
124
102
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
103
|
+
- a dedicated **`playwright.styleproof.config.ts`** that builds and serves a **production build** (never a flaky dev server), scopes discovery to the StyleProof spec, and captures surfaces **in parallel** (`fullyParallel`) without disturbing your app's existing Playwright config;
|
|
104
|
+
- widths you never set — **omit `widths`** and StyleProof sweeps your app's real `@media` breakpoints automatically;
|
|
105
|
+
- determinism you never set up — network settle, frozen clock, animation freeze, and framework-noise filtering are all on by default (see [Deterministic by default](#deterministic-by-default));
|
|
106
|
+
- `.gitignore` entries for `.styleproof/`, `test-results/`, and `playwright-report/`;
|
|
107
|
+
- a **cache-first CI workflow** that restores reusable maps from the `styleproof-maps` branch and generates the report without a browser when both maps are already built;
|
|
108
|
+
- a **pre-push hook** (`.husky/` if present, else `.githooks/`) that captures each pushed commit and publishes the bundle to the `styleproof-maps` branch — CI's hot path stays report-only, and maps never get committed to the PR branch;
|
|
109
|
+
- the **approval workflow** (`styleproof-approve.yml`) that turns the `StyleProof` status green when a reviewer ticks **Approve all changes** — so the review gate is complete, not half-wired (it activates once the init PR merges, since GitHub runs `issue_comment` workflows only from your default branch).
|
|
110
|
+
|
|
111
|
+
### 2. Capture, then diff
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx styleproof-map # capture this commit's computed styles
|
|
115
|
+
npx styleproof-diff # compare against the base branch
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`styleproof-map` captures the current commit into `.styleproof/maps/current`,
|
|
119
|
+
writes a manifest, and uploads the bundle
|
|
120
|
+
to the dedicated `styleproof-maps` branch when the working tree was clean and a
|
|
121
|
+
git remote is available. Nothing under `.styleproof/` belongs in the PR branch.
|
|
122
|
+
HAR recordings are removed before upload by default so private API responses do
|
|
123
|
+
not land in the map store. Keep them locally only for an explicit record/replay
|
|
124
|
+
workflow with `styleproof-map --keep-har` (or `STYLEPROOF_KEEP_HAR=1`).
|
|
125
|
+
|
|
126
|
+
`styleproof-diff` restores the base and head maps from `styleproof-maps`
|
|
127
|
+
automatically: in GitHub Actions it uses the PR base/head SHAs; locally it checks
|
|
128
|
+
`branch.<name>.gh-merge-base`, then the current GitHub PR base via `gh pr view`
|
|
129
|
+
(handy for stacked PRs), then `origin/main`, `origin/master`, `main`, and
|
|
130
|
+
`master`. Pin the base with `styleproof-diff main` or `styleproof-diff master`.
|
|
131
|
+
|
|
132
|
+
**That's the whole loop.** The map is built outside CI by default: the
|
|
133
|
+
pre-push hook `styleproof-init` installs runs `styleproof-map` on every push
|
|
134
|
+
that can affect render (skip one with `STYLEPROOF_SKIP_CAPTURE=1 git push`).
|
|
135
|
+
On the PR, CI first restores the base/head bundles and only generates the
|
|
136
|
+
report — no build, no browser. If either bundle is missing or incompatible,
|
|
137
|
+
CI recaptures both sides in the same pinned environment before reporting.
|
|
138
|
+
Correctness wins over a stale cache, but the hot path is report-only.
|
|
139
|
+
|
|
140
|
+
> **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the **real browser build** (`browser().version()`) in its manifest — the npm `@playwright/test` version is only a proxy, and the actual Chromium binary can change while it holds constant (a `playwright install` re-download, a different `PLAYWRIGHT_BROWSERS_PATH`, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. This guard needs a `styleproof-manifest.json` on **both** sides. Since **v4** a two-directory `styleproof-diff`/`styleproof-report` where a side ships maps but no manifest **refuses to compare**: it exits `2` (usage/capture error) naming the bare side(s), because the environment can't be verified and captures from different browser builds or platforms would diff as false changes. Re-capture with current StyleProof — `styleproof-map`, or `styleproof-capture` for a one-shot design diff (both write a manifest); maps without one are unsupported. (A dir with _no maps at all_ is "no baseline yet", not a bare bundle — that stays the first-adoption review path, exit `3`.) **Installed fonts are your responsibility:** they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.
|
|
141
|
+
|
|
142
|
+
**Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
|
|
143
|
+
styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
|
|
144
|
+
the same cached-map defaults as `styleproof-diff`. Pin the base with
|
|
145
|
+
`styleproof-report main` or keep the manual form with `styleproof-report before
|
|
146
|
+
after --out report`.
|
|
147
|
+
|
|
148
|
+
### 3. Wire it by hand instead (optional)
|
|
149
|
+
|
|
150
|
+
`styleproof-init` scaffolds **both** the report workflow _and_ the
|
|
151
|
+
`styleproof-approve.yml` handler that flips the `StyleProof` status when a
|
|
152
|
+
reviewer ticks the box. GitHub only runs `issue_comment` workflows from the
|
|
153
|
+
default branch, so the checkbox goes live the moment you merge the init PR — no
|
|
154
|
+
manual copy. If you wire it by hand instead, restore or capture two dirs first,
|
|
155
|
+
then use the Action on those dirs:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
# .github/workflows/styleproof.yml
|
|
159
|
+
- uses: actions/checkout@v4
|
|
160
|
+
- run: npx styleproof-map --restore --sha "${{ github.event.pull_request.base.sha }}" --dir base --base-dir __stylemaps__
|
|
161
|
+
- run: npx styleproof-map --restore --sha "${{ github.event.pull_request.head.sha }}" --dir head --base-dir __stylemaps__
|
|
162
|
+
- uses: BenSheridanEdwards/StyleProof@v4
|
|
163
|
+
with:
|
|
164
|
+
baseline-dir: __stylemaps__/base
|
|
165
|
+
fresh-dir: __stylemaps__/head
|
|
166
|
+
require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Only for this hand-wired path: 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 approval checkbox is inert until it's merged). `styleproof-init` writes this file for you, so you can skip this step if you used it.
|
|
170
|
+
|
|
171
|
+
**Prefer to always capture in CI?** For a repo with many outside contributors on different machines, StyleProof can capture **both** base and head in CI and diff them there. See **[Forks and Dependabot](#forks-and-dependabot)** for that flow (it's also the fork-safe split). The default cache-first flow is faster for same-repo teams because the pre-push hook builds the head map before CI starts.
|
|
172
|
+
|
|
173
|
+
**Want to skip work safely?** Skip the **whole** StyleProof workflow only for
|
|
174
|
+
changes that cannot affect rendered output, such as docs-only edits, using your
|
|
175
|
+
CI provider's native path filters. Do not skip individual surfaces from a
|
|
176
|
+
StyleProof run based on a changed-file guess: shared CSS, tokens, resets,
|
|
177
|
+
themes, layout primitives, and runtime styling can repaint any surface, and a
|
|
178
|
+
missed surface would certify green without being measured. If you want faster
|
|
179
|
+
feedback, order the highest-signal surfaces first in your spec, but still let
|
|
180
|
+
the full sweep finish before treating the gate as passed.
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
on:
|
|
184
|
+
pull_request:
|
|
185
|
+
paths-ignore:
|
|
186
|
+
- '**/*.md'
|
|
187
|
+
- 'docs/**'
|
|
188
|
+
- '.github/ISSUE_TEMPLATE/**'
|
|
189
|
+
```
|
|
128
190
|
|
|
129
191
|
## What the PR gets
|
|
130
192
|
|
|
@@ -140,23 +202,112 @@ full report. The report groups each distinct visual change with:
|
|
|
140
202
|
In review-gate mode, one **Approve all changes** checkbox turns the `StyleProof`
|
|
141
203
|
status green for that commit. Clean runs still leave a receipt: `No visual
|
|
142
204
|
changes detected.` New surfaces are shown as new baselines and require approval;
|
|
143
|
-
coverage gaps are handled by `expected`.
|
|
205
|
+
coverage gaps are handled by `expected`. When a PR **adds** an element, the
|
|
206
|
+
report shows its **full resting computed style** (background, padding, font,
|
|
207
|
+
radius, …), value-only, in addition to any interaction-state deltas — so a new
|
|
208
|
+
element's whole look is reviewable, not just its `:hover` changes.
|
|
209
|
+
|
|
210
|
+
### What a report looks like
|
|
211
|
+
|
|
212
|
+
One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, the same crop again with magenta boxes marking exactly what changed, a one-line summary, then the exact property change folded under a toggle. A change too small to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a sub-pixel change can't slip past a reviewer.
|
|
213
|
+
|
|
214
|
+

|
|
215
|
+
|
|
216
|
+
📄 **[See a full live report](docs/demo/report.md)** — rendered by the current code with real images (clean before/after, the highlighted twin, a magnified zoom for a sub-pixel change, and a `🆕 new surface`). It's regenerated and verified on every PR (`npm run demo:report`), so it always reflects exactly what StyleProof produces today.
|
|
217
|
+
|
|
218
|
+
As it renders in the committed report (a plain-English bullet first — naming the theme token and showing the hex with a live colour swatch — then the exact table inside the toggle). The PR comment itself stays lean — a summary plus the approval box — and links here:
|
|
219
|
+
|
|
220
|
+
```text
|
|
221
|
+
### `a.btn-solid` · 1 element restyled
|
|
222
|
+
_landing @ 1280_
|
|
223
|
+
|
|
224
|
+
- **`a.btn-solid`** — background `brand-cyan` (`#5fcadb`) → `brand-amber` (`#f59e0b`)
|
|
225
|
+
|
|
226
|
+
▾ Show the property change
|
|
227
|
+
| Property | Before | After |
|
|
228
|
+
| background-color | #5fcadb | #f59e0b |
|
|
229
|
+
```
|
|
144
230
|
|
|
145
|
-
##
|
|
231
|
+
## Two modes: review or certify
|
|
232
|
+
|
|
233
|
+
**Review-gate mode** (`require-approval: true`) is for normal feature work:
|
|
234
|
+
every visual change is reported with evidence, the `StyleProof` status stays red
|
|
235
|
+
until a reviewer ticks **Approve all changes**, and approved changes become the
|
|
236
|
+
new baseline on merge. A surface that exists only on the PR head is still
|
|
237
|
+
reviewable: it holds the status red until approved, then becomes part of the
|
|
238
|
+
baseline once merged.
|
|
239
|
+
|
|
240
|
+
**Certify mode** (`fail-on-diff: true`) proves a change touched _nothing_
|
|
241
|
+
visual: any difference at all fails the job. It's the job StyleProof was born
|
|
242
|
+
for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical.
|
|
243
|
+
Reach for it on any change whose whole promise is "the output is unchanged": a
|
|
244
|
+
utility-class migration, a design-system swap, a dependency or build-tooling
|
|
245
|
+
bump. Zero diff is the contract; one drifting longhand is a regression to
|
|
246
|
+
investigate, not a change to approve.
|
|
247
|
+
|
|
248
|
+
There's also a third, spec-less use — pointing the one-shot capture at a design
|
|
249
|
+
mockup and diffing your build against it until the number hits zero. See
|
|
250
|
+
[Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel).
|
|
251
|
+
|
|
252
|
+
## What a green certifies
|
|
253
|
+
|
|
254
|
+
A passing check is more than "no style changed". Four gates qualify it, and the
|
|
255
|
+
report leads with their verdicts:
|
|
256
|
+
|
|
257
|
+
- **Coverage** — the `expected` registry travels with the captured bundle as a
|
|
258
|
+
ledger, so the gate states its completeness basis: `✓ coverage complete`,
|
|
259
|
+
`✗ coverage INCOMPLETE` (blocks — a registered surface wasn't captured, even
|
|
260
|
+
on an empty diff), or `⚠ completeness NOT asserted` (no registry declared).
|
|
261
|
+
- **Determinism** — the ledger records how each capture proved itself
|
|
262
|
+
(`self-checked` / `replayed`); a green from an `unproven` capture blocks,
|
|
263
|
+
because a clean diff of two nondeterministic reads could just be luck.
|
|
264
|
+
- **Inventory** — with `inventory: true` (on in `styleproof-init` scaffolds),
|
|
265
|
+
each capture harvests the surface's navigable affordances (links, tabs, menu
|
|
266
|
+
items, keyed by stable identity, not label text). A removal that makes a
|
|
267
|
+
feature unreachable **gates** — in the `styleproof-diff` CLI and in the
|
|
268
|
+
Action, both modes — until acknowledged in `styleproof.inventory.json`
|
|
269
|
+
(`{"<key>": "<why>"}`); a stale acknowledgement is flagged so the ledger
|
|
270
|
+
can't rot. Details and the keying rules:
|
|
271
|
+
[docs/inventory-guard.md](docs/inventory-guard.md). Make it advisory in the
|
|
272
|
+
Action with `"gateInventoryRemovals": false` in `styleproof.config.json`.
|
|
273
|
+
- **Data residue** — a data-boundary request that **failed** during capture
|
|
274
|
+
means the fallback branch was captured, not the state its responses drive.
|
|
275
|
+
Gating is the default (`dataResidue: 'gate'`): an unacknowledged failing
|
|
276
|
+
endpoint blocks until acknowledged in `styleproof.data-residue.json`, and a
|
|
277
|
+
stale acknowledgement also fails. Opt down with `dataResidue: 'warn'`. See
|
|
278
|
+
[Data residue](#data-residue-a-failed-data-request-is-named-not-swallowed).
|
|
279
|
+
|
|
280
|
+
## Coverage: what you own, what's discovered
|
|
281
|
+
|
|
282
|
+
The important boundary: **StyleProof only certifies states it can reach.** It
|
|
283
|
+
diffs the surfaces your spec lists or discovers — so a page nobody added to
|
|
284
|
+
either set is invisible to the gate. Its change has no base capture _and_ no
|
|
285
|
+
head capture, so it never appears in any diff, and the status goes green having
|
|
286
|
+
never looked at it. This is the one thing the captures can't catch on their
|
|
287
|
+
own: a capture that was never taken.
|
|
288
|
+
|
|
289
|
+
Auto-discovery keeps the boring inventory out of your hands where it can be
|
|
290
|
+
inferred safely: Next.js routes, crawlable links, component files, semantic
|
|
291
|
+
popups, one-step variants, breakpoints, and volatile/live candidates. You own
|
|
292
|
+
the app-specific list of states that matter:
|
|
146
293
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
294
|
+
- routes and views belong in `surfaces`;
|
|
295
|
+
- open states belong in `variants` or `popups`;
|
|
296
|
+
- loading/loaded/empty/error states belong in `liveStates`;
|
|
297
|
+
- component catalogs can be wired through `discoverComponentFiles`;
|
|
298
|
+
- required-but-not-yet-captured states belong in `expected`, where the coverage
|
|
299
|
+
guard fails until they are captured or explicitly excluded with a reason.
|
|
152
300
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
green having never looked at it. This is the one thing the captures can't catch
|
|
157
|
-
on their own: a capture that was never taken.
|
|
301
|
+
That boundary is deliberate. StyleProof should not guess destructive flows,
|
|
302
|
+
auth-only fixtures, or which product state your component needs. It should make
|
|
303
|
+
missing coverage loud.
|
|
158
304
|
|
|
159
|
-
|
|
305
|
+
`expected` is what makes it loud. Declare your app's route/view universe in
|
|
306
|
+
`expected` and StyleProof emits a coverage-guard test in your **normal** suite
|
|
307
|
+
(it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It
|
|
308
|
+
fails when `expected` and your captured surfaces diverge — a route you listed in
|
|
309
|
+
`expected` with no surface and no `exclude` entry fails as missing coverage, so
|
|
310
|
+
a registry entry can't quietly ship uncaptured:
|
|
160
311
|
|
|
161
312
|
```ts
|
|
162
313
|
import { defineStyleMapCapture } from 'styleproof';
|
|
@@ -172,7 +323,65 @@ defineStyleMapCapture({
|
|
|
172
323
|
|
|
173
324
|
A route that's neither a captured surface nor an `exclude` entry fails the guard; an `exclude` key that isn't in `expected` (a renamed/removed route) fails too, so the opt-out ledger can't quietly rot. Captured surfaces beyond `expected` are fine — one route can have several states (`landing`, `landing-nav-open`). Omit `expected` and behaviour is unchanged.
|
|
174
325
|
|
|
175
|
-
|
|
326
|
+
What's guarded depends on how `expected` is fed —
|
|
327
|
+
|
|
328
|
+
- **Next.js:** auto-covered. `styleproof-init` derives both `surfaces` and
|
|
329
|
+
`expected` from the same `discoverNextRoutes()` call, so a new static route lands
|
|
330
|
+
in both at once — captured and expected together, with nothing to keep in sync.
|
|
331
|
+
- **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
|
|
332
|
+
reconciles it against the _rendered nav_ (the route universe for such an app),
|
|
333
|
+
both directions — a new linked route with no `expected` entry fails, and an
|
|
334
|
+
`expected` route the nav stopped linking fails. This runs inside the capture, so
|
|
335
|
+
it fires when you capture (unlike the Next guard, which runs in your plain suite).
|
|
336
|
+
- **Other frameworks:** point `expected` at your own route registry.
|
|
337
|
+
- **Modals, dropdowns, toasts:** guarded only for the state keys you enumerate in
|
|
338
|
+
`expected` (e.g. `dashboard-dialog-open`) — nothing discovers UI states for you.
|
|
339
|
+
|
|
340
|
+
### The un-exercised-state gap: an honest green gate can still miss a real restyle
|
|
341
|
+
|
|
342
|
+
The sharpest form of the boundary, observed end-to-end on a real consumer: a PR
|
|
343
|
+
restyled a view's **conditional render branch** (a fault overlay repainting
|
|
344
|
+
indicators green→amber when a probe reports a fault) and shipped through a
|
|
345
|
+
fully-wired gate with every layer passing _honestly_. The capture spec served no
|
|
346
|
+
fixture that put the view into the fault state, so the changed branch never
|
|
347
|
+
rendered on either side — an honest recapture produced **byte-identical maps**,
|
|
348
|
+
the diff was trivially clean, and the green gate certified a restyle it never
|
|
349
|
+
saw. No component lied. The gap is structural: **maps prove only the states the
|
|
350
|
+
spec exercises.** A restyle confined to an un-exercised conditional state
|
|
351
|
+
(fault / error / empty / permission branches behind data) is invisible to any
|
|
352
|
+
amount of honest recapturing, and the coverage guard cannot substitute — it
|
|
353
|
+
checks that declared keys have captures, not that your branches have keys.
|
|
354
|
+
|
|
355
|
+
So the rule is: **every conditional render branch whose styling matters needs a
|
|
356
|
+
surface that exercises it.** The recommended wiring is two-part:
|
|
357
|
+
|
|
358
|
+
1. a **dedicated capture surface** (a `liveState` or variant) driving the
|
|
359
|
+
branch via a **per-surface fixture override** — `page.route` in that
|
|
360
|
+
surface's `setup`, per-surface rather than global, so other surfaces reading
|
|
361
|
+
the same endpoint keep their own state (exactly the shape of the
|
|
362
|
+
[`liveStates` example](#live-ui-states-capture-each-state-not-an-average));
|
|
363
|
+
2. a **browserless guard test** tied to the branch's source, failing loudly if
|
|
364
|
+
the surface, fixture, or state assertion is removed while the conditional
|
|
365
|
+
branch still exists — so the coverage can't silently rot.
|
|
366
|
+
|
|
367
|
+
This pairs with the [data-residue guard](#data-residue-a-failed-data-request-is-named-not-swallowed),
|
|
368
|
+
which names the _failing_ half of the same blind spot: a data request that
|
|
369
|
+
errors during capture is flagged as residue. An endpoint that **succeeds** with
|
|
370
|
+
healthy data — so the fault branch simply never renders — is this gap, and only
|
|
371
|
+
a fixture-driven surface closes it.
|
|
372
|
+
|
|
373
|
+
## Declaring surfaces
|
|
374
|
+
|
|
375
|
+
Discovery captures every route your app links to. It deliberately **won't
|
|
376
|
+
guess** app-specific states — a modal's open state, an auth-gated view, a
|
|
377
|
+
destructive flow, a loading/error render — because guessing one wrong is worse
|
|
378
|
+
than flagging it missing. Those are the only things you list by hand, and you
|
|
379
|
+
add them to the spec `styleproof-init` already generated. This section covers
|
|
380
|
+
each kind.
|
|
381
|
+
|
|
382
|
+
### Next.js routes: wired for you
|
|
383
|
+
|
|
384
|
+
Run `styleproof-init` in a Next.js project and the generated spec discovers your routes (App Router `app/` + Pages Router `pages/`) at run time and derives **both** the surfaces and `expected` from that same `discoverNextRoutes()` call. Because they share one source, a static route you add later is captured and expected in the same step — auto-covered, never a guard failure, with nothing to keep in sync. The guard exists for the cases where the two genuinely diverge: a dynamic `[param]` route (it can't be navigated without a value, so it's placed in `exclude` with a reason rather than captured), a registry you hand-maintain instead of the live call, or a route you drop from `surfaces` while it's still `expected`:
|
|
176
385
|
|
|
177
386
|
```ts
|
|
178
387
|
import { defineStyleMapCapture, discoverNextRoutes } from 'styleproof';
|
|
@@ -197,7 +406,9 @@ defineStyleMapCapture({
|
|
|
197
406
|
|
|
198
407
|
`discoverNextRoutes(cwd?)` reads the filesystem only (route groups `(group)` and `@slots` stripped, `[param]`/`[...catchall]` flagged `dynamic`) — a heuristic, not a router; edit the generated spec for exotic routing. For any other framework, point `expected` at your own route registry as above.
|
|
199
408
|
|
|
200
|
-
|
|
409
|
+
### Single-route SPAs: crawl the nav
|
|
410
|
+
|
|
411
|
+
Filesystem discovery can't see a surface that isn't a page — a tab SPA where every view is `/?tab=overview` on one `app/page.tsx`, or anything client-routed. There the surfaces exist only in the rendered nav, as its links. `defineCrawlCapture` discovers them at run time: it loads a root URL, reads its same-origin `<a href>`s, and captures each — so the surface set _is_ the nav, with no list to hand-maintain (and so none to drift).
|
|
201
412
|
|
|
202
413
|
```ts
|
|
203
414
|
import { defineCrawlCapture } from 'styleproof';
|
|
@@ -223,11 +434,12 @@ defineCrawlCapture({
|
|
|
223
434
|
});
|
|
224
435
|
```
|
|
225
436
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
437
|
+
### Component inventory: fail when the catalog misses a component
|
|
438
|
+
|
|
439
|
+
StyleProof cannot render arbitrary component files by itself across frameworks;
|
|
440
|
+
props, providers, loaders, portals, and app shell context are app-owned. What it
|
|
441
|
+
can do reliably is inventory component files and make your catalog/story route
|
|
442
|
+
prove it has a capture for each one:
|
|
231
443
|
|
|
232
444
|
```ts
|
|
233
445
|
import { componentCatalogSurfaces, defineStyleMapCapture, discoverComponentFiles } from 'styleproof';
|
|
@@ -255,10 +467,12 @@ Use Storybook, Ladle, a framework route, or a tiny app-specific catalog for
|
|
|
255
467
|
`expected`, so a new component file appears immediately and CI fails until it has
|
|
256
468
|
a rendered surface or an explicit exclusion.
|
|
257
469
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
470
|
+
### Dialogs, popovers and menus: capture the open state as a variant
|
|
471
|
+
|
|
472
|
+
StyleProof cannot guess which app-specific button opens a modal, but once you
|
|
473
|
+
tell it the interaction, it compares matching states on base and head
|
|
474
|
+
(`home-dialog-open` to `home-dialog-open`). Keep these under the route/view that
|
|
475
|
+
owns them:
|
|
262
476
|
|
|
263
477
|
```ts
|
|
264
478
|
const SURFACES: Surface[] = [
|
|
@@ -289,6 +503,8 @@ Non-live `variants` add captures; the owning surface still captures too. Use
|
|
|
289
503
|
`liveStates` instead when the default live state is too fuzzy and only pinned
|
|
290
504
|
states such as `loading`, `loaded`, `empty`, or `error` should be compared.
|
|
291
505
|
|
|
506
|
+
### Popups, discovered automatically
|
|
507
|
+
|
|
292
508
|
When `popups: true` is enabled, StyleProof also tries visible safe triggers and
|
|
293
509
|
captures opened dialogs, menus, listboxes, modal roots, popovers, tooltips, and
|
|
294
510
|
toast/status roots. Each saved map includes `overlays` proof metadata for
|
|
@@ -307,12 +523,14 @@ why — instead of capturing contaminated state or keying a popup under the wron
|
|
|
307
523
|
trigger. Dismiss the leaking overlay in the surface's `go()`, or capture it as an
|
|
308
524
|
explicit variant.
|
|
309
525
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
526
|
+
### Harvest one-step variants
|
|
527
|
+
|
|
528
|
+
Routes are not the whole UI: drawers, tabs, dialogs, empty form errors, selects,
|
|
529
|
+
and other one-step states need their own captures. `styleproof-variants` opens a
|
|
530
|
+
running app, tries semantic controls (`[aria-expanded]`, tabs, summaries,
|
|
531
|
+
selects, required forms, etc.), captures a baseline and post-action StyleMap, and
|
|
532
|
+
keeps only actions that change computed styles. It also reports live-state
|
|
533
|
+
candidates that need fixtures or opt-outs.
|
|
316
534
|
|
|
317
535
|
```bash
|
|
318
536
|
styleproof-variants --base-url http://localhost:3000 --route / --route settings=/settings
|
|
@@ -370,16 +588,30 @@ defineStyleMapCapture({
|
|
|
370
588
|
|
|
371
589
|
Destructive labels are skipped, duplicate computed-style outcomes are deduped,
|
|
372
590
|
and `--strict` exits non-zero when live-state fixtures or skipped candidates
|
|
373
|
-
remain unresolved.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
591
|
+
remain unresolved. The harvester only finds states **reachable by interacting**
|
|
592
|
+
— it clicks, selects, and expands. A data-driven conditional branch (a fault
|
|
593
|
+
overlay, an empty render) has no control to click, so it never appears in the
|
|
594
|
+
manifest; those need `liveStates` fixtures, per the
|
|
595
|
+
[un-exercised-state gap](#the-un-exercised-state-gap-an-honest-green-gate-can-still-miss-a-real-restyle).
|
|
596
|
+
|
|
597
|
+
### Live UI states: capture each state, not an average
|
|
598
|
+
|
|
599
|
+
StyleProof automatically detects semantic live-state candidates (`aria-live`,
|
|
600
|
+
`role=status`, `role=alert`, `aria-busy=true`) and keeps stable ones in the
|
|
601
|
+
normal diff. If a stream, poll, or live region represents product states you
|
|
602
|
+
want certified (`loading`, `loaded`, `empty`, `error`), list only those pinned
|
|
603
|
+
states with `liveStates`. StyleProof writes separate captures such as
|
|
604
|
+
`dashboard-loading@1440` and `dashboard-loaded@1440`, so the base branch's
|
|
605
|
+
loading state compares to the feature branch's loading state, and loaded
|
|
606
|
+
compares to loaded.
|
|
607
|
+
|
|
608
|
+
This is also how you close the
|
|
609
|
+
[un-exercised-state gap](#the-un-exercised-state-gap-an-honest-green-gate-can-still-miss-a-real-restyle):
|
|
610
|
+
a conditional branch that only renders under specific data (a fault overlay, an
|
|
611
|
+
empty list, a permission wall) needs its own pinned state here, with the
|
|
612
|
+
fixture in **that surface's** `setup` — per-surface, not a global route
|
|
613
|
+
override, which would leak the faulty payload into every other surface reading
|
|
614
|
+
the same endpoint. Note both fixtures in the example below are scoped this way.
|
|
383
615
|
|
|
384
616
|
```ts
|
|
385
617
|
defineStyleMapCapture({
|
|
@@ -408,38 +640,79 @@ defineStyleMapCapture({
|
|
|
408
640
|
});
|
|
409
641
|
```
|
|
410
642
|
|
|
411
|
-
##
|
|
643
|
+
## Deterministic by default
|
|
412
644
|
|
|
413
|
-
|
|
645
|
+
A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you — **no fixtures required**:
|
|
414
646
|
|
|
415
|
-
|
|
647
|
+
- **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (set on the head capture); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
|
|
648
|
+
- **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
|
|
649
|
+
- **Self-check** — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR. **On by default while recording** (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. `STYLEPROOF_SELFCHECK=1` forces it on for both; `selfCheck: false` opts out.
|
|
650
|
+
- **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
|
|
651
|
+
- **Layout-equivalent margin noise is normalised.** If the browser reports
|
|
652
|
+
horizontal auto-centering margins (`margin-left`/`margin-right` and logical
|
|
653
|
+
equivalents) differently but the captured document-space rectangle is
|
|
654
|
+
identical, StyleProof treats that as the same rendered layout, including in
|
|
655
|
+
forced `:hover`/`:focus`/`:active` deltas. The suppression only fires when the
|
|
656
|
+
sides drift **together** (no demonstrable px imbalance between a side and its
|
|
657
|
+
opposite): a one-sided change like `margin-left: 0 → 40px` still reports even
|
|
658
|
+
when something else compensates and the box doesn't move, and if the box moves
|
|
659
|
+
or resizes, any margin change reports.
|
|
416
660
|
|
|
417
|
-
|
|
661
|
+
> Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
|
|
418
662
|
|
|
419
|
-
|
|
663
|
+
**Live pages just work when the intended state is deterministic.** Before each capture, StyleProof settles the page, and the settle is **network-aware**: it holds while the page's data requests are in flight (excluding long-lived `EventSource`/WebSocket streams, which never finish) _and_ until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured **loaded, not mid-load** — and, crucially, it **can't false-settle on the loading state before a slow backend's response arrives**. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it.
|
|
420
664
|
|
|
421
|
-
|
|
422
|
-
### `a.btn-solid` · 1 element restyled
|
|
423
|
-
_landing @ 1280_
|
|
665
|
+
Anything still moving on its own after that is detected as a volatile region and excluded from direct element comparison, so a stream or ticker never reads as a change just because its value changed. That is not the same as certifying every state of the live UI: an ignored or volatile subtree can still change `html`/`body` layout if its height changes. When those states matter, make them deterministic `liveStates` (`loading`, `loaded`, `empty`, `error`) and capture each on both branches. Self-check and reports automatically mention detected live-state candidates when volatile layout drift appears. `defineStyleMapCapture` arms the request tracker before each `go()` automatically; for a direct `captureStyleMap` call, arm one before you navigate with `trackInflightRequests(page)` and pass `{ pendingRequests }`. Disable or tune with `{ stabilize: false }` / `{ stabilize: { quietFor, timeout, waitForRequests } }`.
|
|
424
666
|
|
|
425
|
-
|
|
667
|
+
**At a glance — almost everything is automatic.** The few knobs exist only for what StyleProof can't know about your app, and each says why:
|
|
426
668
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
669
|
+
| Handled for you — zero config | How |
|
|
670
|
+
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
671
|
+
| In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
|
|
672
|
+
| Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately |
|
|
673
|
+
| Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
|
|
674
|
+
| Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
|
|
675
|
+
| Layout-equivalent horizontal auto margins | ignored only when the rectangle is unchanged **and** the sides drift together — a one-sided change still reports |
|
|
676
|
+
| Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
|
|
677
|
+
| Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
|
|
678
|
+
| Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
|
|
431
679
|
|
|
432
|
-
|
|
680
|
+
| You set this — only because it's app-specific | Why it exists |
|
|
681
|
+
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
682
|
+
| `STYLEPROOF_REPLAY_FROM` (record / replay) | Base and head capture at different times against a live backend; replaying the base's recorded data pins the head to the same inputs, so the diff is **your code, not data drift**. The one piece of real setup. |
|
|
683
|
+
| `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
|
|
684
|
+
| `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
|
|
685
|
+
| `liveStates: [{ key, setup, go }]` | A live feature has real states to certify. Capture each state on base and head (`surface-loading`, `surface-loaded`) instead of relying on a single moving page state. |
|
|
686
|
+
| `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open, modal-open, toast-visible, or overlay-expanded states. |
|
|
687
|
+
| `popups: true` | Visible click-triggered overlays should be discovered automatically. Captures each matching trigger's persistent dialogs, modal roots, popovers, menus, listboxes, toast/status roots, and open data-state overlays as `surface-popup-XX`; keep hover-only or destructive states as explicit `variants`. |
|
|
688
|
+
| `clockTime` | Your styling keys off a **specific** date, not just "now". |
|
|
689
|
+
| `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
|
|
433
690
|
|
|
434
|
-
|
|
691
|
+
### Data residue: a failed data request is named, not swallowed
|
|
692
|
+
|
|
693
|
+
A subtler gap than a _missing_ surface is a surface that renders the **wrong** state, silently. If a surface requests a data endpoint that nothing routes — no fixture, no `liveStates` — the request falls through and **fails during capture**, so the view paints its _fallback_ branch. Every capture then embeds that fallback; the state its real responses would drive is never captured, and a restyle confined to it ships green. StyleProof used to watch that request fail and say nothing.
|
|
435
694
|
|
|
436
|
-
|
|
695
|
+
Now it names it. During a spec-driven capture, any request matching the data boundary (`replayUrl`, default `**/api/**`) that **fails** — a network error, or a 4xx/5xx — is:
|
|
437
696
|
|
|
438
|
-
|
|
697
|
+
- **warned on stderr, always**, naming the surface and endpoint, what it means (the fallback branch was captured; the response-driven states are unproven), and what to do — fixture it with `page.route`/`liveStates`, or acknowledge it;
|
|
698
|
+
- **recorded on the capture** (`StyleMap.dataResidue`) so `styleproof-diff` and the report's certification block surface it, deduped per surface·endpoint across widths and the self-check re-run;
|
|
699
|
+
- **gated by default.** An _unacknowledged_ failing endpoint blocks the diff (exit 1): a silently-failing endpoint means the fallback branch shipped as the certified state, so gating is the correct default, not an opt-in. Acknowledge intentional ones in `styleproof.data-residue.json` (`{"<surface·endpoint>": "why"}`) — they render as visible opt-outs — and a **stale** acknowledgement (the endpoint no longer fails or isn't present) also fails, so the ledger can't rot. The same `exclude`-ledger discipline as the [inventory guard](docs/inventory-guard.md). Set `dataResidue: 'warn'` to opt down to record-and-warn without gating.
|
|
439
700
|
|
|
440
|
-
|
|
701
|
+
A 2xx endpoint that merely wasn't fixtured is **never** flagged: in recording mode every live response is legitimately recorded, so a blanket "uncontrolled" flag would fire on every healthy record run. Only _failures_ are residue. And StyleProof never synthesises the missing state for you — declaring an app's data states stays app-owned (see the un-exercised-state gap this pairs with). A capture with no failing data request is byte-identical whichever mode you run, so a clean app is unaffected.
|
|
441
702
|
|
|
442
|
-
|
|
703
|
+
```ts
|
|
704
|
+
// Gate by default — an unacknowledged failing data endpoint blocks the diff.
|
|
705
|
+
defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
706
|
+
|
|
707
|
+
// Opt down to record-and-warn without gating:
|
|
708
|
+
defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, dataResidue: 'warn' });
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
## Any styling system, real breakpoints
|
|
712
|
+
|
|
713
|
+
StyleProof reads the browser's **computed styles** — the values it actually resolves — never your source CSS. Tailwind, CSS Modules, styled-components, Sass, vanilla CSS, inline styles: all produce the same computed output, and that's what it diffs. Elements are keyed by **DOM structure, not class name**, so a refactor that rewrites every `class` still lines up element-for-element.
|
|
714
|
+
|
|
715
|
+
Breakpoints are detected the same way: omit `widths` on a surface and StyleProof reads your app's real `@media` breakpoints from the **loaded CSSOM** at capture time and sweeps one viewport per band — no config. It's framework-agnostic for the same reason the diff is: it reads the rules the browser actually parsed, not your source, so Tailwind / CSS Modules / Sass / vanilla all resolve to the same `@media` boundaries. And it's authoritative **or it fails** — an unreadable cross-origin stylesheet throws rather than silently miss a band; it never guesses. Pin `widths` explicitly when you want a fixed sweep, or to cover a JS-only (`matchMedia`) breakpoint that has no CSS rule.
|
|
443
716
|
|
|
444
717
|
## Match a design pixel-for-pixel
|
|
445
718
|
|
|
@@ -452,7 +725,7 @@ styleproof-diff design .styleproof/maps/current # design vs build — zero dif
|
|
|
452
725
|
|
|
453
726
|
You watch one number as you implement: the diff starts large and shrinks toward zero, and it hits zero the moment the built page matches the design. It's the objective version of putting the mockup and the app side by side and squinting.
|
|
454
727
|
|
|
455
|
-
(`styleproof-map` is the spec-driven flow for your own app's surfaces, with the coverage guard, map store, and record/replay; `styleproof-capture` is the one-shot for a page you just point at.) It writes `design/pricing@1440.json.gz` (+ `.png`)
|
|
728
|
+
(`styleproof-map` is the spec-driven flow for your own app's surfaces, with the coverage guard, map store, and record/replay; `styleproof-capture` is the one-shot for a page you just point at.) It writes `design/pricing@1440.json.gz` (+ `.png`) and a `styleproof-manifest.json`, the same shape any capture writes, so `styleproof-diff` compares it against anything — the manifest is what lets the two-directory compare verify both sides came from the same environment (v4 refuses a manifest-less side). Omit `--widths` to auto-detect the page's own `@media` breakpoints; pin them for a page whose CSS is cross-origin (a font stylesheet, say), since detection reads every sheet and fails loudly rather than guess. `--wait <selector>` holds until the intended state is on screen; `--ignore <selector>` skips a live region. Capture both sides in the same browser + fonts, since that's what "identical" is measured against.
|
|
456
729
|
|
|
457
730
|
### Crawl the whole interactive design: `--crawl`
|
|
458
731
|
|
|
@@ -467,7 +740,7 @@ It's **exhaustive by default**: the crawl stops when there is nothing left to dr
|
|
|
467
740
|
|
|
468
741
|
What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface. And it's **parallel by default** — `--workers <n>` (default 4) sweeps states concurrently on isolated browser contexts with the exact same surface set as a serial crawl (dedup is shared; children only enter the queue when their parent's sweep completes); `--workers 1` if you want byte-stable dup-key attribution.
|
|
469
742
|
|
|
470
|
-
**And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap).
|
|
743
|
+
**And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap). When coverage is the goal rather than the map, `--until-covered` stops the crawl early the moment every class has rendered — the fast check, vs the exhaustive default.
|
|
471
744
|
|
|
472
745
|
**Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked** — mapping must not mutate; states gated behind one of those need a spec. Prefer the spec-driven `defineStyleMapCapture` when you want stable, named keys and the coverage guard; reach for `--crawl` to map a design (or a third-party page) you don't have a spec for.
|
|
473
746
|
|
|
@@ -512,122 +785,6 @@ The crawl's vocabulary is **click, select, neutral typing, scrolling, and your s
|
|
|
512
785
|
|
|
513
786
|
The rule of thumb: **a rendered state is a function of props, data, and input.** Control all three — mock the data, script the input, mount the component — and every state a component can render is a capturable surface. The verifier tells you, by name, which ones you haven't controlled yet.
|
|
514
787
|
|
|
515
|
-
## Install
|
|
516
|
-
|
|
517
|
-
```bash
|
|
518
|
-
npm install -D styleproof @playwright/test
|
|
519
|
-
npx playwright install chromium
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
Requires **Node ≥ 18** (ESM), **`@playwright/test` ≥ 1.40** (peer dep). Forced states are Chromium-only.
|
|
523
|
-
|
|
524
|
-
## Quickstart
|
|
525
|
-
|
|
526
|
-
After installing (above), one command sets up the whole gate:
|
|
527
|
-
|
|
528
|
-
```bash
|
|
529
|
-
npx styleproof-init
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
It scaffolds:
|
|
533
|
-
|
|
534
|
-
- a **capture spec** (`e2e/styleproof.spec.ts`) describing your surfaces (a Next.js app gets its routes _and_ the coverage guard wired automatically — see below);
|
|
535
|
-
- a dedicated **`playwright.styleproof.config.ts`** that builds and serves a **production build** (never a flaky dev server), scopes discovery to the StyleProof spec, and captures surfaces **in parallel** (`fullyParallel`) without disturbing your app's existing Playwright config;
|
|
536
|
-
- `.gitignore` entries for `.styleproof/`, `test-results/`, and `playwright-report/`;
|
|
537
|
-
- a **cache-first CI workflow** that restores reusable maps from the `styleproof-maps` branch and generates the report without a browser when both maps are already built.
|
|
538
|
-
|
|
539
|
-
Describe your surfaces — **omit `widths`** and StyleProof sweeps your real `@media` breakpoints automatically:
|
|
540
|
-
|
|
541
|
-
```ts
|
|
542
|
-
import { defineStyleMapCapture } from 'styleproof';
|
|
543
|
-
|
|
544
|
-
defineStyleMapCapture({
|
|
545
|
-
surfaces: [
|
|
546
|
-
{
|
|
547
|
-
key: 'landing',
|
|
548
|
-
go: (page) => page.goto('/'), // StyleProof settles the page (in-flight data, fonts, animations) before it reads
|
|
549
|
-
// no `widths` → auto-detected from your @media bands; set `widths: [1280, 768, 390]` to pin them
|
|
550
|
-
},
|
|
551
|
-
],
|
|
552
|
-
dir: process.env.STYLEMAP_DIR,
|
|
553
|
-
});
|
|
554
|
-
```
|
|
555
|
-
|
|
556
|
-
The manual loop is deliberately three commands:
|
|
557
|
-
|
|
558
|
-
```bash
|
|
559
|
-
npx styleproof-init
|
|
560
|
-
npx styleproof-map
|
|
561
|
-
npx styleproof-diff
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
`styleproof-map` captures the current commit into `.styleproof/maps/current`,
|
|
565
|
-
writes a manifest, and uploads the bundle
|
|
566
|
-
to the dedicated `styleproof-maps` branch when the working tree was clean and a
|
|
567
|
-
git remote is available. Nothing under `.styleproof/` belongs in the PR branch.
|
|
568
|
-
HAR recordings are removed before upload by default so private API responses do
|
|
569
|
-
not land in the map store. Keep them locally only for an explicit record/replay
|
|
570
|
-
workflow with `styleproof-map --keep-har` (or `STYLEPROOF_KEEP_HAR=1`).
|
|
571
|
-
|
|
572
|
-
`styleproof-diff` restores the base and head maps from `styleproof-maps`
|
|
573
|
-
automatically: in GitHub Actions it uses the PR base/head SHAs; locally it checks
|
|
574
|
-
`branch.<name>.gh-merge-base`, then the current GitHub PR base via `gh pr view`
|
|
575
|
-
(handy for stacked PRs), then `origin/main`, `origin/master`, `main`, and
|
|
576
|
-
`master`. Pin the base with `styleproof-diff main` or `styleproof-diff master`.
|
|
577
|
-
|
|
578
|
-
**That's the whole loop.** Build the map outside CI when possible by running
|
|
579
|
-
`styleproof-map` after committing. On the PR, CI first restores the base/head
|
|
580
|
-
bundles and only generates the report — no build, no browser. If either bundle
|
|
581
|
-
is missing or incompatible, CI recaptures both sides in the same pinned
|
|
582
|
-
environment before reporting. Correctness wins over a stale cache, but the hot
|
|
583
|
-
path is report-only.
|
|
584
|
-
|
|
585
|
-
> **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the **real browser build** (`browser().version()`) in its manifest — the npm `@playwright/test` version is only a proxy, and the actual Chromium binary can change while it holds constant (a `playwright install` re-download, a different `PLAYWRIGHT_BROWSERS_PATH`, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. **Installed fonts are your responsibility:** they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.
|
|
586
|
-
|
|
587
|
-
**Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
|
|
588
|
-
styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
|
|
589
|
-
the same cached-map defaults as `styleproof-diff`. Pin the base with
|
|
590
|
-
`styleproof-report main` or keep the manual form with `styleproof-report before
|
|
591
|
-
after --out report`.
|
|
592
|
-
|
|
593
|
-
**Want the side-by-side report + one-click approval**? `styleproof-init` scaffolds
|
|
594
|
-
this for you. If you wire it by hand, restore or capture two dirs first, then use
|
|
595
|
-
the Action on those dirs:
|
|
596
|
-
|
|
597
|
-
```yaml
|
|
598
|
-
# .github/workflows/styleproof.yml
|
|
599
|
-
- uses: actions/checkout@v4
|
|
600
|
-
- run: npx styleproof-map --restore --sha "${{ github.event.pull_request.base.sha }}" --dir base --base-dir __stylemaps__
|
|
601
|
-
- run: npx styleproof-map --restore --sha "${{ github.event.pull_request.head.sha }}" --dir head --base-dir __stylemaps__
|
|
602
|
-
- uses: BenSheridanEdwards/StyleProof@v3
|
|
603
|
-
with:
|
|
604
|
-
baseline-dir: __stylemaps__/base
|
|
605
|
-
fresh-dir: __stylemaps__/head
|
|
606
|
-
require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
Then 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 approval checkbox is inert until it's merged).
|
|
610
|
-
|
|
611
|
-
**Prefer to always capture in CI?** For a repo with many outside contributors on different machines, StyleProof can capture **both** base and head in CI and diff them there. See **[Forks and Dependabot](#forks-and-dependabot)** for that flow (it's also the fork-safe split). The default cache-first flow is faster for same-repo teams because local `styleproof-map` can build the head map before CI starts.
|
|
612
|
-
|
|
613
|
-
**Want to skip work safely?** Skip the **whole** StyleProof workflow only for
|
|
614
|
-
changes that cannot affect rendered output, such as docs-only edits, using your
|
|
615
|
-
CI provider's native path filters. Do not skip individual surfaces from a
|
|
616
|
-
StyleProof run based on a changed-file guess: shared CSS, tokens, resets,
|
|
617
|
-
themes, layout primitives, and runtime styling can repaint any surface, and a
|
|
618
|
-
missed surface would certify green without being measured. If you want faster
|
|
619
|
-
feedback, order the highest-signal surfaces first in your spec, but still let
|
|
620
|
-
the full sweep finish before treating the gate as passed.
|
|
621
|
-
|
|
622
|
-
```yaml
|
|
623
|
-
on:
|
|
624
|
-
pull_request:
|
|
625
|
-
paths-ignore:
|
|
626
|
-
- '**/*.md'
|
|
627
|
-
- 'docs/**'
|
|
628
|
-
- '.github/ISSUE_TEMPLATE/**'
|
|
629
|
-
```
|
|
630
|
-
|
|
631
788
|
## Forks and Dependabot
|
|
632
789
|
|
|
633
790
|
If you **always capture in CI** rather than restoring maps from `styleproof-maps` (a better fit when many outside contributors push from different machines), the simplest setup runs the whole gate in one `pull_request` job that captures base + head and diffs them. That job needs a **write** token to push the report branch, post the comment, and set the `StyleProof` status. That's fine for same-repo PRs, but **fork and Dependabot PRs run with a read-only `GITHUB_TOKEN`** (GitHub's security default for untrusted PRs). So the job can't post the status — and a required `StyleProof` check then sits `pending` forever, blocking the PR even though a dependency or fork change usually touches no UI at all.
|
|
@@ -643,49 +800,6 @@ That last point is why this works where `pull_request_target` does not: StylePro
|
|
|
643
800
|
|
|
644
801
|
Copy both `capture` and `report` files to `.github/workflows/` (the `report` one must be on your default branch, like `styleproof-approve.yml`), then require the `StyleProof` status in branch protection. A single combined `pull_request` job that captures base + head and diffs them is fine for repos that never see fork or bot PRs; this split is only needed for untrusted PRs.
|
|
645
802
|
|
|
646
|
-
**Deterministic by default — no fixtures required.** A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you:
|
|
647
|
-
|
|
648
|
-
- **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (set on the head capture); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
|
|
649
|
-
- **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
|
|
650
|
-
- **Self-check** — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR. **On by default while recording** (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. `STYLEPROOF_SELFCHECK=1` forces it on for both; `selfCheck: false` opts out.
|
|
651
|
-
- **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
|
|
652
|
-
- **Layout-equivalent margin noise is normalised.** If the browser reports
|
|
653
|
-
horizontal auto-centering margins (`margin-left`/`margin-right` and logical
|
|
654
|
-
equivalents) differently but the captured document-space rectangle is
|
|
655
|
-
identical, StyleProof treats that as the same rendered layout, including in
|
|
656
|
-
forced `:hover`/`:focus`/`:active` deltas. If the box moves or resizes, the
|
|
657
|
-
margin change still reports.
|
|
658
|
-
|
|
659
|
-
> Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
|
|
660
|
-
|
|
661
|
-
**Live pages just work when the intended state is deterministic.** Before each capture, StyleProof settles the page, and the settle is **network-aware**: it holds while the page's data requests are in flight (excluding long-lived `EventSource`/WebSocket streams, which never finish) _and_ until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured **loaded, not mid-load** — and, crucially, it **can't false-settle on the loading state before a slow backend's response arrives**. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it.
|
|
662
|
-
|
|
663
|
-
Anything still moving on its own after that is detected as a volatile region and excluded from direct element comparison, so a stream or ticker never reads as a change just because its value changed. That is not the same as certifying every state of the live UI: an ignored or volatile subtree can still change `html`/`body` layout if its height changes. When those states matter, make them deterministic `liveStates` (`loading`, `loaded`, `empty`, `error`) and capture each on both branches. Self-check and reports automatically mention detected live-state candidates when volatile layout drift appears. `defineStyleMapCapture` arms the request tracker before each `go()` automatically; for a direct `captureStyleMap` call, arm one before you navigate with `trackInflightRequests(page)` and pass `{ pendingRequests }`. Disable or tune with `{ stabilize: false }` / `{ stabilize: { quietFor, timeout, waitForRequests } }`.
|
|
664
|
-
|
|
665
|
-
**At a glance — almost everything is automatic.** The few knobs exist only for what StyleProof can't know about your app, and each says why:
|
|
666
|
-
|
|
667
|
-
| Handled for you — zero config | How |
|
|
668
|
-
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
669
|
-
| In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
|
|
670
|
-
| Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately |
|
|
671
|
-
| Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
|
|
672
|
-
| Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
|
|
673
|
-
| Layout-equivalent horizontal auto margins | ignored only when the captured element rectangle is unchanged |
|
|
674
|
-
| Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
|
|
675
|
-
| Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
|
|
676
|
-
| Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
|
|
677
|
-
|
|
678
|
-
| You set this — only because it's app-specific | Why it exists |
|
|
679
|
-
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
680
|
-
| `STYLEPROOF_REPLAY_FROM` (record / replay) | Base and head capture at different times against a live backend; replaying the base's recorded data pins the head to the same inputs, so the diff is **your code, not data drift**. The one piece of real setup. |
|
|
681
|
-
| `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
|
|
682
|
-
| `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
|
|
683
|
-
| `liveStates: [{ key, setup, go }]` | A live feature has real states to certify. Capture each state on base and head (`surface-loading`, `surface-loaded`) instead of relying on a single moving page state. |
|
|
684
|
-
| `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open, modal-open, toast-visible, or overlay-expanded states. |
|
|
685
|
-
| `popups: true` | Visible click-triggered overlays should be discovered automatically. Captures each matching trigger's persistent dialogs, modal roots, popovers, menus, listboxes, toast/status roots, and open data-state overlays as `surface-popup-XX`; keep hover-only or destructive states as explicit `variants`. |
|
|
686
|
-
| `clockTime` | Your styling keys off a **specific** date, not just "now". |
|
|
687
|
-
| `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
|
|
688
|
-
|
|
689
803
|
## Optional: content layer (advisory)
|
|
690
804
|
|
|
691
805
|
StyleProof is **computed-styles first**, and stays that way: a CSS-only refactor that also rewrites text is still certified identical, and live text (a clock, "2m ago") never reads as a change. But a pure-style diff is blind to copy, and copy isn't always cosmetic: **new or longer text can overflow or clip its box, silently breaking the layout.** A visual-confidence tool that can't see that isn't quite complete. So the content layer exists as an explicit **opt-in**, off by default, and **advisory** — it never feeds the certification or the gate.
|
|
@@ -721,7 +835,7 @@ Like the content layer it is **advisory**: never fed to the certification diff o
|
|
|
721
835
|
|
|
722
836
|
## Optional: selective remap (advisory)
|
|
723
837
|
|
|
724
|
-
On a large app, capturing every surface on every PR is the slow part. `affectedSurfaces` answers the question that lets you skip most of it: **given the files a change touched, which declared surfaces could have rendered differently?** Everything it doesn't return can reuse its
|
|
838
|
+
On a large app, capturing every surface on every PR is the slow part. `affectedSurfaces` answers the question that lets you skip most of it: **given the files a change touched, which declared surfaces could have rendered differently?** Everything it doesn't return can reuse its restored base map.
|
|
725
839
|
|
|
726
840
|
It is **opt-in and never part of the default gate** — the gate still captures every surface and lets the map be the oracle. This is a helper for wiring a faster pre-push/CI path yourself, and it is built to be wrong only in the safe direction: when it cannot _prove_ a surface is unaffected, it returns the sentinel `'all'` (re-capture everything). A global stylesheet or token, a vanilla (unscoped) stylesheet, a `createGlobalStyle`, a design-system config, an unbounded `import(x)`, or a file it can't place — all resolve to `'all'`.
|
|
727
841
|
|
|
@@ -752,7 +866,7 @@ Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)`
|
|
|
752
866
|
|
|
753
867
|
### Show the skip list, then wire the pre-push hook
|
|
754
868
|
|
|
755
|
-
Before you trust a skip, print it. `explainAffectedSurfaces(result, allSurfaceKeys)` renders the verdict as reviewer-checkable lines — which surfaces re-capture and which reuse their
|
|
869
|
+
Before you trust a skip, print it. `explainAffectedSurfaces(result, allSurfaceKeys)` renders the verdict as reviewer-checkable lines — which surfaces re-capture and which reuse their restored base map — and takes an optional reason string for the `'all'` case:
|
|
756
870
|
|
|
757
871
|
```ts
|
|
758
872
|
import { affectedSurfaces, explainAffectedSurfaces } from 'styleproof';
|
|
@@ -779,7 +893,7 @@ selective remap: OFF → re-capture all 3 surface(s) — src/tokens.css is a glo
|
|
|
779
893
|
↻ pricing (re-capture)
|
|
780
894
|
```
|
|
781
895
|
|
|
782
|
-
Wired into a pre-push hook, the whole recipe is: diff the changed files, produce the graph, ask `affectedSurfaces`, print the skip list, then capture only the subset and reuse the
|
|
896
|
+
Wired into a pre-push hook, the whole recipe is: diff the changed files, produce the graph, ask `affectedSurfaces`, print the skip list, then capture only the subset and reuse the base maps restored from `styleproof-maps` for the rest.
|
|
783
897
|
|
|
784
898
|
```sh
|
|
785
899
|
#!/usr/bin/env sh
|
|
@@ -789,15 +903,11 @@ npx dependency-cruiser src --no-config --output-type json > dc.json
|
|
|
789
903
|
node scripts/selective-remap.mjs "$CHANGED" dc.json # affectedSurfaces + explain + capture subset
|
|
790
904
|
```
|
|
791
905
|
|
|
792
|
-
`scripts/selective-remap.mjs` is yours to own — it maps `dc.json` into `{ from, to }` edges (as above), calls `affectedSurfaces`, prints `explainAffectedSurfaces`, then captures only the returned keys and copies each reused surface's
|
|
793
|
-
|
|
794
|
-
## Newly-added elements show their full style
|
|
795
|
-
|
|
796
|
-
When a PR **adds** an element, StyleProof now reports its **full resting computed style** (background, padding, font, radius, …), value-only, in addition to any interaction-state deltas — previously an added element surfaced only its `:hover`/`:focus` changes. The new element already gates via its `added` finding; this only enriches what you see, in both the report and the `styleproof-diff` CLI.
|
|
906
|
+
`scripts/selective-remap.mjs` is yours to own — it maps `dc.json` into `{ from, to }` edges (as above), calls `affectedSurfaces`, prints `explainAffectedSurfaces`, then captures only the returned keys and copies each reused surface's restored base map forward. `main` re-captures everything, so a PR-time miss is still caught at merge.
|
|
797
907
|
|
|
798
908
|
## Reference
|
|
799
909
|
|
|
800
|
-
**Action `BenSheridanEdwards/StyleProof@
|
|
910
|
+
**Action `BenSheridanEdwards/StyleProof@v4`** — key inputs:
|
|
801
911
|
|
|
802
912
|
| Input | Default | Purpose |
|
|
803
913
|
| ------------------ | ------------ | ----------------------------------------------------------------------------------- |
|
|
@@ -811,38 +921,42 @@ Outputs: `changed` (`"true"` when any existing surface changed, or a new surface
|
|
|
811
921
|
|
|
812
922
|
**Policy file `styleproof.config.json`** (optional, at the repo root) — gate policy that isn't workflow plumbing:
|
|
813
923
|
|
|
814
|
-
| Key
|
|
815
|
-
|
|
|
816
|
-
| `blocking`
|
|
924
|
+
| Key | Default | Purpose |
|
|
925
|
+
| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
926
|
+
| `blocking` | `true` | Review-gate mode only: on **unapproved** visual changes, also **fail the job** (red ✗), so the check blocks even without a branch-protection rule requiring the status. On by default; set `false` for advisory-only (status red, job green). See below. |
|
|
927
|
+
| `gateInventoryRemovals` | `true` | Fail the Action on an **unacknowledged navigable removal** (see [What a green certifies](#what-a-green-certifies)). Set `false` to make inventory advisory. |
|
|
817
928
|
|
|
818
929
|
### Blocking without branch protection
|
|
819
930
|
|
|
820
|
-
A commit status only _blocks a merge_ where a branch-protection rule requires it — which needs GitHub Pro or a public repo. On a free private repo the `StyleProof` status is advisory.
|
|
931
|
+
A commit status only _blocks a merge_ where a branch-protection rule requires it — which needs GitHub Pro or a public repo. On a free private repo the `StyleProof` status is advisory. So review-gate mode is **blocking by default**: on unapproved changes it also fails the report job, and the PR shows a red check regardless of branch protection.
|
|
932
|
+
|
|
933
|
+
For advisory-only (the status goes red but the job stays green — useful when a branch-protection rule already requires the `StyleProof` status), opt out in `styleproof.config.json`:
|
|
821
934
|
|
|
822
935
|
```json
|
|
823
|
-
{ "blocking":
|
|
936
|
+
{ "blocking": false }
|
|
824
937
|
```
|
|
825
938
|
|
|
826
939
|
It's **asynchronous by design**: approval is a checkbox tick handled by a separate workflow, so to clear the red you tick **Approve all changes**, then **re-run the StyleProof job** — the re-run sees the sign-off on the commit status and passes. (A new push that changes styles re-opens it.)
|
|
827
940
|
|
|
828
941
|
**Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
|
|
829
942
|
|
|
830
|
-
| Option | Default | Purpose
|
|
831
|
-
| ------------- | --------------------------- |
|
|
832
|
-
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants?, popups? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band.
|
|
833
|
-
| `liveStates` | _none_ | Optional pinned live product states. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<state>` and is labeled as a live state in reports.
|
|
834
|
-
| `variants` | _none_ | Optional non-live deterministic states under a surface. The base surface still captures; each variant becomes `<surface>-<variant>` so base/head compare matching states.
|
|
835
|
-
| `popups` | `false` | Optional automatic popup capture. Set `true` or `{ max, triggers, overlays, timeoutMs }` to click visible safe triggers and save each opened overlay state as `<surface>-popup-XX`; maps include `overlays` proof metadata for captured semantic roots.
|
|
836
|
-
| `expected` | _none_ | Your route/view/state/component universe. Emits a coverage-guard test (runs without a capture dir) that fails when a required key has no surface and isn't excluded.
|
|
837
|
-
| `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale.
|
|
838
|
-
| `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs.
|
|
839
|
-
| `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use.
|
|
840
|
-
| `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs.
|
|
841
|
-
| `
|
|
842
|
-
| `
|
|
843
|
-
| `
|
|
844
|
-
| `
|
|
845
|
-
| `
|
|
943
|
+
| Option | Default | Purpose |
|
|
944
|
+
| ------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
945
|
+
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants?, popups? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
|
|
946
|
+
| `liveStates` | _none_ | Optional pinned live product states. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<state>` and is labeled as a live state in reports. |
|
|
947
|
+
| `variants` | _none_ | Optional non-live deterministic states under a surface. The base surface still captures; each variant becomes `<surface>-<variant>` so base/head compare matching states. |
|
|
948
|
+
| `popups` | `false` | Optional automatic popup capture. Set `true` or `{ max, triggers, overlays, timeoutMs }` to click visible safe triggers and save each opened overlay state as `<surface>-popup-XX`; maps include `overlays` proof metadata for captured semantic roots. |
|
|
949
|
+
| `expected` | _none_ | Your route/view/state/component universe. Emits a coverage-guard test (runs without a capture dir) that fails when a required key has no surface and isn't excluded. |
|
|
950
|
+
| `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale. |
|
|
951
|
+
| `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
|
|
952
|
+
| `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
|
|
953
|
+
| `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
|
|
954
|
+
| `dataResidue` | `'gate'` | Name data-boundary (`replayUrl`) requests that **fail** during capture (network error / 4xx/5xx — the fallback branch got captured). Always warned + recorded; `'gate'` (the default) also blocks the diff on an unacknowledged one, `'warn'` is the opt-out that records + warns without gating. See [Data residue](#data-residue-a-failed-data-request-is-named-not-swallowed). |
|
|
955
|
+
| `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
|
|
956
|
+
| `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
|
|
957
|
+
| `selfCheck` | on while recording | Capture each surface twice and fail on any difference — proves the capture is deterministic. Off on the replay run; `STYLEPROOF_SELFCHECK=1` forces both. |
|
|
958
|
+
| `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
|
|
959
|
+
| `baseDir` | `__stylemaps__` | Output root directory. |
|
|
846
960
|
|
|
847
961
|
Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<style>`/… and `next-route-announcer`) are skipped automatically; a surface's `ignore` adds to that default, it doesn't replace it.
|
|
848
962
|
|
|
@@ -858,16 +972,18 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
858
972
|
| `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
|
|
859
973
|
| `STYLEPROOF_UPLOAD` | `1` to require map-store upload; `0` to capture locally only. |
|
|
860
974
|
| `STYLEPROOF_CACHE_BRANCH` | Map store branch (default `styleproof-maps`). |
|
|
975
|
+
| `STYLEPROOF_SKIP_CAPTURE` | `1` to skip the scaffolded pre-push capture/publish hook for one push. |
|
|
861
976
|
| `STYLEPROOF_CRAWL_BASE_URL` | App URL for the optional pre-map `styleproof-variants` crawl. |
|
|
862
977
|
| `STYLEPROOF_CRAWL_ROUTES` | Comma-separated routes for the optional pre-map crawl, e.g. `/,settings=/settings`. |
|
|
863
978
|
| `STYLEPROOF_CRAWL_STRICT` | `1` to fail the optional pre-map crawl on live-state fixtures or skipped candidates. |
|
|
864
979
|
|
|
865
980
|
**CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
|
|
866
981
|
|
|
867
|
-
- `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries,
|
|
982
|
+
- `styleproof-init` — scaffold the gate: the capture spec (inventory guard on; Next.js repos get route discovery + the coverage guard, others a crawl-by-default spec), a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, the cache-first report workflow, the approval workflow, and the pre-push publish hook. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
|
|
868
983
|
- `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
|
|
869
|
-
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no
|
|
984
|
+
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an unacknowledged failing data endpoint under an armed `dataResidue: 'gate'`, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **manifest-less side** — since **v4**, a two-directory compare where a side ships maps but no `styleproof-manifest.json` is refused loudly, naming the bare side(s), because the same-environment guard can't be enforced without one; re-capture with current StyleProof; **and** a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no maps at all (and hence no manifest), meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
|
|
870
985
|
- `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
|
|
986
|
+
- `styleproof-capture` — one-shot capture of any URL (no spec): `styleproof-capture <url> --key <name> --out <dir>`, with `--widths` (omit to auto-detect `@media` bands), `--wait <selector>`, `--ignore <selector>`, `--no-screenshots`, and the crawler flags (`--crawl`, `--setup <file>`, `--require-full-coverage` → exit 4 on residue, `--until-covered`, `--workers <n>`, `--no-data-states`) described in [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel).
|
|
871
987
|
- `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
|
|
872
988
|
|
|
873
989
|
A programmatic API is also exported — `captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, and the breakpoint helpers `detectViewportWidths` / `widthsFromBoundaries`, among others. 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.
|