styleproof 3.1.5 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +85 -25
- package/bin/styleproof-capture.mjs +136 -0
- package/bin/styleproof-diff.mjs +1 -1
- package/bin/styleproof-init.mjs +81 -19
- package/bin/styleproof-map.mjs +95 -4
- package/bin/styleproof-report.mjs +3 -3
- package/dist/capture-url.d.ts +80 -0
- package/dist/capture-url.js +185 -0
- package/dist/crawl-surfaces.d.ts +96 -0
- package/dist/crawl-surfaces.js +500 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/map-store.js +9 -1
- package/dist/report.js +5 -7
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.3.0] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- New `styleproof-capture <url>` CLI: capture a single page (a deployed URL, a
|
|
15
|
+
static export, or a standalone HTML mockup) so you can prove a production build
|
|
16
|
+
renders identically to its design. Point it at the design and at the build,
|
|
17
|
+
diff the two, and zero diff means pixel-identical — anything else is named down
|
|
18
|
+
to the computed style. One shot, no spec, no config; writes the same
|
|
19
|
+
`<key>@<width>.json.gz` (+ `.png`) shape any capture does, so `styleproof-diff`
|
|
20
|
+
compares it against anything. Also exported programmatically as
|
|
21
|
+
`captureUrlToDir` / `runCaptureUrl` / `parseCaptureUrlArgs`.
|
|
22
|
+
- `styleproof-capture --crawl`: map a whole interactive design from one URL with
|
|
23
|
+
no spec and no selectors — EXHAUSTIVE by default, running until every
|
|
24
|
+
non-destructive control has been driven once and every structurally new surface
|
|
25
|
+
captured (a modal's tabs, a drawer's sub-views, a popover's panels), each under
|
|
26
|
+
a derived key. The sweep works in place with lazy, fingerprint-verified resets:
|
|
27
|
+
a no-op click costs nothing, only state-changing clicks pay a fresh
|
|
28
|
+
navigation + replay, and children are never attributed to the wrong parent.
|
|
29
|
+
Deterministic, deduped by a structural fingerprint, self-settling for async
|
|
30
|
+
(React/Vue/Babel) apps, discovery pinned to one viewport width, progress
|
|
31
|
+
streamed per captured surface, and destructive-looking controls are never
|
|
32
|
+
clicked. `--max-depth` / `--max-actions` / `--max-states` exist only as
|
|
33
|
+
throttles. Exported programmatically as `crawlAndCapture`.
|
|
34
|
+
- Crawl coverage verifier: after `--crawl`, every class the page's own
|
|
35
|
+
stylesheets define (parsed CSSOM) is checked against the classes rendered
|
|
36
|
+
across all captured surfaces, and the never-seen residue is printed — dead
|
|
37
|
+
CSS, or a state the crawl could not reach. `--require-full-coverage` makes
|
|
38
|
+
any residue exit 4, so "the design is fully covered, nothing missing" is a
|
|
39
|
+
machine-checked property. On `CrawlReport.coverage` programmatically.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- GitHub Action reports now publish every generated crop PNG, including zoom
|
|
44
|
+
crops, so committed `report.md` files do not point at missing images.
|
|
45
|
+
|
|
46
|
+
## [3.2.0] - 2026-06-30
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- Added optional `styleproof-map --crawl-base-url ... --crawl-route ...` pre-map
|
|
51
|
+
variant crawling, so automation can refresh the generated variant manifest
|
|
52
|
+
immediately before map capture.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- `styleproof-init` now writes a dedicated `playwright.styleproof.config.ts`,
|
|
57
|
+
scopes discovery to the StyleProof spec, detects Vite/Next production preview
|
|
58
|
+
commands, and respects pnpm/Corepack pins in generated commands.
|
|
59
|
+
- Map upload dirty-tree detection now ignores Next's generated `next-env.d.ts`
|
|
60
|
+
shim, while normal source edits still block upload.
|
|
61
|
+
|
|
10
62
|
## [3.1.5] - 2026-06-29
|
|
11
63
|
|
|
12
64
|
### Added
|
|
@@ -56,6 +108,13 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
56
108
|
|
|
57
109
|
## [3.1.4] - 2026-06-29
|
|
58
110
|
|
|
111
|
+
### Changed
|
|
112
|
+
|
|
113
|
+
- New surfaces now require approval in review-gate mode. A new-surface-only diff
|
|
114
|
+
still exits `3`, but the Action marks `changed=true`, posts the report, and
|
|
115
|
+
holds the `StyleProof` status red until the reviewer approves it. Certify mode
|
|
116
|
+
was already strict because it fails on any report.
|
|
117
|
+
|
|
59
118
|
### Fixed
|
|
60
119
|
|
|
61
120
|
- **Popup capture no longer collapses distinct triggers that reuse the same
|
|
@@ -1058,7 +1117,8 @@ number)`), so each viewport band can capture at its own height. Default remains
|
|
|
1058
1117
|
- `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
|
|
1059
1118
|
property, and state that drifted (exit 1).
|
|
1060
1119
|
|
|
1061
|
-
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.
|
|
1120
|
+
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.2.0...HEAD
|
|
1121
|
+
[3.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.5...v3.2.0
|
|
1062
1122
|
[3.1.5]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.4...v3.1.5
|
|
1063
1123
|
[3.1.4]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.3...v3.1.4
|
|
1064
1124
|
[3.1.3]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.2...v3.1.3
|
package/README.md
CHANGED
|
@@ -16,11 +16,12 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
|
|
|
16
16
|
- [What it catches](#what-it-catches)
|
|
17
17
|
- [What you own](#what-you-own)
|
|
18
18
|
- [What the PR gets](#what-the-pr-gets)
|
|
19
|
-
- [
|
|
19
|
+
- [Auto-discovery](#auto-discovery)
|
|
20
20
|
- [What a report looks like](#what-a-report-looks-like)
|
|
21
21
|
- [Works with any styling system](#works-with-any-styling-system)
|
|
22
22
|
- [Breakpoints, detected automatically](#breakpoints-detected-automatically)
|
|
23
23
|
- [Certify a refactor](#certify-a-refactor)
|
|
24
|
+
- [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel)
|
|
24
25
|
- [Install](#install)
|
|
25
26
|
- [Quickstart](#quickstart)
|
|
26
27
|
- [Forks and Dependabot](#forks-and-dependabot)
|
|
@@ -52,13 +53,15 @@ The important boundary: StyleProof only certifies states it can reach. If a stat
|
|
|
52
53
|
matters, list it as a surface, variant, popup, live state, or component-catalog
|
|
53
54
|
surface. `expected` turns that inventory into a guard, so an uncaptured new page,
|
|
54
55
|
component, modal, dropdown, or toast fails as missing coverage instead of
|
|
55
|
-
silently passing.
|
|
56
|
+
silently passing. A surface that exists only on the PR head is still reviewable:
|
|
57
|
+
in review-gate mode it holds the status red until approved, then becomes part of
|
|
58
|
+
the baseline once merged.
|
|
56
59
|
|
|
57
60
|
## The short version
|
|
58
61
|
|
|
59
62
|
1. A **surface** is one UI state to certify: a route, tab, modal-open state,
|
|
60
63
|
dropdown-open state, toast-visible state, loading state, etc.
|
|
61
|
-
2. You list surfaces in a Playwright-style spec.
|
|
64
|
+
2. You list or auto-discover surfaces in a Playwright-style spec.
|
|
62
65
|
3. StyleProof opens each surface at real breakpoint widths and records computed
|
|
63
66
|
styles for every captured element.
|
|
64
67
|
4. On a PR, it compares base vs head and reports exactly which rendered styles
|
|
@@ -94,6 +97,12 @@ states that matter:
|
|
|
94
97
|
- required-but-not-yet-captured states belong in `expected`, where the coverage
|
|
95
98
|
guard fails until they are captured or explicitly excluded with a reason.
|
|
96
99
|
|
|
100
|
+
Auto-discovery is a key feature, not a side path: StyleProof can discover
|
|
101
|
+
Next.js routes, crawl real nav links, inventory component files, harvest
|
|
102
|
+
one-step variants, click safe popups, detect breakpoints, and flag live or
|
|
103
|
+
volatile regions. You review the app-specific leftovers; StyleProof keeps the
|
|
104
|
+
mechanical inventory from drifting.
|
|
105
|
+
|
|
97
106
|
That boundary is deliberate. StyleProof should not guess destructive flows,
|
|
98
107
|
auth-only fixtures, or which product state your component needs. It should make
|
|
99
108
|
missing coverage loud.
|
|
@@ -111,12 +120,22 @@ full report. The report groups each distinct visual change with:
|
|
|
111
120
|
|
|
112
121
|
In review-gate mode, one **Approve all changes** checkbox turns the `StyleProof`
|
|
113
122
|
status green for that commit. Clean runs still leave a receipt: `No visual
|
|
114
|
-
changes detected.` New surfaces are shown as new baselines
|
|
115
|
-
handled by `expected`.
|
|
123
|
+
changes detected.` New surfaces are shown as new baselines and require approval;
|
|
124
|
+
coverage gaps are handled by `expected`.
|
|
125
|
+
|
|
126
|
+
## Auto-discovery
|
|
116
127
|
|
|
117
|
-
|
|
128
|
+
StyleProof's auto-discovery keeps the boring inventory out of your hands where it
|
|
129
|
+
can be inferred safely: Next.js routes, crawlable links, component files,
|
|
130
|
+
semantic popups, one-step variants, breakpoints, and volatile/live candidates.
|
|
131
|
+
The key loop is simple: discover what the app exposes, capture what is safe, and
|
|
132
|
+
fail loudly for anything that still needs an owner.
|
|
118
133
|
|
|
119
|
-
StyleProof diffs the surfaces your spec lists — so a page nobody
|
|
134
|
+
StyleProof diffs the surfaces your spec lists or discovers — so a page nobody
|
|
135
|
+
added to either set is invisible to the gate. Its change has no base capture
|
|
136
|
+
_and_ no head capture, so it never appears in any diff, and the status goes
|
|
137
|
+
green having never looked at it. This is the one thing the captures can't catch
|
|
138
|
+
on their own: a capture that was never taken.
|
|
120
139
|
|
|
121
140
|
Declare your app's route/view universe in `expected` and StyleProof emits a coverage-guard test in your **normal** suite (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It fails the moment a route exists with no surface, so a new page can't ship uncaptured:
|
|
122
141
|
|
|
@@ -258,9 +277,17 @@ styles. It also reports live-state candidates that need fixtures or opt-outs.
|
|
|
258
277
|
styleproof-variants --base-url http://localhost:3000 --route / --route settings=/settings
|
|
259
278
|
```
|
|
260
279
|
|
|
261
|
-
Use it as a manifest generator, not a replacement for review.
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
Use it as a manifest generator, not a replacement for review. To refresh that
|
|
281
|
+
manifest as part of the map loop, pass the same crawl inputs to `styleproof-map`;
|
|
282
|
+
it runs the crawler before Playwright captures the maps:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
styleproof-map --crawl-base-url http://localhost:3000 --crawl-route / --crawl-route settings=/settings
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The app must already be reachable at `--crawl-base-url`. If Playwright's
|
|
289
|
+
`webServer` is the thing starting the app, keep route-link crawling inside the
|
|
290
|
+
capture run with `defineCrawlCapture`.
|
|
264
291
|
|
|
265
292
|
```json
|
|
266
293
|
{
|
|
@@ -373,6 +400,36 @@ Omit `widths` on a surface and StyleProof reads your app's real `@media` breakpo
|
|
|
373
400
|
|
|
374
401
|
The same engine has a second mode that proves a change touched _nothing_ visual: with `fail-on-diff: true`, any difference at all fails the job. It's the job StyleProof was born for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical. Reach for it on any change whose whole promise is "the output is unchanged": a utility-class migration, a design-system swap, a dependency or build-tooling bump. Zero diff is the contract; one drifting longhand is a regression to investigate, not a change to approve.
|
|
375
402
|
|
|
403
|
+
## Match a design pixel-for-pixel
|
|
404
|
+
|
|
405
|
+
When you build a design in production, "looks the same" is a judgement call — and small gaps ship. `styleproof-capture` makes it an objective check: point it at the **design** (a deployed mockup, a static export, a standalone HTML file), point it at your **build**, and diff. Zero diff means the production UI renders _identically_ to the design; anything else is named exactly, down to the computed style, so you know precisely what's still off.
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
styleproof-capture https://example.com/pricing --key pricing --widths 1440,1024,768 --out design
|
|
409
|
+
styleproof-diff design .styleproof/maps/current # design vs build — zero diff = pixel-identical
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
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.
|
|
413
|
+
|
|
414
|
+
(`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`), the same shape any capture writes, so `styleproof-diff` compares it against anything. 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.
|
|
415
|
+
|
|
416
|
+
### Crawl the whole interactive design: `--crawl`
|
|
417
|
+
|
|
418
|
+
A design is mostly _behind clicks_ — modals, drawers, popovers, tabs that don't exist in the DOM until you open them. A single capture sees only the landing state. `--crawl` maps the rest for you: point it at the URL and it drives every non-destructive control, keeps whatever opens a structurally new surface, and recurses into it — a modal's tabs, a drawer's sub-views, a popover's panels — capturing each under a derived key. No spec, no selectors, no hand-holding.
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
styleproof-capture https://example.com --crawl --out design # maps every reachable surface
|
|
422
|
+
styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
It's **exhaustive by default**: the crawl stops when there is nothing left to drive — every control tried once, every structurally new surface captured — not at a budget. Termination is guaranteed by dedup (controls dedup by selector, surfaces by a structural fingerprint), and the `--max-depth` / `--max-actions` / `--max-states` flags exist only as deliberate throttles. It's deterministic (document order; the same surface reached two ways is captured once) and self-settling — it waits for an async app (React/Vue/Babel that boots after `load`) to mount before reading, so a bare crawl of a client-rendered page still captures the mounted UI.
|
|
426
|
+
|
|
427
|
+
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.
|
|
428
|
+
|
|
429
|
+
**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).
|
|
430
|
+
|
|
431
|
+
**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.
|
|
432
|
+
|
|
376
433
|
## Install
|
|
377
434
|
|
|
378
435
|
```bash
|
|
@@ -393,7 +450,7 @@ npx styleproof-init
|
|
|
393
450
|
It scaffolds:
|
|
394
451
|
|
|
395
452
|
- 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);
|
|
396
|
-
- a **`playwright.config.ts`** that builds and serves a **production build** (never a flaky dev server) and captures surfaces **in parallel** (`fullyParallel`);
|
|
453
|
+
- 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;
|
|
397
454
|
- `.gitignore` entries for `.styleproof/`, `test-results/`, and `playwright-report/`;
|
|
398
455
|
- 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.
|
|
399
456
|
|
|
@@ -596,7 +653,7 @@ When a PR **adds** an element, StyleProof now reports its **full resting compute
|
|
|
596
653
|
| `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
|
|
597
654
|
| `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
|
|
598
655
|
|
|
599
|
-
Outputs: `changed` (`"true"` when
|
|
656
|
+
Outputs: `changed` (`"true"` when any existing surface changed, or a new surface needs approval), `report-url`. Other inputs (`report-branch`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
|
|
600
657
|
|
|
601
658
|
**Policy file `styleproof.config.json`** (optional, at the repo root) — gate policy that isn't workflow plumbing:
|
|
602
659
|
|
|
@@ -637,22 +694,25 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
637
694
|
|
|
638
695
|
**Capture env vars** (wire CI without editing the spec):
|
|
639
696
|
|
|
640
|
-
| Env
|
|
641
|
-
|
|
|
642
|
-
| `STYLEMAP_DIR`
|
|
643
|
-
| `STYLEPROOF_BASEDIR`
|
|
644
|
-
| `STYLEPROOF_SCREENSHOTS`
|
|
645
|
-
| `STYLEPROOF_REPLAY_FROM`
|
|
646
|
-
| `STYLEPROOF_REPLAY_URL`
|
|
647
|
-
| `STYLEPROOF_SELFCHECK`
|
|
648
|
-
| `STYLEPROOF_UPLOAD`
|
|
649
|
-
| `STYLEPROOF_CACHE_BRANCH`
|
|
697
|
+
| Env | Purpose |
|
|
698
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
699
|
+
| `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
|
|
700
|
+
| `STYLEPROOF_BASEDIR` | Output root dir (runner default `__stylemaps__`; `styleproof-map` CLI default `.styleproof/maps`). |
|
|
701
|
+
| `STYLEPROOF_SCREENSHOTS` | `0` to skip full-page screenshots. The CLI keeps screenshots by default so reports can crop maps restored from cache. |
|
|
702
|
+
| `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from — set this on the **head** capture. |
|
|
703
|
+
| `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
|
|
704
|
+
| `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
|
|
705
|
+
| `STYLEPROOF_UPLOAD` | `1` to require map-store upload; `0` to capture locally only. |
|
|
706
|
+
| `STYLEPROOF_CACHE_BRANCH` | Map store branch (default `styleproof-maps`). |
|
|
707
|
+
| `STYLEPROOF_CRAWL_BASE_URL` | App URL for the optional pre-map `styleproof-variants` crawl. |
|
|
708
|
+
| `STYLEPROOF_CRAWL_ROUTES` | Comma-separated routes for the optional pre-map crawl, e.g. `/,settings=/settings`. |
|
|
709
|
+
| `STYLEPROOF_CRAWL_STRICT` | `1` to fail the optional pre-map crawl on live-state fixtures or skipped candidates. |
|
|
650
710
|
|
|
651
711
|
**CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
|
|
652
712
|
|
|
653
|
-
- `styleproof-init` — scaffold the gate: the capture spec, a `playwright.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default) instead of assuming
|
|
654
|
-
- `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 `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
|
|
655
|
-
- `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 diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
713
|
+
- `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. 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`.
|
|
714
|
+
- `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.
|
|
715
|
+
- `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 diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
656
716
|
- `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).
|
|
657
717
|
- `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.
|
|
658
718
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Capture a single URL's computed-style map — one shot, no spec, no config.
|
|
4
|
+
*
|
|
5
|
+
* styleproof-capture <url> [options]
|
|
6
|
+
*
|
|
7
|
+
* For a page you just want to point at: a deployed URL, a static export, or a
|
|
8
|
+
* standalone HTML mockup. Writes `<key>@<width>.json.gz` (+ `.png`) into --out,
|
|
9
|
+
* the same shape a surface capture writes, so `styleproof-diff <a> <b>` compares
|
|
10
|
+
* it against any other capture. Capture a design mockup once, then diff each
|
|
11
|
+
* build against it to measure how close the implementation is.
|
|
12
|
+
*
|
|
13
|
+
* (Use styleproof-map — the spec-driven flow — for your own app's surfaces, where
|
|
14
|
+
* you also want the coverage guard, the map store, and record/replay.)
|
|
15
|
+
*
|
|
16
|
+
* Exit code 0 = captured, 2 = usage error, 3 = capture failed (e.g. browser
|
|
17
|
+
* missing, page unreachable, cross-origin CSS with no explicit --widths).
|
|
18
|
+
*/
|
|
19
|
+
import { chromium } from '@playwright/test';
|
|
20
|
+
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
21
|
+
import { UsageError, parseCaptureUrlArgs, runCaptureUrl } from '../dist/capture-url.js';
|
|
22
|
+
import { crawlAndCapture } from '../dist/crawl-surfaces.js';
|
|
23
|
+
|
|
24
|
+
const COMMAND = 'styleproof-capture';
|
|
25
|
+
|
|
26
|
+
const HELP = `${COMMAND} — capture a page's computed-style map(s) (no spec, no config)
|
|
27
|
+
|
|
28
|
+
usage: ${COMMAND} <url> [options]
|
|
29
|
+
|
|
30
|
+
one state (default): capture the page as it loads
|
|
31
|
+
--key <name> capture file prefix, <key>@<width>.json.gz (default: page)
|
|
32
|
+
--wait <selector> wait for this selector to be visible before capturing
|
|
33
|
+
--widths <csv> viewport widths, e.g. 1440,1024,768. Omit to detect the
|
|
34
|
+
page's own @media breakpoints (fails on cross-origin CSS —
|
|
35
|
+
pass widths for those). Crawl defaults to 1280 when omitted.
|
|
36
|
+
|
|
37
|
+
whole surface: --crawl
|
|
38
|
+
--crawl EXHAUSTIVE: drive every non-destructive control, recurse into
|
|
39
|
+
what opens, and capture every discovered surface under a derived
|
|
40
|
+
key — runs to natural termination, no budget. For a design that's
|
|
41
|
+
mostly modals/drawers/popovers. Destructive-looking controls
|
|
42
|
+
(delete/deploy/pay/revoke…) are never clicked.
|
|
43
|
+
--require-full-coverage
|
|
44
|
+
exit 4 unless every class the page's stylesheets define was
|
|
45
|
+
rendered in a captured surface — the machine check that
|
|
46
|
+
NOTHING in the design was missed (coverage is always printed)
|
|
47
|
+
--max-depth <n> throttle recursion depth (default: unbounded)
|
|
48
|
+
--max-actions <n> throttle controls tried per state (default: unbounded)
|
|
49
|
+
--max-states <n> throttle total surfaces (default: unbounded)
|
|
50
|
+
--no-reset-storage don't clear localStorage between steps (default: clear)
|
|
51
|
+
|
|
52
|
+
common:
|
|
53
|
+
--out <dir> output directory (default: styleproof-capture)
|
|
54
|
+
--ignore <sel> skip a nondeterministic region (repeatable)
|
|
55
|
+
--height <px> viewport height (default: 800)
|
|
56
|
+
--no-screenshots write lean .json.gz maps only (screenshots on by default)
|
|
57
|
+
-h, --help show this help
|
|
58
|
+
|
|
59
|
+
Then diff against another capture — zero diff = pixel-identical:
|
|
60
|
+
${COMMAND} https://example.com --crawl --out design
|
|
61
|
+
styleproof-diff design .styleproof/maps/current
|
|
62
|
+
|
|
63
|
+
exit: 0 captured, 2 usage error, 3 capture failed, 4 coverage gap (--require-full-coverage).
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const argv = process.argv.slice(2);
|
|
67
|
+
if (isHelpArg(argv[0])) showHelpAndExit(HELP);
|
|
68
|
+
|
|
69
|
+
let opts;
|
|
70
|
+
try {
|
|
71
|
+
opts = parseCaptureUrlArgs(argv);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e instanceof UsageError) {
|
|
74
|
+
console.error(`${COMMAND}: ${e.message}\nNext: run ${COMMAND} --help to see supported options.`);
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runCrawl() {
|
|
81
|
+
const browser = await chromium.launch();
|
|
82
|
+
try {
|
|
83
|
+
const page = await browser.newPage();
|
|
84
|
+
const crawlOpts = {
|
|
85
|
+
url: opts.url,
|
|
86
|
+
out: opts.out,
|
|
87
|
+
widths: opts.widths.length ? opts.widths : [1280],
|
|
88
|
+
ignore: opts.ignore,
|
|
89
|
+
height: opts.height,
|
|
90
|
+
screenshots: opts.screenshots,
|
|
91
|
+
waitSelector: opts.waitSelector,
|
|
92
|
+
maxDepth: opts.maxDepth,
|
|
93
|
+
maxActionsPerState: opts.maxActionsPerState,
|
|
94
|
+
maxStates: opts.maxStates,
|
|
95
|
+
resetStorage: opts.resetStorage,
|
|
96
|
+
// Stream each surface as it is captured, so progress is visible live and an
|
|
97
|
+
// interrupted run still shows exactly what it mapped.
|
|
98
|
+
onSurface: (s, ok) =>
|
|
99
|
+
console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
|
|
100
|
+
};
|
|
101
|
+
const report = await crawlAndCapture(page, crawlOpts);
|
|
102
|
+
console.log(
|
|
103
|
+
`✓ ${report.captured}/${report.surfaces.length} surface(s) × ${crawlOpts.widths.length} width(s) → ${opts.out} ` +
|
|
104
|
+
`(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
|
|
105
|
+
);
|
|
106
|
+
const cov = report.coverage;
|
|
107
|
+
if (cov.missing.length === 0) {
|
|
108
|
+
console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
|
|
109
|
+
} else {
|
|
110
|
+
console.log(
|
|
111
|
+
`⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
|
|
112
|
+
`(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
|
|
113
|
+
);
|
|
114
|
+
if (opts.requireFullCoverage) process.exit(4);
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
await browser.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if (opts.crawl) {
|
|
123
|
+
await runCrawl();
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
const results = await runCaptureUrl(opts, () => chromium.launch());
|
|
127
|
+
for (const r of results) console.log(`captured ${r.map}${r.screenshot ? ` (+ ${r.screenshot})` : ''}`);
|
|
128
|
+
console.log(`✓ ${results.length} capture(s) → ${opts.out}`);
|
|
129
|
+
process.exit(0);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(`${COMMAND}: capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
132
|
+
console.error(
|
|
133
|
+
'Next: check the URL is reachable and run `npx playwright install chromium` if the browser is missing.',
|
|
134
|
+
);
|
|
135
|
+
process.exit(3);
|
|
136
|
+
}
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -181,7 +181,7 @@ console.log(
|
|
|
181
181
|
total === 0
|
|
182
182
|
? newSurfaces === 0
|
|
183
183
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
184
|
-
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare —
|
|
184
|
+
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
185
185
|
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
|
|
186
186
|
);
|
|
187
187
|
// 0 = identical, 1 = reviewable differences, 3 = only new surfaces (no baseline,
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
* the `expected` coverage guard from the app's routes at run time, so a page
|
|
12
12
|
* added later can't ship without a surface; otherwise it writes one sample
|
|
13
13
|
* surface plus a commented guard block to wire to your own route registry.
|
|
14
|
-
* - playwright.config.ts:
|
|
15
|
-
* Playwright
|
|
14
|
+
* - playwright.styleproof.config.ts: a dedicated production-build Playwright
|
|
15
|
+
* config for StyleProof captures, so an existing app Playwright config is
|
|
16
|
+
* never disturbed or accidentally reused.
|
|
16
17
|
* - .github/workflows/styleproof.yml: restores reusable maps from the
|
|
17
18
|
* styleproof-maps branch and only captures in CI when the maps are missing.
|
|
18
19
|
*
|
|
19
20
|
* Idempotent: re-running never overwrites an existing spec (use --force) and
|
|
20
|
-
* never touches an existing playwright.config.ts. Exit 0 = done (or nothing
|
|
21
|
-
* do), 2 = usage error.
|
|
21
|
+
* never touches an existing app playwright.config.ts. Exit 0 = done (or nothing
|
|
22
|
+
* to do), 2 = usage error.
|
|
22
23
|
*/
|
|
23
24
|
import fs from 'node:fs';
|
|
24
25
|
import path from 'node:path';
|
|
@@ -31,7 +32,7 @@ usage: styleproof-init [options]
|
|
|
31
32
|
|
|
32
33
|
options:
|
|
33
34
|
--dir <path> spec output path (default: e2e/styleproof.spec.ts)
|
|
34
|
-
--base-url <url> baseURL for a generated playwright.config.ts
|
|
35
|
+
--base-url <url> baseURL for a generated playwright.styleproof.config.ts
|
|
35
36
|
(default: http://localhost:3000)
|
|
36
37
|
--force overwrite the spec if it already exists
|
|
37
38
|
-h, --help show this help
|
|
@@ -41,7 +42,7 @@ What it writes:
|
|
|
41
42
|
In a Next.js app it discovers your routes at run time and wires both the
|
|
42
43
|
surfaces and the \`expected\` coverage guard to them, so a new page can't ship
|
|
43
44
|
uncaptured. Otherwise it writes one sample surface + a commented guard block.
|
|
44
|
-
- playwright.config.ts,
|
|
45
|
+
- playwright.styleproof.config.ts, a dedicated production-build Playwright config
|
|
45
46
|
- .github/workflows/styleproof.yml, a cache-first PR report workflow
|
|
46
47
|
|
|
47
48
|
After running, build and upload this commit's map outside CI when possible:
|
|
@@ -237,14 +238,15 @@ const PACKAGE_MANAGERS = {
|
|
|
237
238
|
},
|
|
238
239
|
pnpm: {
|
|
239
240
|
label: 'pnpm',
|
|
240
|
-
run: (script) => `
|
|
241
|
-
exec: (command) => `
|
|
242
|
-
install: '
|
|
241
|
+
run: (script) => `pnpm run ${script}`,
|
|
242
|
+
exec: (command) => `pnpm exec ${command}`,
|
|
243
|
+
install: 'pnpm install --frozen-lockfile',
|
|
243
244
|
setup: ` - uses: actions/setup-node@v4
|
|
244
245
|
with:
|
|
245
246
|
node-version: '20'
|
|
246
247
|
cache: pnpm
|
|
247
|
-
cache-dependency-path: pnpm-lock.yaml
|
|
248
|
+
cache-dependency-path: pnpm-lock.yaml
|
|
249
|
+
- run: corepack enable`,
|
|
248
250
|
},
|
|
249
251
|
bun: {
|
|
250
252
|
label: 'Bun',
|
|
@@ -269,6 +271,57 @@ function detectPackageManager(root) {
|
|
|
269
271
|
|
|
270
272
|
const PM = detectPackageManager(process.cwd());
|
|
271
273
|
|
|
274
|
+
function readPackageJson(root) {
|
|
275
|
+
try {
|
|
276
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
277
|
+
} catch {
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function hasDep(pkg, name) {
|
|
283
|
+
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function scriptIncludes(pkg, script, text) {
|
|
287
|
+
return typeof pkg.scripts?.[script] === 'string' && pkg.scripts[script].includes(text);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function portFromBaseUrl(url) {
|
|
291
|
+
try {
|
|
292
|
+
const parsed = new URL(url);
|
|
293
|
+
if (parsed.port) return parsed.port;
|
|
294
|
+
return parsed.protocol === 'https:' ? '443' : '80';
|
|
295
|
+
} catch {
|
|
296
|
+
return '3000';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function productionServerCommand(root, base) {
|
|
301
|
+
const pkg = readPackageJson(root);
|
|
302
|
+
const port = portFromBaseUrl(base);
|
|
303
|
+
const build = pkg.scripts?.build ? `${PM.run('build')} && ` : '';
|
|
304
|
+
const looksLikeVite =
|
|
305
|
+
hasDep(pkg, 'vite') || scriptIncludes(pkg, 'dev', 'vite') || scriptIncludes(pkg, 'build', 'vite');
|
|
306
|
+
const looksLikeNext =
|
|
307
|
+
hasDep(pkg, 'next') || scriptIncludes(pkg, 'dev', 'next') || scriptIncludes(pkg, 'build', 'next');
|
|
308
|
+
|
|
309
|
+
if (looksLikeVite) return `${build}${PM.exec(`vite preview --host 127.0.0.1 --port ${port}`)}`;
|
|
310
|
+
if (looksLikeNext) {
|
|
311
|
+
const start = pkg.scripts?.start ? PM.run('start') : PM.exec(`next start -p ${port}`);
|
|
312
|
+
return `${build}${start}`;
|
|
313
|
+
}
|
|
314
|
+
if (pkg.scripts?.start) return `${build}${PM.run('start')}`;
|
|
315
|
+
if (pkg.scripts?.preview) return `${build}${PM.run('preview')}`;
|
|
316
|
+
return `${PM.run('build')} && ${PM.run('start')}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function configTestDir(spec) {
|
|
320
|
+
const dir = path.dirname(path.resolve(process.cwd(), spec));
|
|
321
|
+
const rel = path.relative(process.cwd(), dir).replace(/\\/g, '/');
|
|
322
|
+
return rel ? `./${rel}` : '.';
|
|
323
|
+
}
|
|
324
|
+
|
|
272
325
|
const CONFIG = `import { defineConfig, devices } from '@playwright/test';
|
|
273
326
|
|
|
274
327
|
// Generated by styleproof-init.
|
|
@@ -280,6 +333,8 @@ const CONFIG = `import { defineConfig, devices } from '@playwright/test';
|
|
|
280
333
|
// app serves precompiled routes at consistent timing. (StyleProof's settle waits for
|
|
281
334
|
// in-flight data either way, but a production build removes the variance at the source.)
|
|
282
335
|
export default defineConfig({
|
|
336
|
+
testDir: ${JSON.stringify(configTestDir(specPath))},
|
|
337
|
+
testMatch: ${JSON.stringify(path.basename(specPath))},
|
|
283
338
|
timeout: 120_000,
|
|
284
339
|
// Capture surfaces in PARALLEL. StyleProof generates one test per surface × width,
|
|
285
340
|
// each an isolated page writing a uniquely-keyed file (\`<key>@<width>.json.gz\`), with
|
|
@@ -293,11 +348,13 @@ export default defineConfig({
|
|
|
293
348
|
baseURL: process.env.BASE_URL || '${baseUrl}',
|
|
294
349
|
},
|
|
295
350
|
// Build once, then serve THAT production build for the captures — so you can't
|
|
296
|
-
// accidentally capture a dev server.
|
|
297
|
-
//
|
|
351
|
+
// accidentally capture a dev server. styleproof-init detected the production
|
|
352
|
+
// serve command from your package scripts/dependencies; tune it here if your
|
|
353
|
+
// framework needs a custom preview command.
|
|
298
354
|
webServer: {
|
|
299
|
-
command: '${
|
|
355
|
+
command: '${productionServerCommand(process.cwd(), baseUrl)}',
|
|
300
356
|
url: process.env.BASE_URL || '${baseUrl}',
|
|
357
|
+
env: { PORT: '${portFromBaseUrl(baseUrl)}' },
|
|
301
358
|
reuseExistingServer: !process.env.CI,
|
|
302
359
|
timeout: 600_000, // a cold production build can take a few minutes
|
|
303
360
|
},
|
|
@@ -422,13 +479,18 @@ if (spec.wrote) {
|
|
|
422
479
|
console.log(`${specPath} already exists — left untouched (use --force to overwrite)`);
|
|
423
480
|
}
|
|
424
481
|
|
|
425
|
-
const configPath = 'playwright.config.ts';
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
fs.writeFileSync(configPath, CONFIG);
|
|
430
|
-
console.log(`created ${configPath} (baseURL ${baseUrl})`);
|
|
482
|
+
const configPath = 'playwright.styleproof.config.ts';
|
|
483
|
+
const config = writeFileSafe(configPath, CONFIG, { force });
|
|
484
|
+
if (config.wrote) {
|
|
485
|
+
console.log(`${config.exists ? 'overwrote' : 'created'} ${configPath} (dedicated StyleProof capture config)`);
|
|
431
486
|
wroteSomething = true;
|
|
487
|
+
} else {
|
|
488
|
+
console.log(`${configPath} already exists — left untouched (use --force to overwrite)`);
|
|
489
|
+
}
|
|
490
|
+
if (fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js')) {
|
|
491
|
+
console.log(
|
|
492
|
+
'app playwright.config exists — left untouched; styleproof-map uses playwright.styleproof.config.ts by default',
|
|
493
|
+
);
|
|
432
494
|
}
|
|
433
495
|
|
|
434
496
|
const ignored = ['.styleproof/', 'test-results/', 'playwright-report/'].filter((line) => ensureGitignoreLine(line));
|