styleproof 3.2.0 → 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,42 @@ 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
+
10
46
  ## [3.2.0] - 2026-06-30
11
47
 
12
48
  ### Added
@@ -72,6 +108,13 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
72
108
 
73
109
  ## [3.1.4] - 2026-06-29
74
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
+
75
118
  ### Fixed
76
119
 
77
120
  - **Popup capture no longer collapses distinct triggers that reuse the same
package/README.md CHANGED
@@ -21,6 +21,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
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,7 +53,9 @@ 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
 
@@ -117,8 +120,8 @@ full report. The report groups each distinct visual change with:
117
120
 
118
121
  In review-gate mode, one **Approve all changes** checkbox turns the `StyleProof`
119
122
  status green for that commit. Clean runs still leave a receipt: `No visual
120
- changes detected.` New surfaces are shown as new baselines; coverage gaps are
121
- handled by `expected`.
123
+ changes detected.` New surfaces are shown as new baselines and require approval;
124
+ coverage gaps are handled by `expected`.
122
125
 
123
126
  ## Auto-discovery
124
127
 
@@ -397,6 +400,36 @@ Omit `widths` on a surface and StyleProof reads your app's real `@media` breakpo
397
400
 
398
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.
399
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
+
400
433
  ## Install
401
434
 
402
435
  ```bash
@@ -620,7 +653,7 @@ When a PR **adds** an element, StyleProof now reports its **full resting compute
620
653
  | `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
621
654
  | `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
622
655
 
623
- 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).
624
657
 
625
658
  **Policy file `styleproof.config.json`** (optional, at the repo root) — gate policy that isn't workflow plumbing:
626
659
 
@@ -679,7 +712,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
679
712
 
680
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`.
681
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.
682
- - `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`.
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`.
683
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).
684
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.
685
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,
@@ -36,8 +36,8 @@ options:
36
36
  --remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
37
37
  --out <dir> output directory (default: styleproof-report)
38
38
  --image-base-url <url> prefix for image URLs in report.md (default: relative)
39
- --pad <px> padding around changed rects when cropping (default: 24)
40
- --max-crops <n> max crop regions per surface before collapsing (default: 6)
39
+ --pad <px> padding around changed rects when cropping (default: 12)
40
+ --max-crops <n> max crop regions per surface before collapsing (default: 8)
41
41
  --fold-details-at <n> row count at which a crop's property tables fold under a
42
42
  <details> toggle (default: 0 = always; 'Infinity' = never)
43
43
  --min-width <px> minimum crop width, for context (default: 320)
@@ -178,7 +178,7 @@ console.log(
178
178
  result.changedSurfaces === 0
179
179
  ? result.newSurfaces === 0
180
180
  ? '✓ no changes — empty report written'
181
- : `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for reference`
181
+ : `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for review`
182
182
  : `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
183
183
  );
184
184
  console.log(`report: ${result.reportMdPath}`);
@@ -0,0 +1,80 @@
1
+ import type { Browser, Page } from '@playwright/test';
2
+ /**
3
+ * One-shot capture of a single URL's computed-style map — no spec, no config,
4
+ * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
5
+ * your own app and you want the coverage guard, the map store, and record/replay.
6
+ * This is the tool for a page you just want to point at: a deployed URL, a
7
+ * static export, or a standalone HTML mockup. Capture it into a directory of
8
+ * `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
9
+ * `styleproof-diff <a> <b>` — compares like any other capture.
10
+ *
11
+ * The output is deliberately the same shape a surface capture writes, so a
12
+ * mockup's map and your app's committed map are directly diffable: capture the
13
+ * design once, then diff each build against it to measure how close the
14
+ * implementation is (a diff that shrinks toward zero as it converges).
15
+ */
16
+ /** Raised for bad CLI usage so the bin can print help and exit 2. */
17
+ export declare class UsageError extends Error {
18
+ }
19
+ export type CaptureUrlOptions = {
20
+ /** Page to capture. */
21
+ url: string;
22
+ /** Capture file name prefix (`<key>@<width>.json.gz`); default `page`. */
23
+ key: string;
24
+ /**
25
+ * Viewport widths to sweep, one per @media band. Empty = auto-detect from the
26
+ * loaded CSSOM (fails loudly on a cross-origin/unreadable stylesheet — pass
27
+ * widths explicitly for a page whose CSS can't be read, e.g. a cross-origin
28
+ * font stylesheet).
29
+ */
30
+ widths: number[];
31
+ /** Output directory for the maps (+ screenshots). */
32
+ out: string;
33
+ /** Selectors for nondeterministic regions to skip (passed through to capture). */
34
+ ignore: string[];
35
+ /** Wait for this selector to be visible before capturing (reach the intended state). */
36
+ waitSelector?: string;
37
+ /** Viewport height (default 800). */
38
+ height: number;
39
+ /** Also write a full-page `.png` per capture (default true). */
40
+ screenshots: boolean;
41
+ /**
42
+ * Crawl the URL's whole interactive surface instead of capturing one state:
43
+ * drive every non-destructive control, recurse into what opens, capture each
44
+ * discovered surface under a derived key. See {@link crawlAndCapture}.
45
+ */
46
+ crawl: boolean;
47
+ /** crawl: recursion depth into opened surfaces (default 3). */
48
+ maxDepth: number;
49
+ /** crawl: fresh controls driven per state (default: unbounded — try them all). */
50
+ maxActionsPerState: number;
51
+ /** crawl: safety backstop on total surfaces (default: unbounded — exhaustive). */
52
+ maxStates: number;
53
+ /** crawl: clear storage on each reset so replay is deterministic (default true). */
54
+ resetStorage: boolean;
55
+ /** crawl: exit non-zero unless every class the page's stylesheets define was
56
+ * rendered in at least one captured surface (default false — report only). */
57
+ requireFullCoverage: boolean;
58
+ };
59
+ /**
60
+ * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
61
+ * flow (help/exit codes) and this parse are testable without a browser.
62
+ */
63
+ export declare function parseCaptureUrlArgs(argv: string[]): CaptureUrlOptions;
64
+ /** Written artifacts for one width. */
65
+ export type CaptureUrlResult = {
66
+ width: number;
67
+ map: string;
68
+ screenshot?: string;
69
+ };
70
+ /**
71
+ * Capture `opts.url` at each width using an already-open {@link Page}, writing
72
+ * `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
73
+ * width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
74
+ * arms the in-flight request tracker before each navigation so the page's own
75
+ * load fetches count toward the network-aware settle — same contract as a
76
+ * surface capture. Returns the files written.
77
+ */
78
+ export declare function captureUrlToDir(page: Page, opts: CaptureUrlOptions): Promise<CaptureUrlResult[]>;
79
+ /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
80
+ export declare function runCaptureUrl(opts: CaptureUrlOptions, launch: () => Promise<Browser>): Promise<CaptureUrlResult[]>;
@@ -0,0 +1,185 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
4
+ import { detectViewportWidths } from './breakpoints.js';
5
+ /**
6
+ * One-shot capture of a single URL's computed-style map — no spec, no config,
7
+ * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
8
+ * your own app and you want the coverage guard, the map store, and record/replay.
9
+ * This is the tool for a page you just want to point at: a deployed URL, a
10
+ * static export, or a standalone HTML mockup. Capture it into a directory of
11
+ * `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
12
+ * `styleproof-diff <a> <b>` — compares like any other capture.
13
+ *
14
+ * The output is deliberately the same shape a surface capture writes, so a
15
+ * mockup's map and your app's committed map are directly diffable: capture the
16
+ * design once, then diff each build against it to measure how close the
17
+ * implementation is (a diff that shrinks toward zero as it converges).
18
+ */
19
+ /** Raised for bad CLI usage so the bin can print help and exit 2. */
20
+ export class UsageError extends Error {
21
+ }
22
+ const DEFAULTS = {
23
+ key: 'page',
24
+ height: 800,
25
+ screenshots: true,
26
+ crawl: false,
27
+ // Exhaustive by default — these are safety backstops, not budgets.
28
+ maxDepth: 1000,
29
+ maxActionsPerState: 100000,
30
+ maxStates: 100000,
31
+ resetStorage: true,
32
+ requireFullCoverage: false,
33
+ };
34
+ function positiveNumber(raw, flag) {
35
+ const n = Number(raw);
36
+ if (!Number.isFinite(n) || n <= 0)
37
+ throw new UsageError(`${flag}: not a positive number: ${raw}`);
38
+ return n;
39
+ }
40
+ function parseWidths(raw) {
41
+ const widths = raw
42
+ .split(',')
43
+ .map((s) => s.trim())
44
+ .filter(Boolean)
45
+ .map((s) => positiveNumber(s, '--widths'));
46
+ if (widths.length === 0)
47
+ throw new UsageError('--widths: no widths given');
48
+ return widths;
49
+ }
50
+ // Table-driven so adding a flag is one entry, not another branch in the loop.
51
+ // value flags mutate the accumulator with their argument; bool flags take none.
52
+ const VALUE_FLAGS = {
53
+ '--key': (o, v) => (o.key = v),
54
+ '--widths': (o, v) => (o.widths = parseWidths(v)),
55
+ '--out': (o, v) => (o.out = v),
56
+ '--ignore': (o, v) => o.ignore.push(v),
57
+ '--wait': (o, v) => (o.waitSelector = v),
58
+ '--height': (o, v) => (o.height = positiveNumber(v, '--height')),
59
+ '--max-depth': (o, v) => (o.maxDepth = positiveNumber(v, '--max-depth')),
60
+ '--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
61
+ '--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
62
+ };
63
+ const BOOL_FLAGS = {
64
+ '--screenshots': (o) => (o.screenshots = true),
65
+ '--no-screenshots': (o) => (o.screenshots = false),
66
+ '--crawl': (o) => (o.crawl = true),
67
+ '--no-reset-storage': (o) => (o.resetStorage = false),
68
+ '--require-full-coverage': (o) => (o.requireFullCoverage = true),
69
+ };
70
+ // Apply one argv token to the accumulator; returns the index to resume from
71
+ // (advanced past a consumed `--flag value` pair). Flat early-returns so the
72
+ // parse loop stays trivial. Supports `--flag value` and `--flag=value`.
73
+ function applyArg(o, argv, i, positional) {
74
+ const a = argv[i];
75
+ const eq = a.startsWith('--') ? a.indexOf('=') : -1;
76
+ const name = eq === -1 ? a : a.slice(0, eq);
77
+ const bool = BOOL_FLAGS[name];
78
+ if (bool) {
79
+ bool(o);
80
+ return i;
81
+ }
82
+ const apply = VALUE_FLAGS[name];
83
+ if (apply) {
84
+ const v = eq === -1 ? argv[i + 1] : a.slice(eq + 1);
85
+ if (v === undefined)
86
+ throw new UsageError(`${name}: missing value`);
87
+ apply(o, v);
88
+ return eq === -1 ? i + 1 : i;
89
+ }
90
+ if (a.startsWith('--'))
91
+ throw new UsageError(`unknown flag: ${a}`);
92
+ positional.push(a);
93
+ return i;
94
+ }
95
+ /**
96
+ * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
97
+ * flow (help/exit codes) and this parse are testable without a browser.
98
+ */
99
+ export function parseCaptureUrlArgs(argv) {
100
+ const o = {
101
+ url: '',
102
+ key: DEFAULTS.key,
103
+ widths: [],
104
+ out: 'styleproof-capture',
105
+ ignore: [],
106
+ waitSelector: undefined,
107
+ height: DEFAULTS.height,
108
+ screenshots: DEFAULTS.screenshots,
109
+ crawl: DEFAULTS.crawl,
110
+ maxDepth: DEFAULTS.maxDepth,
111
+ maxActionsPerState: DEFAULTS.maxActionsPerState,
112
+ maxStates: DEFAULTS.maxStates,
113
+ resetStorage: DEFAULTS.resetStorage,
114
+ requireFullCoverage: DEFAULTS.requireFullCoverage,
115
+ };
116
+ const positional = [];
117
+ for (let i = 0; i < argv.length; i++)
118
+ i = applyArg(o, argv, i, positional);
119
+ if (positional.length === 0)
120
+ throw new UsageError('missing <url>');
121
+ if (positional.length > 1)
122
+ throw new UsageError(`expected one <url>, got ${positional.length}`);
123
+ o.url = positional[0];
124
+ return o;
125
+ }
126
+ /**
127
+ * Capture `opts.url` at each width using an already-open {@link Page}, writing
128
+ * `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
129
+ * width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
130
+ * arms the in-flight request tracker before each navigation so the page's own
131
+ * load fetches count toward the network-aware settle — same contract as a
132
+ * surface capture. Returns the files written.
133
+ */
134
+ export async function captureUrlToDir(page, opts) {
135
+ fs.mkdirSync(opts.out, { recursive: true });
136
+ let widths = opts.widths;
137
+ if (widths.length === 0) {
138
+ await page.setViewportSize({ width: 1280, height: opts.height });
139
+ await page.goto(opts.url, { waitUntil: 'load' });
140
+ if (opts.waitSelector)
141
+ await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
142
+ widths = await detectViewportWidths(page);
143
+ }
144
+ const results = [];
145
+ for (const width of widths) {
146
+ await page.setViewportSize({ width, height: opts.height });
147
+ const requests = trackInflightRequests(page);
148
+ try {
149
+ await page.goto(opts.url, { waitUntil: 'load' });
150
+ if (opts.waitSelector)
151
+ await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
152
+ const map = await captureStyleMap(page, {
153
+ ignore: opts.ignore,
154
+ pendingRequests: requests.pending,
155
+ metadata: { surfaceKey: opts.key },
156
+ });
157
+ const stem = path.join(opts.out, `${opts.key}@${width}`);
158
+ const mapPath = `${stem}.json.gz`;
159
+ saveStyleMap(mapPath, map);
160
+ const result = { width, map: mapPath };
161
+ if (opts.screenshots) {
162
+ const shot = `${stem}.png`;
163
+ // captureStyleMap froze animations, so the shot matches the mapped state.
164
+ await page.screenshot({ path: shot, fullPage: true, animations: 'disabled' });
165
+ result.screenshot = shot;
166
+ }
167
+ results.push(result);
168
+ }
169
+ finally {
170
+ requests.dispose();
171
+ }
172
+ }
173
+ return results;
174
+ }
175
+ /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
176
+ export async function runCaptureUrl(opts, launch) {
177
+ const browser = await launch();
178
+ try {
179
+ const page = await browser.newPage();
180
+ return await captureUrlToDir(page, opts);
181
+ }
182
+ finally {
183
+ await browser.close();
184
+ }
185
+ }
@@ -0,0 +1,96 @@
1
+ import type { Page } from '@playwright/test';
2
+ /**
3
+ * Surface crawler: deterministically map a single URL's WHOLE interactive
4
+ * surface — not just the state it loads in — and run to natural termination.
5
+ *
6
+ * A one-shot capture records the page as it lands, so a design that's mostly
7
+ * modals, drawers, and popovers has most of its surface behind clicks it never
8
+ * reaches. This crawls it instead: from the base state it finds every visible,
9
+ * non-destructive control (semantic controls AND anything the page styles as
10
+ * clickable — `cursor: pointer`) and drives each one, keeping whatever opens a
11
+ * structurally new surface. It then recurses depth-first into each new surface,
12
+ * so a modal's tabs, a drawer's sub-views, and a popover's panels are all mapped.
13
+ *
14
+ * EXHAUSTIVE by default: the crawl stops when the queue drains and every control
15
+ * has been driven once — not at a budget. Termination is guaranteed by dedup
16
+ * (a control is driven once by selector; a structural surface is captured once;
17
+ * both sets are finite). The `--max-*` options exist only as throttles.
18
+ *
19
+ * The sweep is IN-PLACE with lazy resets, which is what makes exhaustive
20
+ * affordable: standing in a state, each candidate is clicked where the page
21
+ * already is, and a cheap DOM fingerprint (tag+class in document order — no
22
+ * computed styles) decides what happened. A no-op click costs nothing; only a
23
+ * state-changing click pays a reset (fresh navigation + path replay), and every
24
+ * reset is VERIFIED against the state's fingerprint before the sweep continues,
25
+ * so children are never attributed to the wrong parent. New surfaces are
26
+ * captured in place the moment they're reached — a deep or animated click-path
27
+ * is never re-driven to capture, so it can't be the thing that drops a surface.
28
+ *
29
+ * Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked —
30
+ * mapping must not mutate. States gated behind such an action need a spec.
31
+ */
32
+ export type CrawlStep = {
33
+ action: 'click' | 'select-option';
34
+ selector: string;
35
+ label: string;
36
+ reason: string;
37
+ value?: string;
38
+ };
39
+ export type CrawledSurface = {
40
+ key: string;
41
+ depth: number;
42
+ path: CrawlStep[];
43
+ elements: number;
44
+ };
45
+ /** Did the crawl SEE everything the design styles? `missing` lists classes the
46
+ * page's own stylesheets select on that never appeared in any captured surface —
47
+ * dead CSS, or a state the crawl could not reach. Empty missing = full coverage. */
48
+ export type CrawlCoverage = {
49
+ defined: number;
50
+ rendered: number;
51
+ missing: string[];
52
+ };
53
+ export type CrawlReport = {
54
+ surfaces: CrawledSurface[];
55
+ actionsTried: number;
56
+ skipped: number;
57
+ /** Surfaces successfully captured to disk at every width. */
58
+ captured: number;
59
+ /** Keys of surfaces discovered but whose full capture failed. */
60
+ failed: string[];
61
+ coverage: CrawlCoverage;
62
+ };
63
+ export type SurfaceCrawlOptions = {
64
+ url: string;
65
+ out: string;
66
+ widths: number[];
67
+ ignore: string[];
68
+ height: number;
69
+ screenshots: boolean;
70
+ waitSelector?: string;
71
+ /** Throttle: recursion depth into opened surfaces (base = 0). Default: unbounded. */
72
+ maxDepth: number;
73
+ /** Throttle: fresh controls driven per state. Default: unbounded — try them all. */
74
+ maxActionsPerState: number;
75
+ /** Throttle: total surfaces. Default: unbounded — run to natural termination. */
76
+ maxStates: number;
77
+ /** Clear localStorage/sessionStorage on each reset so replay is deterministic. Default true. */
78
+ resetStorage: boolean;
79
+ /** Called as each surface is recorded (captured=false when its full capture failed).
80
+ * Lets CLIs stream progress instead of reporting only at the end. */
81
+ onSurface?: (surface: CrawledSurface, captured: boolean) => void;
82
+ };
83
+ export declare const CRAWL_DEFAULTS: {
84
+ height: number;
85
+ screenshots: boolean;
86
+ maxDepth: number;
87
+ maxActionsPerState: number;
88
+ maxStates: number;
89
+ resetStorage: boolean;
90
+ };
91
+ /**
92
+ * Crawl `opts.url` and capture every reachable surface at every width — runs to
93
+ * natural termination by default. Returns the surfaces mapped (with the click-path
94
+ * that reached each), how many actions were tried/skipped, and captured/failed.
95
+ */
96
+ export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;
@@ -0,0 +1,500 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
5
+ // Exhaustive by default — these ceilings are safety backstops, not budgets.
6
+ export const CRAWL_DEFAULTS = {
7
+ height: 900,
8
+ screenshots: true,
9
+ maxDepth: 1000,
10
+ maxActionsPerState: 100000,
11
+ maxStates: 100000,
12
+ resetStorage: true,
13
+ };
14
+ function slug(value) {
15
+ return (value
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-+|-+$/g, '')
19
+ .slice(0, 24) || 'state');
20
+ }
21
+ function pathAndSearch(url) {
22
+ try {
23
+ const u = new URL(url);
24
+ return u.pathname + u.search;
25
+ }
26
+ catch {
27
+ return url;
28
+ }
29
+ }
30
+ function deriveKey(steps, used) {
31
+ const base = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
32
+ let key = base;
33
+ for (let i = 2; used.has(key); i++)
34
+ key = `${base}-${i}`;
35
+ used.add(key);
36
+ return key;
37
+ }
38
+ /** Runs in the browser: every visible, enabled, non-navigating control worth trying. */
39
+ /* c8 ignore start */ // fallow-ignore-next-line complexity
40
+ function collectClickable() {
41
+ const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
42
+ const DANGER = /\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop)\b/i;
43
+ const esc = (v) => CSS.escape(v);
44
+ const quote = (v) => JSON.stringify(v);
45
+ const visible = (el) => {
46
+ const b = el.getBoundingClientRect();
47
+ const cs = getComputedStyle(el);
48
+ return (b.width > 0 && b.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && cs.pointerEvents !== 'none');
49
+ };
50
+ const unique = (s) => document.querySelectorAll(s).length === 1;
51
+ const pathSelector = (el) => {
52
+ const parts = [];
53
+ let cur = el;
54
+ while (cur && cur !== document.documentElement) {
55
+ let i = 1;
56
+ for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
57
+ if (sib.tagName === cur.tagName)
58
+ i++;
59
+ parts.unshift(`${cur.tagName.toLowerCase()}:nth-of-type(${i})`);
60
+ cur = cur.parentElement;
61
+ }
62
+ return parts.join(' > ');
63
+ };
64
+ const selectorFor = (el) => {
65
+ const id = el.getAttribute('id');
66
+ if (id && unique(`#${esc(id)}`))
67
+ return `#${esc(id)}`;
68
+ for (const attr of ['data-testid', 'data-test', 'aria-label', 'name']) {
69
+ const v = el.getAttribute(attr);
70
+ if (v && unique(`${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`))
71
+ return `${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`;
72
+ }
73
+ return pathSelector(el);
74
+ };
75
+ const labelFor = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '')
76
+ .replace(/\s+/g, ' ')
77
+ .trim()
78
+ .slice(0, 80) || el.tagName.toLowerCase();
79
+ // Semantic controls first (stable, meaningful), then anything styled clickable.
80
+ // `grab` counts too: a draggable card is routinely ALSO a click target (open on
81
+ // click, drag to move), and we never drag — el.click() fires no drag gesture.
82
+ const pool = new Set([...document.querySelectorAll(SEMANTIC)]);
83
+ for (const el of document.querySelectorAll('body *')) {
84
+ if (pool.has(el))
85
+ continue;
86
+ const cursor = getComputedStyle(el).cursor;
87
+ if (cursor === 'pointer' || cursor === 'grab')
88
+ pool.add(el);
89
+ }
90
+ const seen = new Set();
91
+ const out = [];
92
+ for (const el of pool) {
93
+ if (el instanceof HTMLAnchorElement && el.href)
94
+ continue; // links navigate — handled by link crawl, not here
95
+ if (el.closest(':disabled,[aria-disabled="true"]'))
96
+ continue;
97
+ if (!visible(el))
98
+ continue;
99
+ const selector = selectorFor(el);
100
+ if (seen.has(selector))
101
+ continue;
102
+ seen.add(selector);
103
+ const label = labelFor(el);
104
+ const unsafe = DANGER.test(label);
105
+ if (el instanceof HTMLSelectElement) {
106
+ const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
107
+ if (next)
108
+ out.push({ action: 'select-option', selector, label, reason: 'select-option', value: next.value, unsafe });
109
+ }
110
+ else {
111
+ out.push({
112
+ action: 'click',
113
+ selector,
114
+ label,
115
+ reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
116
+ unsafe,
117
+ });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /* c8 ignore stop */
123
+ /** Runs in the browser: the DOM's structural shape — every element's tag + class
124
+ * in document order (scripts/styles/meta skipped). Text and computed styles are
125
+ * deliberately excluded, so a ticking clock or animated opacity never reads as a
126
+ * new state, while any mount/unmount/class flip does. ~30ms vs the full
127
+ * computed-style walk it replaces for change detection. */
128
+ /* c8 ignore start */
129
+ function domShape() {
130
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'META', 'LINK', 'NOSCRIPT', 'TEMPLATE']);
131
+ const parts = [];
132
+ const classes = new Set();
133
+ const add = (el) => {
134
+ const cls = el.getAttribute('class') ?? '';
135
+ parts.push(`${el.tagName}.${cls}`);
136
+ for (const c of cls.split(/\s+/))
137
+ if (c)
138
+ classes.add(c);
139
+ };
140
+ // html/body themselves first: state classes there (a theme switch, modal-open)
141
+ // are a common pattern and restyle the whole page without touching any
142
+ // descendant's class — invisible to a descendants-only walk.
143
+ add(document.documentElement);
144
+ add(document.body);
145
+ for (const el of document.body.getElementsByTagName('*')) {
146
+ if (SKIP.has(el.tagName))
147
+ continue;
148
+ add(el);
149
+ }
150
+ return { shape: parts.join('\n'), elements: parts.length, classes: [...classes] };
151
+ }
152
+ /* c8 ignore stop */
153
+ /** Runs in the browser: every class name the page's OWN stylesheets select on —
154
+ * the design's defined vocabulary, read from the parsed CSSOM (inline and
155
+ * same-origin sheets; unreadable cross-origin sheets are skipped). Coverage is
156
+ * checked against this, so "fully covered" means every class the design styles
157
+ * was seen rendered in at least one captured surface. */
158
+ /* c8 ignore start */
159
+ function collectDefinedClasses() {
160
+ const out = new Set();
161
+ const scan = (rules) => {
162
+ if (!rules)
163
+ return;
164
+ for (const rule of rules) {
165
+ const r = rule;
166
+ if (r.selectorText) {
167
+ const re = /\.([A-Za-z_][A-Za-z0-9_-]*)/g;
168
+ for (let m = re.exec(r.selectorText); m; m = re.exec(r.selectorText))
169
+ out.add(m[1]);
170
+ }
171
+ if (r.cssRules)
172
+ scan(r.cssRules);
173
+ }
174
+ };
175
+ for (const sheet of document.styleSheets) {
176
+ try {
177
+ scan(sheet.cssRules);
178
+ }
179
+ catch {
180
+ /* cross-origin sheet — not the design's own vocabulary */
181
+ }
182
+ }
183
+ return [...out];
184
+ }
185
+ /* c8 ignore stop */
186
+ /** Structural fingerprint of the page's CURRENT state. Dedup key for surfaces. */
187
+ async function fingerprint(page) {
188
+ const fp = await page.evaluate(domShape);
189
+ return {
190
+ sig: createHash('sha256').update(fp.shape).digest('hex').slice(0, 16),
191
+ elements: fp.elements,
192
+ classes: fp.classes,
193
+ };
194
+ }
195
+ /** Wait for the DOM to stop changing (element count stable) — a cheap post-click
196
+ * settle that also covers content painted by a click-triggered fetch. */
197
+ async function settleDom(page, maxMs = 1200) {
198
+ let prev = -1;
199
+ const deadline = Date.now() + maxMs;
200
+ while (Date.now() < deadline) {
201
+ const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
202
+ if (n === prev)
203
+ return;
204
+ prev = n;
205
+ await page.waitForTimeout(90);
206
+ }
207
+ }
208
+ /** Wait for an async-rendered app to finish mounting: the DOM stops growing and is
209
+ * non-trivial. Generic — no app-specific selector needed, so a bare crawl of a
210
+ * Babel/React/Vue page that boots after `load` still captures the mounted UI. */
211
+ async function waitSettled(page) {
212
+ await page.waitForLoadState('networkidle').catch(() => { });
213
+ let prev = -1;
214
+ for (let i = 0; i < 40; i++) {
215
+ const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
216
+ if (n > 5 && n === prev)
217
+ return;
218
+ prev = n;
219
+ await page.waitForTimeout(100);
220
+ }
221
+ }
222
+ /**
223
+ * Load the URL from a clean slate (storage cleared by the init script armed in
224
+ * crawlAndCapture — one navigation, no clear-then-reload) and wait for the app to
225
+ * mount. Pins the viewport to widths[0] so DISCOVERY always happens at one
226
+ * consistent width — captureInPlace sweeps the other widths and would otherwise
227
+ * leave the viewport wherever it finished (e.g. a mobile band where half the
228
+ * controls are hidden).
229
+ */
230
+ async function gotoFresh(page, opts) {
231
+ await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
232
+ await page.goto(opts.url, { waitUntil: 'load' });
233
+ // Tolerant wait: the generic settle below is the real readiness signal; an
234
+ // optional waitSelector just accelerates it and must not fail the crawl.
235
+ const ready = opts.waitSelector ? page.locator(opts.waitSelector).first() : null;
236
+ if (ready)
237
+ await ready.waitFor({ state: 'visible' }).catch(() => { });
238
+ await waitSettled(page);
239
+ }
240
+ async function perform(page, s) {
241
+ const target = page.locator(s.selector).first();
242
+ if (s.action === 'select-option') {
243
+ await target.selectOption(s.value ?? '');
244
+ return;
245
+ }
246
+ await target.waitFor({ state: 'attached', timeout: 5000 });
247
+ await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
248
+ // Dispatch the click IN-PAGE rather than through Playwright's actionability
249
+ // pipeline. `el.click()` fires the app's real (delegated) click handler but
250
+ // skips the stability/visibility/hit-testing waits that make Playwright's own
251
+ // click flake on an opening modal — the intermittent failure that silently
252
+ // dropped deep states on re-drive. We only need to REACH the state to map it.
253
+ const dispatched = await target
254
+ .evaluate((el) => {
255
+ if (el instanceof HTMLElement) {
256
+ el.click();
257
+ return true;
258
+ }
259
+ return false;
260
+ })
261
+ .catch(() => false);
262
+ if (!dispatched)
263
+ await target.click({ timeout: 3000 }); // non-HTMLElement (SVG etc.)
264
+ }
265
+ async function replay(page, steps) {
266
+ for (const s of steps) {
267
+ await perform(page, s);
268
+ await settleDom(page);
269
+ }
270
+ }
271
+ /** Reset to a known state and VERIFY arrival by fingerprint (retry once). A false
272
+ * return means the path won't reproduce right now — the caller abandons the rest
273
+ * of that state's sweep (fail-soft) rather than mis-attributing children. */
274
+ async function resetToState(page, opts, steps, wantSig) {
275
+ for (let attempt = 0; attempt < 2; attempt++) {
276
+ try {
277
+ await gotoFresh(page, opts);
278
+ await replay(page, steps);
279
+ if ((await fingerprint(page)).sig === wantSig)
280
+ return true;
281
+ }
282
+ catch {
283
+ /* retry */
284
+ }
285
+ }
286
+ return false;
287
+ }
288
+ /** Capture the page's CURRENT state (already driven to) at every width, resizing
289
+ * in place. Capturing where discovery already stands means a fragile click-path
290
+ * is never replayed a second time — the failure mode of re-driving deep, animated
291
+ * controls. */
292
+ async function captureInPlace(page, key, opts) {
293
+ for (const width of opts.widths) {
294
+ await page.setViewportSize({ width, height: opts.height });
295
+ const requests = trackInflightRequests(page);
296
+ try {
297
+ const map = await captureStyleMap(page, {
298
+ ignore: opts.ignore,
299
+ pendingRequests: requests.pending,
300
+ metadata: { surfaceKey: key },
301
+ });
302
+ const stem = path.join(opts.out, `${key}@${width}`);
303
+ saveStyleMap(`${stem}.json.gz`, map);
304
+ if (opts.screenshots)
305
+ await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
306
+ }
307
+ finally {
308
+ requests.dispose();
309
+ }
310
+ }
311
+ }
312
+ /** Identity of a state for the changer registry: the click-path that defines it. */
313
+ function stateKey(steps) {
314
+ return steps.map((s) => s.selector + (s.value ?? '')).join(' → ');
315
+ }
316
+ /** Record a newly-found surface, capture it in place (page is already there), and
317
+ * queue it for its own sweep. Streams progress via onSurface. */
318
+ async function record(page, opts, newPath, depth, fp, st) {
319
+ const key = deriveKey(newPath, st.used);
320
+ const surface = { key, depth, path: newPath, elements: fp.elements };
321
+ st.surfaces.push(surface);
322
+ st.queue.push({ path: newPath, depth, sig: fp.sig });
323
+ for (const c of fp.classes)
324
+ st.classes.add(c);
325
+ let ok = true;
326
+ try {
327
+ await captureInPlace(page, key, opts);
328
+ st.captured++;
329
+ }
330
+ catch {
331
+ st.failed.push(key);
332
+ ok = false;
333
+ }
334
+ opts.onSurface?.(surface, ok);
335
+ }
336
+ /** Click one candidate where the page stands; classify the outcome. */
337
+ async function tryInPlace(page, c) {
338
+ const startUrl = pathAndSearch(page.url());
339
+ const before = (await fingerprint(page)).sig;
340
+ try {
341
+ await perform(page, c);
342
+ }
343
+ catch {
344
+ return 'failed';
345
+ }
346
+ await settleDom(page);
347
+ if (pathAndSearch(page.url()) !== startUrl)
348
+ return 'navigated';
349
+ return (await fingerprint(page)).sig === before ? 'noop' : 'changed';
350
+ }
351
+ /** Drive one candidate from where the page stands. Returns whether the page is
352
+ * still in the swept state (no-op click) and whether the action was a skip. */
353
+ async function driveCandidate(page, opts, entry, c, st) {
354
+ const outcome = await tryInPlace(page, c);
355
+ if (outcome === 'noop')
356
+ return { inState: true, skipped: false }; // still in the state — no reset needed
357
+ if (outcome === 'failed' || outcome === 'navigated')
358
+ return { inState: false, skipped: true };
359
+ // Register the changer for family retry — persistence checked in the state its
360
+ // own click produced (a tab survives its switch; an approve row consumes itself).
361
+ const persists = await page
362
+ .locator(c.selector)
363
+ .first()
364
+ .isVisible()
365
+ .catch(() => false);
366
+ const from = stateKey(entry.path);
367
+ const list = st.changersFrom.get(from) ?? [];
368
+ if (!list.some((x) => x.c.selector === c.selector))
369
+ list.push({ c, persists });
370
+ st.changersFrom.set(from, list);
371
+ const fp = await fingerprint(page);
372
+ if (st.seen.has(fp.sig))
373
+ return { inState: false, skipped: false }; // same surface reached another way
374
+ st.seen.add(fp.sig);
375
+ const step = {
376
+ action: c.action,
377
+ selector: c.selector,
378
+ label: c.label,
379
+ reason: c.reason,
380
+ ...(c.value ? { value: c.value } : {}),
381
+ };
382
+ await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st);
383
+ return { inState: false, skipped: false };
384
+ }
385
+ /**
386
+ * Sweep one state: drive every not-yet-tried control reachable from it, IN PLACE.
387
+ * A no-op click leaves the page in the state, so the sweep just continues; only a
388
+ * state-changing click (new surface, dup, navigation, or failure) pays a verified
389
+ * reset before the next candidate.
390
+ */
391
+ /** The family-retry list for a state: its parent's persistent mode-switchers that
392
+ * are visible right now — minus the step that created this state itself. */
393
+ function familyRetries(entry, all, st) {
394
+ if (entry.path.length === 0)
395
+ return [];
396
+ const parentKey = stateKey(entry.path.slice(0, -1));
397
+ const ownSelector = entry.path[entry.path.length - 1].selector;
398
+ const visibleNow = new Set(all.map((c) => c.selector));
399
+ return (st.changersFrom.get(parentKey) ?? [])
400
+ .filter((x) => x.persists && x.c.selector !== ownSelector && visibleNow.has(x.c.selector))
401
+ .map((x) => x.c);
402
+ }
403
+ async function sweepState(page, opts, entry, st) {
404
+ if (!(await resetToState(page, opts, entry.path, entry.sig)))
405
+ return { tried: 0, skipped: 0 };
406
+ const all = await page.evaluate(collectClickable).catch(() => []);
407
+ // Fresh controls first (already-driven global chrome would otherwise starve a
408
+ // deep surface's own controls; the throttle applies to fresh ones), then the
409
+ // parent's persistent mode-switchers re-tried in THIS sibling mode.
410
+ const fresh = all.filter((c) => !st.tried.has(c.selector)).slice(0, opts.maxActionsPerState);
411
+ const work = [
412
+ ...fresh.map((c) => ({ c, retry: false })),
413
+ ...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
414
+ ];
415
+ let tried = 0;
416
+ let skipped = 0;
417
+ let inState = true;
418
+ for (const { c, retry } of work) {
419
+ if (st.surfaces.length >= opts.maxStates)
420
+ break;
421
+ if (!inState) {
422
+ if (!(await resetToState(page, opts, entry.path, entry.sig)))
423
+ break; // abandon rest, fail-soft
424
+ inState = true;
425
+ }
426
+ if (!retry)
427
+ st.tried.add(c.selector);
428
+ if (c.unsafe) {
429
+ skipped++;
430
+ continue;
431
+ }
432
+ tried++;
433
+ const r = await driveCandidate(page, opts, entry, c, st);
434
+ inState = r.inState;
435
+ if (r.skipped)
436
+ skipped++;
437
+ }
438
+ return { tried, skipped };
439
+ }
440
+ /** Depth-first discovery + in-place capture of every reachable surface. Depth-first
441
+ * so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
442
+ * branch is fresh; with no budget, order affects time-to-depth, not coverage. */
443
+ async function discover(page, opts) {
444
+ fs.mkdirSync(opts.out, { recursive: true });
445
+ await gotoFresh(page, opts);
446
+ const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
447
+ const fp = await fingerprint(page);
448
+ const st = {
449
+ seen: new Set([fp.sig]),
450
+ used: new Set(),
451
+ tried: new Set(),
452
+ changersFrom: new Map(),
453
+ classes: new Set(),
454
+ surfaces: [],
455
+ queue: [],
456
+ captured: 0,
457
+ failed: [],
458
+ };
459
+ await record(page, opts, [], 0, fp, st);
460
+ let actionsTried = 0;
461
+ let skipped = 0;
462
+ while (st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
463
+ const entry = st.queue.pop(); // LIFO → depth-first
464
+ if (entry.depth >= opts.maxDepth)
465
+ continue;
466
+ const r = await sweepState(page, opts, entry, st);
467
+ actionsTried += r.tried;
468
+ skipped += r.skipped;
469
+ }
470
+ const missing = defined.filter((c) => !st.classes.has(c)).sort();
471
+ return {
472
+ surfaces: st.surfaces,
473
+ actionsTried,
474
+ skipped,
475
+ captured: st.captured,
476
+ failed: st.failed,
477
+ coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
478
+ };
479
+ }
480
+ /**
481
+ * Crawl `opts.url` and capture every reachable surface at every width — runs to
482
+ * natural termination by default. Returns the surfaces mapped (with the click-path
483
+ * that reached each), how many actions were tried/skipped, and captured/failed.
484
+ */
485
+ export async function crawlAndCapture(page, opts) {
486
+ if (opts.resetStorage) {
487
+ // Clear storage before the app's code runs on EVERY load, so each gotoFresh is
488
+ // a clean slate in one navigation (no clear-then-reload round trip).
489
+ await page.addInitScript(() => {
490
+ try {
491
+ localStorage.clear();
492
+ sessionStorage.clear();
493
+ }
494
+ catch {
495
+ /* storage unavailable (e.g. file://) — ignore */
496
+ }
497
+ });
498
+ }
499
+ return discover(page, opts);
500
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
+ export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
+ export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
4
+ export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
5
+ export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep } from './crawl-surfaces.js';
2
6
  export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
3
7
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
4
8
  export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
+ export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
+ export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
2
4
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
3
5
  export { coverageGaps } from './coverage.js';
4
6
  export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
package/dist/report.js CHANGED
@@ -8,8 +8,7 @@ import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
8
8
  // through the package's report module rather than a deep path.
9
9
  export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
10
10
  // Hidden marker appended to a new-surface heading. Invisible in rendered
11
- // markdown; lets the PR-comment layer attach an OPTIONAL "approve" box to a new
12
- // surface (vs the required box on a real change), so new surfaces never gate.
11
+ // markdown; lets the PR-comment layer recognize one-sided surfaces.
13
12
  const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
14
13
  const rectToBox = (r) => ({ x: r[0], y: r[1], w: r[2], h: r[3] });
15
14
  const pad = (b, by) => ({ x: b.x - by, y: b.y - by, w: b.w + 2 * by, h: b.h + 2 * by });
@@ -910,8 +909,8 @@ export function generateStyleMapReport(opts) {
910
909
  if (missing.length > 0) {
911
910
  if (changeGroups.length > 0)
912
911
  md.push('');
913
- md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for reference. ` +
914
- `New surfaces don't block the check.`);
912
+ md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for review. ` +
913
+ `Approve them before they become the baseline.`);
915
914
  }
916
915
  }
917
916
  if (volatileCount > 0) {
@@ -1037,8 +1036,7 @@ export function generateStyleMapReport(opts) {
1037
1036
  json.push(surfaceJson);
1038
1037
  }
1039
1038
  // New surfaces: present on only one side, so there's nothing to diff. Show the
1040
- // captured side as a single screenshot for reference and mark the heading so the
1041
- // PR comment can attach an OPTIONAL approval box — these never gate the check.
1039
+ // captured side as a single screenshot and mark the heading for the PR comment.
1042
1040
  for (const p of missing) {
1043
1041
  const side = p.sd.missing === 'before' ? 'after' : 'before';
1044
1042
  const srcDir = side === 'after' ? afterDir : beforeDir;
@@ -1058,7 +1056,7 @@ export function generateStyleMapReport(opts) {
1058
1056
  else {
1059
1057
  md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
1060
1058
  }
1061
- md.push('', `_No baseline to compare against — this surface is new, so it doesn't block the check. It becomes part of the baseline once this merges._`);
1059
+ md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
1062
1060
  json.push(surfaceJson);
1063
1061
  }
1064
1062
  md.push(...contentSection.md);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -38,6 +38,7 @@
38
38
  "bin": {
39
39
  "styleproof-init": "./bin/styleproof-init.mjs",
40
40
  "styleproof-map": "./bin/styleproof-map.mjs",
41
+ "styleproof-capture": "./bin/styleproof-capture.mjs",
41
42
  "styleproof-diff": "./bin/styleproof-diff.mjs",
42
43
  "styleproof-report": "./bin/styleproof-report.mjs",
43
44
  "styleproof-variants": "./bin/styleproof-variants.mjs"