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 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.1.5...HEAD
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
- - [Don't let a new page ship uncaptured](#dont-let-a-new-page-ship-uncaptured)
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; coverage gaps are
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
- ## Don't let a new page ship uncaptured
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 added to the list is invisible to the gate. Its change has no base capture _and_ no head capture, so it never appears in any diff, and the status goes green having never looked at it. This is the one thing the captures can't catch on their own: a capture that was never taken.
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. The generated JSON
262
- is deliberately not read by `styleproof-map`; approve the states you want, then
263
- copy them into your capture spec as ordinary `variants` / `liveStates`.
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 anything changed), `report-url`. Other inputs (`report-branch`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
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 | Purpose |
641
- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
642
- | `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
643
- | `STYLEPROOF_BASEDIR` | Output root dir (runner default `__stylemaps__`; `styleproof-map` CLI default `.styleproof/maps`). |
644
- | `STYLEPROOF_SCREENSHOTS` | `0` to skip full-page screenshots. The CLI keeps screenshots by default so reports can crop maps restored from cache. |
645
- | `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from — set this on the **head** capture. |
646
- | `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
647
- | `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
648
- | `STYLEPROOF_UPLOAD` | `1` to require map-store upload; `0` to capture locally only. |
649
- | `STYLEPROOF_CACHE_BRANCH` | Map store branch (default `styleproof-maps`). |
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 npm.
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
+ }
@@ -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 — shown for reference, no reviewable change`
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,
@@ -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: only if one does not already exist, so an existing
15
- * Playwright project is never disturbed.
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 to
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, only if absent (an existing one is left untouched)
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) => `npx -y pnpm ${script}`,
241
- exec: (command) => `npx -y pnpm exec ${command}`,
242
- install: 'npx -y pnpm install --frozen-lockfile',
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. Adjust the command to your framework (e.g.
297
- // \`vite build && vite preview\`); a server already running locally is reused.
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: '${PM.run('build')} && ${PM.run('start')}',
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
- if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
427
- console.log('playwright.config exists — left untouched; ensure its baseURL points at a production build');
428
- } else {
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));