styleproof 3.3.0 → 3.5.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,97 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - Report tables never show an equal-looking Before/After pair for a real diff.
13
+ A colour embedded in a compound value (gradient, shadow) no longer stands in
14
+ for the whole value — `toHex` only converts values that ARE a colour — and
15
+ long value pairs (gradients, data URIs) are excerpted around the differing
16
+ substring with a little shared context on each side.
17
+ - Report values render verbatim: display rounding of decimals is gone (alpha
18
+ `0.18` was shown as `0.2`, and a real `0.18 → 0.2` change could be dropped
19
+ as a no-op after rounding).
20
+
21
+ ### Changed
22
+
23
+ - Control identity is now SEMANTIC, not positional: the driven-once dedup keys
24
+ on tag ancestry (no indices, no classes) + label + role — what stays stable
25
+ across every re-render — instead of nth-of-type selectors. Mode switches
26
+ re-render subtrees and re-mint positional selectors for the same logical
27
+ controls, which made every combination view refill the fresh-candidate pool
28
+ (a crawl inflated past 2,500 surfaces through that leak). Positional clones
29
+ with identical tag-path AND label (a repeated row's expand button) merge —
30
+ one is explored; distinctly-labeled controls stay distinct; anything hidden
31
+ behind a merge is still NAMED by the coverage verifier.
32
+ - Family retries no longer compound: a state reached via a retry still explores
33
+ its genuinely-new UI, but never re-retries mode-switchers. Every PAIRWISE
34
+ combination of independent modes is captured (a tab's edit state, a decided
35
+ list's other tab); 3-way-and-deeper toggle products — which multiply surfaces
36
+ without new render vocabulary — are not walked. Observed live: an exhaustive
37
+ crawl inflated past 725 surfaces in the N-way product tail; the pairwise walk
38
+ covers the same vocabulary in a fraction of the states. Anything class-visible
39
+ only at deeper combination depth is still NAMED by the coverage verifier.
40
+
41
+ ## [3.5.0] - 2026-07-02
42
+
43
+ ### Added
44
+
45
+ - Parallel crawl: `--workers <n>` (default 4) sweeps queued states concurrently,
46
+ each worker on its own browser context. The surface set is identical to a
47
+ serial crawl — dedup sets are shared, and a state's children only join the
48
+ queue when its sweep completes, so family retry never reads a half-built
49
+ changer registry. Only dup-key suffix attribution can vary with timing; pass
50
+ `--workers 1` for byte-stable keys. Exhaustive runs drop from hours to
51
+ minutes on lattice-heavy designs.
52
+
53
+ ## [3.4.0] - 2026-07-02
54
+
55
+ ### Fixed
56
+
57
+ - Consuming actions (controls that disappear when used — resolve/approve/dismiss
58
+ rows) no longer spawn a combinatorial decision lattice. Their result-states are
59
+ captured and swept RETRY-ONLY: the parent's persistent mode-switchers still
60
+ apply (a resolved list's tab view stays reachable), but no fresh candidates are
61
+ collected there — removed rows shift sibling nth-of-type selectors, which made
62
+ the same logical controls look "fresh" in every decided subset and could stall
63
+ an exhaustive crawl for hours adding zero coverage. Found live, on a
64
+ decision-heavy page whose crawl went 449 surfaces / 6 new classes before the
65
+ rule; with it, the same shape converges in single digits with full coverage.
66
+
67
+ ### Added
68
+
69
+ - `styleproof-capture --setup <file>`: deterministic steps (goto/fill/click/
70
+ waitFor) run after every fresh navigation, so input-gated states — a login,
71
+ an unlock code, seeded input — become crawlable. `${ENV_VAR}` in values is
72
+ interpolated from the environment at load time, so credentials never live in
73
+ the file or the maps; a failed non-optional step aborts loudly.
74
+ - Automatic data states: the crawl watches the entry page's data requests and
75
+ additionally captures `loading` (requests stalled — the skeleton) and `error`
76
+ (requests fulfilled with 500) out of the box. Identical-to-base renders dedup
77
+ away; `--no-data-states` to skip.
78
+ - README: "What the crawler can and cannot reach — honestly" — the crawl
79
+ vocabulary, what each state class is reached by, and the verifier contract
80
+ that anything unreached is named, never silently missed.
81
+ - Automatic neutral-input fill: text/search/email/tel/url/number inputs and
82
+ textareas are typed with a deterministic value, so input-driven states (a
83
+ search's results, a filter's rendering) are crawled with no config.
84
+ Credential-semantic fields (type=password, autocomplete username/
85
+ current-password/new-password/one-time-code) are never auto-filled — that is
86
+ `--setup` territory, the only input the tool cannot derive.
87
+ - Automatic scroll reveal: a bounded, deterministic scroll pass on every load
88
+ mounts IntersectionObserver/lazy content before discovery, so scroll-gated
89
+ sections are part of the mapped surface.
90
+ - The crawl now auto-detects the page's real `@media` breakpoints when no
91
+ `--widths` are given (like the one-shot path), sweeping one width per band.
92
+ - `--setup` steps are honoured by the one-shot (non-crawl) capture too, so a
93
+ gated page's single state is capturable directly.
94
+
95
+ ### Fixed
96
+
97
+ - Automatic data states now intercept by resource type (fetch/XHR) instead of
98
+ exact URL — apps that cache-bust their requests (`?t=...`) previously
99
+ slipped past the stall and produced no `loading` capture.
100
+
10
101
  ## [3.3.0] - 2026-07-02
11
102
 
12
103
  ### Added
package/README.md CHANGED
@@ -424,12 +424,53 @@ styleproof-diff design .styleproof/maps/current # diff the whole
424
424
 
425
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
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.
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. And it's **parallel by default** — `--workers <n>` (default 4) sweeps states concurrently on isolated browser contexts with the exact same surface set as a serial crawl (dedup is shared; children only enter the queue when their parent's sweep completes); `--workers 1` if you want byte-stable dup-key attribution.
428
428
 
429
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
430
 
431
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
432
 
433
+ ### Data states, out of the box
434
+
435
+ Every data-driven page has states that almost never sit on a click path: the **loading skeleton** and the **error render**. The crawl captures both automatically — it watches the entry page's data requests, then re-loads once with them **stalled** (the skeleton is the settled state, captured as `loading`) and once with them **fulfilled as 500** (captured as `error`). States that render identically to the base (e.g. server-rendered pages) dedup away silently. On by default; `--no-data-states` to skip. Deeper data states — a specific empty list, a partial payload — are fixture territory: model them as `liveStates`/`variants` in a spec.
436
+
437
+ ### Input-gated states: `--setup`
438
+
439
+ A crawler clicks and selects; it does not guess your password. States behind typed input — a login, an unlock code, a seeded search — become crawlable with a deterministic setup file, run after **every** fresh navigation so each reset re-establishes the gate identically:
440
+
441
+ ```json
442
+ [
443
+ { "action": "fill", "selector": "#user", "value": "${CAPTURE_USER}" },
444
+ { "action": "fill", "selector": "#pass", "value": "${CAPTURE_PASS}" },
445
+ { "action": "click", "selector": "#sign-in" },
446
+ { "action": "waitFor", "selector": ".dashboard" }
447
+ ]
448
+ ```
449
+
450
+ ```bash
451
+ CAPTURE_USER=demo CAPTURE_PASS=… styleproof-capture https://example.com --crawl --setup login.json --out design
452
+ ```
453
+
454
+ `${ENV_VAR}` in `value`/`url` is interpolated from the environment at load time — **credentials never live in the file, the shell history, or the captured maps.** A non-optional step that fails aborts the crawl loudly (a half-established gate must never silently crawl the ungated page); mark a step `"optional": true` when it legitimately may not apply (a cookie-session app that shows the login form only once).
455
+
456
+ ### What the crawler can and cannot reach — honestly
457
+
458
+ The crawl's vocabulary is **click, select, neutral typing, scrolling, and your setup steps** — and it sweeps the page's real `@media` breakpoints automatically when you give it none. Within it, mapping is exhaustive. Outside it, states are not reached by crawling — and the coverage verifier is what keeps that honest: anything unreached is _named_, never silently missed.
459
+
460
+ | State | Reached by |
461
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
462
+ | Click-opened surfaces (modals, drawers, popovers, tabs, toggles) | crawl, automatically |
463
+ | Mode × sibling combinations (a tab's edit state, a decided list's other tab) | crawl — family retry |
464
+ | Loading / error data states of the entry page | crawl — automatic data states |
465
+ | Login / unlock / typed input | `--setup` steps |
466
+ | `:hover` / `:focus` / `:active` styling | the forced-state layer of every capture |
467
+ | Deeper data states (empty, partial, streaming) | spec `liveStates` / `variants` with fixtures |
468
+ | States behind destructive actions | a spec, deliberately — the crawl never clicks them |
469
+ | Drag-and-drop, keyboard-shortcut, scroll-triggered states | a spec driving them explicitly |
470
+ | Components not mounted anywhere in the UI | a component catalog page (each component per prop-state is a surface — Storybook/Ladle stories work; `discoverComponentFiles` fails CI when a component file has no captured surface) |
471
+
472
+ The rule of thumb: **a rendered state is a function of props, data, and input.** Control all three — mock the data, script the input, mount the component — and every state a component can render is a capturable surface. The verifier tells you, by name, which ones you haven't controlled yet.
473
+
433
474
  ## Install
434
475
 
435
476
  ```bash
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { chromium } from '@playwright/test';
20
20
  import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
21
- import { UsageError, parseCaptureUrlArgs, runCaptureUrl } from '../dist/capture-url.js';
21
+ import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
22
22
  import { crawlAndCapture } from '../dist/crawl-surfaces.js';
23
23
 
24
24
  const COMMAND = 'styleproof-capture';
@@ -32,7 +32,7 @@ one state (default): capture the page as it loads
32
32
  --wait <selector> wait for this selector to be visible before capturing
33
33
  --widths <csv> viewport widths, e.g. 1440,1024,768. Omit to detect the
34
34
  page's own @media breakpoints (fails on cross-origin CSS —
35
- pass widths for those). Crawl defaults to 1280 when omitted.
35
+ pass widths for those). The crawl auto-detects too.
36
36
 
37
37
  whole surface: --crawl
38
38
  --crawl EXHAUSTIVE: drive every non-destructive control, recurse into
@@ -44,6 +44,15 @@ whole surface: --crawl
44
44
  exit 4 unless every class the page's stylesheets define was
45
45
  rendered in a captured surface — the machine check that
46
46
  NOTHING in the design was missed (coverage is always printed)
47
+ --setup <file> JSON steps (goto/fill/click/waitFor) run after EVERY fresh
48
+ navigation — how input-gated states (login, unlock) become
49
+ crawlable. \${ENV_VAR} in value/url is read from the
50
+ environment, so secrets never live in the file or the maps.
51
+ --no-data-states skip the automatic loading/error captures of the entry page
52
+ (on by default: data requests stalled → loading skeleton;
53
+ fulfilled with 500 → error render)
54
+ --workers <n> concurrent sweep workers (default 4); same surface set as a
55
+ serial crawl — pass 1 for byte-stable key attribution
47
56
  --max-depth <n> throttle recursion depth (default: unbounded)
48
57
  --max-actions <n> throttle controls tried per state (default: unbounded)
49
58
  --max-states <n> throttle total surfaces (default: unbounded)
@@ -67,8 +76,11 @@ const argv = process.argv.slice(2);
67
76
  if (isHelpArg(argv[0])) showHelpAndExit(HELP);
68
77
 
69
78
  let opts;
79
+ let setupSteps;
70
80
  try {
71
81
  opts = parseCaptureUrlArgs(argv);
82
+ setupSteps = opts.setupFile ? loadSetupSteps(opts.setupFile) : undefined;
83
+ opts.setup = setupSteps; // one-shot capture honours setup steps too
72
84
  } catch (e) {
73
85
  if (e instanceof UsageError) {
74
86
  console.error(`${COMMAND}: ${e.message}\nNext: run ${COMMAND} --help to see supported options.`);
@@ -84,7 +96,7 @@ async function runCrawl() {
84
96
  const crawlOpts = {
85
97
  url: opts.url,
86
98
  out: opts.out,
87
- widths: opts.widths.length ? opts.widths : [1280],
99
+ widths: opts.widths, // empty = auto-detect the page's real breakpoints
88
100
  ignore: opts.ignore,
89
101
  height: opts.height,
90
102
  screenshots: opts.screenshots,
@@ -93,6 +105,11 @@ async function runCrawl() {
93
105
  maxActionsPerState: opts.maxActionsPerState,
94
106
  maxStates: opts.maxStates,
95
107
  resetStorage: opts.resetStorage,
108
+ setup: setupSteps,
109
+ dataStates: opts.dataStates,
110
+ workers: opts.workers,
111
+ // each worker page in its OWN context, so storage resets can't interfere
112
+ newPage: async () => (await browser.newContext()).newPage(),
96
113
  // Stream each surface as it is captured, so progress is visible live and an
97
114
  // interrupted run still shows exactly what it mapped.
98
115
  onSurface: (s, ok) =>
@@ -1,4 +1,5 @@
1
1
  import type { Browser, Page } from '@playwright/test';
2
+ import { type SetupStep } from './crawl-surfaces.js';
2
3
  /**
3
4
  * One-shot capture of a single URL's computed-style map — no spec, no config,
4
5
  * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
@@ -55,6 +56,17 @@ export type CaptureUrlOptions = {
55
56
  /** crawl: exit non-zero unless every class the page's stylesheets define was
56
57
  * rendered in at least one captured surface (default false — report only). */
57
58
  requireFullCoverage: boolean;
59
+ /** crawl: JSON file of deterministic setup steps (login, unlock, seed input)
60
+ * run after every fresh navigation. See {@link loadSetupSteps}. */
61
+ setupFile?: string;
62
+ /** Loaded setup steps (set by the CLI from `setupFile`); applied after every
63
+ * navigation in BOTH modes, so a gated page's single state is capturable too. */
64
+ setup?: SetupStep[];
65
+ /** crawl: also capture automatic `loading`/`error` data states of the entry
66
+ * page (default true). */
67
+ dataStates: boolean;
68
+ /** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
69
+ workers: number;
58
70
  };
59
71
  /**
60
72
  * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
@@ -78,3 +90,10 @@ export type CaptureUrlResult = {
78
90
  export declare function captureUrlToDir(page: Page, opts: CaptureUrlOptions): Promise<CaptureUrlResult[]>;
79
91
  /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
80
92
  export declare function runCaptureUrl(opts: CaptureUrlOptions, launch: () => Promise<Browser>): Promise<CaptureUrlResult[]>;
93
+ /**
94
+ * Load and validate a `--setup` steps file, interpolating `${ENV_VAR}` in every
95
+ * `value` and `url` from the environment — so credentials for an input-gated
96
+ * page live in env vars, never in the file, the shell history, or the maps.
97
+ * Throws {@link UsageError} on a malformed file or a missing variable.
98
+ */
99
+ export declare function loadSetupSteps(file: string, env?: NodeJS.ProcessEnv): SetupStep[];
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
4
4
  import { detectViewportWidths } from './breakpoints.js';
5
+ import { runSetup } from './crawl-surfaces.js';
5
6
  /**
6
7
  * One-shot capture of a single URL's computed-style map — no spec, no config,
7
8
  * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
@@ -30,6 +31,8 @@ const DEFAULTS = {
30
31
  maxStates: 100000,
31
32
  resetStorage: true,
32
33
  requireFullCoverage: false,
34
+ dataStates: true,
35
+ workers: 4,
33
36
  };
34
37
  function positiveNumber(raw, flag) {
35
38
  const n = Number(raw);
@@ -59,6 +62,8 @@ const VALUE_FLAGS = {
59
62
  '--max-depth': (o, v) => (o.maxDepth = positiveNumber(v, '--max-depth')),
60
63
  '--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
61
64
  '--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
65
+ '--setup': (o, v) => (o.setupFile = v),
66
+ '--workers': (o, v) => (o.workers = positiveNumber(v, '--workers')),
62
67
  };
63
68
  const BOOL_FLAGS = {
64
69
  '--screenshots': (o) => (o.screenshots = true),
@@ -66,6 +71,8 @@ const BOOL_FLAGS = {
66
71
  '--crawl': (o) => (o.crawl = true),
67
72
  '--no-reset-storage': (o) => (o.resetStorage = false),
68
73
  '--require-full-coverage': (o) => (o.requireFullCoverage = true),
74
+ '--data-states': (o) => (o.dataStates = true),
75
+ '--no-data-states': (o) => (o.dataStates = false),
69
76
  };
70
77
  // Apply one argv token to the accumulator; returns the index to resume from
71
78
  // (advanced past a consumed `--flag value` pair). Flat early-returns so the
@@ -112,6 +119,9 @@ export function parseCaptureUrlArgs(argv) {
112
119
  maxStates: DEFAULTS.maxStates,
113
120
  resetStorage: DEFAULTS.resetStorage,
114
121
  requireFullCoverage: DEFAULTS.requireFullCoverage,
122
+ setupFile: undefined,
123
+ dataStates: DEFAULTS.dataStates,
124
+ workers: DEFAULTS.workers,
115
125
  };
116
126
  const positional = [];
117
127
  for (let i = 0; i < argv.length; i++)
@@ -139,6 +149,8 @@ export async function captureUrlToDir(page, opts) {
139
149
  await page.goto(opts.url, { waitUntil: 'load' });
140
150
  if (opts.waitSelector)
141
151
  await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
152
+ if (opts.setup?.length)
153
+ await runSetup(page, opts.setup);
142
154
  widths = await detectViewportWidths(page);
143
155
  }
144
156
  const results = [];
@@ -149,6 +161,8 @@ export async function captureUrlToDir(page, opts) {
149
161
  await page.goto(opts.url, { waitUntil: 'load' });
150
162
  if (opts.waitSelector)
151
163
  await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
164
+ if (opts.setup?.length)
165
+ await runSetup(page, opts.setup);
152
166
  const map = await captureStyleMap(page, {
153
167
  ignore: opts.ignore,
154
168
  pendingRequests: requests.pending,
@@ -183,3 +197,41 @@ export async function runCaptureUrl(opts, launch) {
183
197
  await browser.close();
184
198
  }
185
199
  }
200
+ const SETUP_ACTIONS = new Set(['goto', 'fill', 'click', 'waitFor']);
201
+ /**
202
+ * Load and validate a `--setup` steps file, interpolating `${ENV_VAR}` in every
203
+ * `value` and `url` from the environment — so credentials for an input-gated
204
+ * page live in env vars, never in the file, the shell history, or the maps.
205
+ * Throws {@link UsageError} on a malformed file or a missing variable.
206
+ */
207
+ export function loadSetupSteps(file, env = process.env) {
208
+ let parsed;
209
+ try {
210
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
211
+ }
212
+ catch (e) {
213
+ throw new UsageError(`--setup: cannot read ${file}: ${e instanceof Error ? e.message : String(e)}`);
214
+ }
215
+ if (!Array.isArray(parsed))
216
+ throw new UsageError('--setup: the file must be a JSON array of steps');
217
+ const interpolate = (raw) => raw.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
218
+ const v = env[name];
219
+ if (v === undefined)
220
+ throw new UsageError(`--setup: environment variable ${name} is not set`);
221
+ return v;
222
+ });
223
+ return parsed.map((raw, i) => {
224
+ const step = raw;
225
+ if (!SETUP_ACTIONS.has(step.action))
226
+ throw new UsageError(`--setup: step ${i} has unknown action "${String(step.action)}"`);
227
+ if (step.action === 'goto' && !step.url)
228
+ throw new UsageError(`--setup: step ${i} (goto) needs a url`);
229
+ if (step.action !== 'goto' && !step.selector)
230
+ throw new UsageError(`--setup: step ${i} (${step.action}) needs a selector`);
231
+ return {
232
+ ...step,
233
+ ...(step.url ? { url: interpolate(step.url) } : {}),
234
+ ...(step.value ? { value: interpolate(step.value) } : {}),
235
+ };
236
+ });
237
+ }
@@ -30,7 +30,7 @@ import type { Page } from '@playwright/test';
30
30
  * mapping must not mutate. States gated behind such an action need a spec.
31
31
  */
32
32
  export type CrawlStep = {
33
- action: 'click' | 'select-option';
33
+ action: 'click' | 'select-option' | 'fill-input';
34
34
  selector: string;
35
35
  label: string;
36
36
  reason: string;
@@ -60,6 +60,21 @@ export type CrawlReport = {
60
60
  failed: string[];
61
61
  coverage: CrawlCoverage;
62
62
  };
63
+ /**
64
+ * One deterministic step run after every fresh navigation, BEFORE the crawl
65
+ * looks at the page — how input-gated states (a login form, a search box)
66
+ * become crawlable. Values come from the caller with `${ENV_VAR}` interpolation
67
+ * done at load time, so secrets live in the environment, never in files or maps.
68
+ * `optional: true` skips the step silently when its selector isn't present
69
+ * (e.g. a cookie-session app that only shows the login form once).
70
+ */
71
+ export type SetupStep = {
72
+ action: 'goto' | 'fill' | 'click' | 'waitFor';
73
+ url?: string;
74
+ selector?: string;
75
+ value?: string;
76
+ optional?: boolean;
77
+ };
63
78
  export type SurfaceCrawlOptions = {
64
79
  url: string;
65
80
  out: string;
@@ -68,6 +83,13 @@ export type SurfaceCrawlOptions = {
68
83
  height: number;
69
84
  screenshots: boolean;
70
85
  waitSelector?: string;
86
+ /** Deterministic steps (login, unlock, seed input) run after EVERY fresh
87
+ * navigation, so each reset re-establishes the gated state identically. */
88
+ setup?: SetupStep[];
89
+ /** Also capture automatic data states of the entry page: `loading` (data
90
+ * requests stalled — the skeleton) and `error` (data requests fulfilled with
91
+ * a 500). Default true; states that render identically to base are skipped. */
92
+ dataStates?: boolean;
71
93
  /** Throttle: recursion depth into opened surfaces (base = 0). Default: unbounded. */
72
94
  maxDepth: number;
73
95
  /** Throttle: fresh controls driven per state. Default: unbounded — try them all. */
@@ -79,6 +101,15 @@ export type SurfaceCrawlOptions = {
79
101
  /** Called as each surface is recorded (captured=false when its full capture failed).
80
102
  * Lets CLIs stream progress instead of reporting only at the end. */
81
103
  onSurface?: (surface: CrawledSurface, captured: boolean) => void;
104
+ /** Concurrent sweep workers (default 4). Each worker gets its own page from
105
+ * `newPage`; without a factory the crawl runs single-page regardless. The
106
+ * surface SET is identical to a serial crawl (same dedup sets); only which
107
+ * path first claims a shape — and so dup-key suffixes — can vary run to run.
108
+ * Use workers: 1 for byte-stable key attribution. */
109
+ workers?: number;
110
+ /** Factory for worker pages — create each in its OWN browser context so
111
+ * storage resets cannot interfere across concurrent sweeps. */
112
+ newPage?: () => Promise<Page>;
82
113
  };
83
114
  export declare const CRAWL_DEFAULTS: {
84
115
  height: number;
@@ -87,10 +118,10 @@ export declare const CRAWL_DEFAULTS: {
87
118
  maxActionsPerState: number;
88
119
  maxStates: number;
89
120
  resetStorage: boolean;
121
+ workers: number;
90
122
  };
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
- */
123
+ /** Run the caller's deterministic setup steps (login, unlock, seed input). A
124
+ * non-optional step that fails throws loudly a half-established gate must
125
+ * never silently crawl the ungated page instead. */
126
+ export declare function runSetup(page: Page, steps: SetupStep[]): Promise<void>;
96
127
  export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
5
+ import { detectViewportWidths } from './breakpoints.js';
5
6
  // Exhaustive by default — these ceilings are safety backstops, not budgets.
6
7
  export const CRAWL_DEFAULTS = {
7
8
  height: 900,
@@ -10,6 +11,7 @@ export const CRAWL_DEFAULTS = {
10
11
  maxActionsPerState: 100000,
11
12
  maxStates: 100000,
12
13
  resetStorage: true,
14
+ workers: 4,
13
15
  };
14
16
  function slug(value) {
15
17
  return (value
@@ -76,6 +78,19 @@ function collectClickable() {
76
78
  .replace(/\s+/g, ' ')
77
79
  .trim()
78
80
  .slice(0, 80) || el.tagName.toLowerCase();
81
+ const identityFor = (el) => {
82
+ // Tag-path only — NO classes and NO indices: classes carry state (body.alt,
83
+ // .on) and would re-contextualize the same control per mode; indices carry
84
+ // position and drift on re-render. Tag ancestry + label is what stays
85
+ // stable across every context the same logical control appears in.
86
+ const parts = [];
87
+ let cur = el;
88
+ while (cur && cur !== document.documentElement) {
89
+ parts.unshift(cur.tagName.toLowerCase());
90
+ cur = cur.parentElement;
91
+ }
92
+ return `${parts.join('>')}|${labelFor(el)}|${el.getAttribute('role') ?? ''}`;
93
+ };
79
94
  // Semantic controls first (stable, meaningful), then anything styled clickable.
80
95
  // `grab` counts too: a draggable card is routinely ALSO a click target (open on
81
96
  // click, drag to move), and we never drag — el.click() fires no drag gesture.
@@ -87,8 +102,42 @@ function collectClickable() {
87
102
  if (cursor === 'pointer' || cursor === 'grab')
88
103
  pool.add(el);
89
104
  }
105
+ // Neutral text inputs are typed automatically with a deterministic value —
106
+ // a search box or filter needs no secrets. Credential-semantic fields
107
+ // (type=password, autocomplete username/current-password/new-password/
108
+ // one-time-code) are NEVER auto-filled: those are --setup territory.
109
+ const FILLABLE = 'input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="tel"],input[type="url"],input[type="number"],textarea';
110
+ const CRED_AUTOCOMPLETE = /username|current-password|new-password|one-time-code/i;
111
+ const AUTO_VALUE = {
112
+ email: 'sample@example.com',
113
+ url: 'https://example.com',
114
+ tel: '5550100',
115
+ number: '1',
116
+ };
90
117
  const seen = new Set();
91
118
  const out = [];
119
+ for (const el of document.querySelectorAll(FILLABLE)) {
120
+ if (CRED_AUTOCOMPLETE.test(el.getAttribute('autocomplete') ?? ''))
121
+ continue;
122
+ if (el.closest(':disabled,[aria-disabled="true"]') || el.readOnly)
123
+ continue;
124
+ if (!visible(el))
125
+ continue;
126
+ const selector = selectorFor(el);
127
+ if (seen.has(selector))
128
+ continue;
129
+ seen.add(selector);
130
+ const kind = el.getAttribute('type') ?? 'text';
131
+ out.push({
132
+ action: 'fill-input',
133
+ selector,
134
+ identity: identityFor(el),
135
+ label: labelFor(el) === el.tagName.toLowerCase() ? (el.getAttribute('placeholder') ?? 'input') : labelFor(el),
136
+ reason: 'auto-fill',
137
+ value: AUTO_VALUE[kind] ?? 'sample text',
138
+ unsafe: false,
139
+ });
140
+ }
92
141
  for (const el of pool) {
93
142
  if (el instanceof HTMLAnchorElement && el.href)
94
143
  continue; // links navigate — handled by link crawl, not here
@@ -105,12 +154,21 @@ function collectClickable() {
105
154
  if (el instanceof HTMLSelectElement) {
106
155
  const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
107
156
  if (next)
108
- out.push({ action: 'select-option', selector, label, reason: 'select-option', value: next.value, unsafe });
157
+ out.push({
158
+ action: 'select-option',
159
+ selector,
160
+ identity: identityFor(el),
161
+ label,
162
+ reason: 'select-option',
163
+ value: next.value,
164
+ unsafe,
165
+ });
109
166
  }
110
167
  else {
111
168
  out.push({
112
169
  action: 'click',
113
170
  selector,
171
+ identity: identityFor(el),
114
172
  label,
115
173
  reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
116
174
  unsafe,
@@ -227,6 +285,59 @@ async function waitSettled(page) {
227
285
  * leave the viewport wherever it finished (e.g. a mobile band where half the
228
286
  * controls are hidden).
229
287
  */
288
+ /** One runner per setup action — a table, so adding an action is one entry. */
289
+ const SETUP_RUNNERS = {
290
+ goto: async (page, s) => {
291
+ await page.goto(s.url ?? '', { waitUntil: 'load' });
292
+ },
293
+ fill: async (page, s) => {
294
+ await page
295
+ .locator(s.selector ?? '')
296
+ .first()
297
+ .fill(s.value ?? '', { timeout: 10000 });
298
+ },
299
+ click: async (page, s) => {
300
+ await perform(page, { action: 'click', selector: s.selector ?? '' });
301
+ },
302
+ waitFor: async (page, s) => {
303
+ await page
304
+ .locator(s.selector ?? '')
305
+ .first()
306
+ .waitFor({ state: 'visible', timeout: 10000 });
307
+ },
308
+ };
309
+ /** Run the caller's deterministic setup steps (login, unlock, seed input). A
310
+ * non-optional step that fails throws loudly — a half-established gate must
311
+ * never silently crawl the ungated page instead. */
312
+ export async function runSetup(page, steps) {
313
+ for (const s of steps) {
314
+ try {
315
+ await SETUP_RUNNERS[s.action](page, s);
316
+ }
317
+ catch (e) {
318
+ if (s.optional)
319
+ continue;
320
+ throw new Error(`setup step failed (${s.action} ${s.selector ?? s.url ?? ''})`, { cause: e });
321
+ }
322
+ }
323
+ }
324
+ /** Reveal scroll-gated content deterministically: IntersectionObserver mounts,
325
+ * lazy sections. One bounded pass per load (same scroll every time, so replay
326
+ * and fingerprints stay stable); capped so an infinite feed can't spin it. */
327
+ async function scrollReveal(page) {
328
+ await page
329
+ .evaluate(async () => {
330
+ const step = Math.max(200, window.innerHeight);
331
+ let y = 0;
332
+ for (let i = 0; i < 20 && y <= document.body.scrollHeight; i++) {
333
+ window.scrollTo(0, y);
334
+ y += step;
335
+ await new Promise((r) => setTimeout(r, 60));
336
+ }
337
+ window.scrollTo(0, 0);
338
+ })
339
+ .catch(() => { });
340
+ }
230
341
  async function gotoFresh(page, opts) {
231
342
  await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
232
343
  await page.goto(opts.url, { waitUntil: 'load' });
@@ -236,6 +347,12 @@ async function gotoFresh(page, opts) {
236
347
  if (ready)
237
348
  await ready.waitFor({ state: 'visible' }).catch(() => { });
238
349
  await waitSettled(page);
350
+ if (opts.setup?.length) {
351
+ await runSetup(page, opts.setup);
352
+ await settleDom(page); // the steps changed page state — let it land
353
+ }
354
+ await scrollReveal(page);
355
+ await settleDom(page);
239
356
  }
240
357
  async function perform(page, s) {
241
358
  const target = page.locator(s.selector).first();
@@ -243,6 +360,10 @@ async function perform(page, s) {
243
360
  await target.selectOption(s.value ?? '');
244
361
  return;
245
362
  }
363
+ if (s.action === 'fill-input') {
364
+ await target.fill(s.value ?? '', { timeout: 5000 });
365
+ return;
366
+ }
246
367
  await target.waitFor({ state: 'attached', timeout: 5000 });
247
368
  await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
248
369
  // Dispatch the click IN-PAGE rather than through Playwright's actionability
@@ -315,20 +436,27 @@ function stateKey(steps) {
315
436
  }
316
437
  /** Record a newly-found surface, capture it in place (page is already there), and
317
438
  * queue it for its own sweep. Streams progress via onSurface. */
318
- async function record(page, opts, newPath, depth, fp, st) {
439
+ async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
319
440
  const key = deriveKey(newPath, st.used);
320
441
  const surface = { key, depth, path: newPath, elements: fp.elements };
321
442
  st.surfaces.push(surface);
322
- st.queue.push({ path: newPath, depth, sig: fp.sig });
443
+ // Children buffer in the sweep's sink and enter the shared queue only when the
444
+ // parent's sweep completes — family retry reads the parent's changer registry,
445
+ // which is only complete then. (Serial mode passes st.queue directly.)
446
+ sink.push({ path: newPath, depth, sig: fp.sig, retryOnly, viaRetry });
323
447
  for (const c of fp.classes)
324
448
  st.classes.add(c);
449
+ await captureAndReport(page, opts, surface, st);
450
+ }
451
+ /** Capture the current page as `surface` at every width and report the outcome. */
452
+ async function captureAndReport(page, opts, surface, st) {
325
453
  let ok = true;
326
454
  try {
327
- await captureInPlace(page, key, opts);
455
+ await captureInPlace(page, surface.key, opts);
328
456
  st.captured++;
329
457
  }
330
458
  catch {
331
- st.failed.push(key);
459
+ st.failed.push(surface.key);
332
460
  ok = false;
333
461
  }
334
462
  opts.onSurface?.(surface, ok);
@@ -350,7 +478,9 @@ async function tryInPlace(page, c) {
350
478
  }
351
479
  /** Drive one candidate from where the page stands. Returns whether the page is
352
480
  * still in the swept state (no-op click) and whether the action was a skip. */
353
- async function driveCandidate(page, opts, entry, c, st) {
481
+ async function driveCandidate(page, opts, entry, c, st, sink, viaRetry) {
482
+ // (retry-only lineage is inherited: a consumed state's descendants can also
483
+ // only be mode-switch views, never fresh-candidate exploration.)
354
484
  const outcome = await tryInPlace(page, c);
355
485
  if (outcome === 'noop')
356
486
  return { inState: true, skipped: false }; // still in the state — no reset needed
@@ -365,7 +495,7 @@ async function driveCandidate(page, opts, entry, c, st) {
365
495
  .catch(() => false);
366
496
  const from = stateKey(entry.path);
367
497
  const list = st.changersFrom.get(from) ?? [];
368
- if (!list.some((x) => x.c.selector === c.selector))
498
+ if (!list.some((x) => x.c.identity === c.identity))
369
499
  list.push({ c, persists });
370
500
  st.changersFrom.set(from, list);
371
501
  const fp = await fingerprint(page);
@@ -379,7 +509,7 @@ async function driveCandidate(page, opts, entry, c, st) {
379
509
  reason: c.reason,
380
510
  ...(c.value ? { value: c.value } : {}),
381
511
  };
382
- await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st);
512
+ await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, sink, entry.retryOnly || !persists, viaRetry);
383
513
  return { inState: false, skipped: false };
384
514
  }
385
515
  /**
@@ -391,27 +521,44 @@ async function driveCandidate(page, opts, entry, c, st) {
391
521
  /** The family-retry list for a state: its parent's persistent mode-switchers that
392
522
  * are visible right now — minus the step that created this state itself. */
393
523
  function familyRetries(entry, all, st) {
524
+ // RETRIES DO NOT COMPOUND: a state reached via a family retry still explores
525
+ // its genuinely-new UI (fresh selectors), but never re-retries mode-switchers.
526
+ // Every PAIRWISE mode combination is captured; N-way products of independent
527
+ // toggles are not walked — they multiply states without new render vocabulary,
528
+ // and anything class-visible only at 3-way depth is still NAMED by the
529
+ // coverage verifier.
530
+ if (entry.viaRetry)
531
+ return [];
394
532
  if (entry.path.length === 0)
395
533
  return [];
396
534
  const parentKey = stateKey(entry.path.slice(0, -1));
397
535
  const ownSelector = entry.path[entry.path.length - 1].selector;
398
- const visibleNow = new Set(all.map((c) => c.selector));
536
+ // Match registered changers to THIS state's candidates by semantic identity —
537
+ // the mode switch re-rendered the subtree, so positional selectors drifted;
538
+ // the current candidate carries the right selector for this state.
539
+ const byIdentity = new Map(all.map((c) => [c.identity, c]));
399
540
  return (st.changersFrom.get(parentKey) ?? [])
400
- .filter((x) => x.persists && x.c.selector !== ownSelector && visibleNow.has(x.c.selector))
401
- .map((x) => x.c);
541
+ .filter((x) => x.persists && x.c.selector !== ownSelector)
542
+ .map((x) => byIdentity.get(x.c.identity))
543
+ .filter((c) => Boolean(c));
402
544
  }
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 = [
545
+ /** The work list for one state's sweep: fresh controls first (already-driven
546
+ * global chrome would otherwise starve a deep surface's own controls; the
547
+ * throttle applies to fresh ones), then the parent's persistent mode-switchers
548
+ * re-tried in THIS sibling mode. A state reached through a consuming action
549
+ * collects NO fresh candidates see QueueEntry.retryOnly. */
550
+ function sweepWorkList(entry, all, opts, st) {
551
+ const fresh = entry.retryOnly ? [] : all.filter((c) => !st.tried.has(c.identity)).slice(0, opts.maxActionsPerState);
552
+ return [
412
553
  ...fresh.map((c) => ({ c, retry: false })),
413
554
  ...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
414
555
  ];
556
+ }
557
+ async function sweepState(page, opts, entry, st, sink) {
558
+ if (!(await resetToState(page, opts, entry.path, entry.sig)))
559
+ return { tried: 0, skipped: 0 };
560
+ const all = await page.evaluate(collectClickable).catch(() => []);
561
+ const work = sweepWorkList(entry, all, opts, st);
415
562
  let tried = 0;
416
563
  let skipped = 0;
417
564
  let inState = true;
@@ -424,25 +571,125 @@ async function sweepState(page, opts, entry, st) {
424
571
  inState = true;
425
572
  }
426
573
  if (!retry)
427
- st.tried.add(c.selector);
574
+ st.tried.add(c.identity);
428
575
  if (c.unsafe) {
429
576
  skipped++;
430
577
  continue;
431
578
  }
432
579
  tried++;
433
- const r = await driveCandidate(page, opts, entry, c, st);
580
+ const r = await driveCandidate(page, opts, entry, c, st, sink, retry);
434
581
  inState = r.inState;
435
582
  if (r.skipped)
436
583
  skipped++;
437
584
  }
438
585
  return { tried, skipped };
439
586
  }
587
+ /** Capture one synthetic data state of the entry page (its data requests stalled
588
+ * or failed) in place, deduped and coverage-counted like any surface — but never
589
+ * queued: a stalled app is not a state to crawl deeper from. */
590
+ async function recordDataState(page, opts, mode, st) {
591
+ // Match by resource TYPE, not by URL: real apps cache-bust (?t=...), so the
592
+ // re-load's data URLs never equal the observed ones. Any fetch/xhr is data.
593
+ await page.route('**/*', async (route) => {
594
+ const kind = route.request().resourceType();
595
+ if (kind !== 'fetch' && kind !== 'xhr')
596
+ return route.continue();
597
+ if (mode === 'error')
598
+ return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
599
+ // loading: leave the request pending forever — the skeleton IS the state.
600
+ });
601
+ try {
602
+ await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
603
+ await page.goto(opts.url, { waitUntil: 'load' });
604
+ await settleDom(page, 2500); // no networkidle wait — a stalled request never goes idle
605
+ const fp = await fingerprint(page);
606
+ if (st.seen.has(fp.sig))
607
+ return; // renders identically to a captured state (e.g. SSR)
608
+ st.seen.add(fp.sig);
609
+ for (const c of fp.classes)
610
+ st.classes.add(c);
611
+ const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used);
612
+ const surface = { key, depth: 0, path: [], elements: fp.elements };
613
+ st.surfaces.push(surface);
614
+ await captureAndReport(page, opts, surface, st);
615
+ }
616
+ finally {
617
+ await page.unroute('**/*');
618
+ }
619
+ }
620
+ /**
621
+ * Sweep the queue with N concurrent workers, each on its own page. LIFO keeps
622
+ * the depth-first bias; a worker's discovered children enter the shared queue
623
+ * only when its sweep completes (see record). The surface SET matches a serial
624
+ * crawl — dedup sets are shared and mutated synchronously — only dup-key
625
+ * suffix attribution can vary with timing.
626
+ */
627
+ async function runPool(primary, opts, st, counters) {
628
+ const target = Math.max(1, opts.workers ?? 1);
629
+ const pages = [primary];
630
+ while (opts.newPage && pages.length < target) {
631
+ const extra = await opts.newPage();
632
+ if (opts.resetStorage)
633
+ await armResetStorage(extra);
634
+ pages.push(extra);
635
+ }
636
+ let active = 0;
637
+ await new Promise((resolve) => {
638
+ const pump = () => {
639
+ while (pages.length > 0 && st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
640
+ const entry = st.queue.pop(); // LIFO → depth-first
641
+ if (entry.depth >= opts.maxDepth)
642
+ continue;
643
+ const worker = pages.pop();
644
+ active++;
645
+ const sink = [];
646
+ sweepState(worker, opts, entry, st, sink)
647
+ .then((r) => {
648
+ counters.tried += r.tried;
649
+ counters.skipped += r.skipped;
650
+ })
651
+ .catch(() => {
652
+ /* fail-soft: the state's surface was already captured in place */
653
+ })
654
+ .finally(() => {
655
+ st.queue.push(...sink);
656
+ pages.push(worker);
657
+ active--;
658
+ pump();
659
+ });
660
+ }
661
+ if (active === 0 && (st.queue.length === 0 || st.surfaces.length >= opts.maxStates))
662
+ resolve();
663
+ };
664
+ pump();
665
+ });
666
+ }
440
667
  /** Depth-first discovery + in-place capture of every reachable surface. Depth-first
441
668
  * so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
442
669
  * branch is fresh; with no budget, order affects time-to-depth, not coverage. */
443
670
  async function discover(page, opts) {
444
671
  fs.mkdirSync(opts.out, { recursive: true });
672
+ // Watch the entry load's data requests so the automatic data states know what
673
+ // to stall/fail. Armed before navigation to see the app's own boot fetches.
674
+ const dataUrls = new Set();
675
+ const onRequest = (req) => {
676
+ const t = req.resourceType();
677
+ if (t === 'fetch' || t === 'xhr')
678
+ dataUrls.add(req.url());
679
+ };
680
+ page.on('request', onRequest);
445
681
  await gotoFresh(page, opts);
682
+ // No widths given? Detect the page's real @media breakpoints (like the
683
+ // one-shot path does) and sweep one width per band — automatically. Detection
684
+ // reads every stylesheet; if one is cross-origin/unreadable it falls back to
685
+ // the single default width rather than dying.
686
+ if (opts.widths.length === 0) {
687
+ const widths = await detectViewportWidths(page).catch(() => [1280]);
688
+ opts = { ...opts, widths };
689
+ if ((widths[0] ?? 1280) !== 1280)
690
+ await gotoFresh(page, opts); // re-pin discovery width BEFORE the base fingerprint
691
+ }
692
+ page.off('request', onRequest);
446
693
  const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
447
694
  const fp = await fingerprint(page);
448
695
  const st = {
@@ -456,22 +703,21 @@ async function discover(page, opts) {
456
703
  captured: 0,
457
704
  failed: [],
458
705
  };
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;
706
+ await record(page, opts, [], 0, fp, st, st.queue, false, false);
707
+ // Automatic data states of the entry page — the loading skeleton and the
708
+ // error render exist in every data-driven app but almost never in a click
709
+ // path. Captured out of the box; identical-to-base renders dedup away.
710
+ if (opts.dataStates !== false && dataUrls.size > 0) {
711
+ await recordDataState(page, opts, 'loading', st);
712
+ await recordDataState(page, opts, 'error', st);
469
713
  }
714
+ const counters = { tried: 0, skipped: 0 };
715
+ await runPool(page, opts, st, counters);
470
716
  const missing = defined.filter((c) => !st.classes.has(c)).sort();
471
717
  return {
472
718
  surfaces: st.surfaces,
473
- actionsTried,
474
- skipped,
719
+ actionsTried: counters.tried,
720
+ skipped: counters.skipped,
475
721
  captured: st.captured,
476
722
  failed: st.failed,
477
723
  coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
@@ -482,19 +728,21 @@ async function discover(page, opts) {
482
728
  * natural termination by default. Returns the surfaces mapped (with the click-path
483
729
  * that reached each), how many actions were tried/skipped, and captured/failed.
484
730
  */
731
+ /** Clear storage before the app's code runs on EVERY load, so each gotoFresh is
732
+ * a clean slate in one navigation (no clear-then-reload round trip). */
733
+ async function armResetStorage(page) {
734
+ await page.addInitScript(() => {
735
+ try {
736
+ localStorage.clear();
737
+ sessionStorage.clear();
738
+ }
739
+ catch {
740
+ /* storage unavailable (e.g. file://) — ignore */
741
+ }
742
+ });
743
+ }
485
744
  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
- }
745
+ if (opts.resetStorage)
746
+ await armResetStorage(page);
499
747
  return discover(page, opts);
500
748
  }
package/dist/describe.js CHANGED
@@ -18,7 +18,10 @@ const PALETTE = [
18
18
  ['pink', [236, 64, 122]],
19
19
  ];
20
20
  function parseColor(v) {
21
- const m = v.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)/i);
21
+ // Anchored: only a value that IS a colour parses. An embedded colour inside a
22
+ // gradient/shadow/url must not stand in for the whole value — that once made a
23
+ // report show the same "representative" rgba on both sides of a real diff.
24
+ const m = v.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)$/i);
22
25
  if (!m)
23
26
  return null;
24
27
  return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } fr
2
2
  export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
3
  export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
4
4
  export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
5
- export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep } from './crawl-surfaces.js';
5
+ export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep, SetupStep, } from './crawl-surfaces.js';
6
+ export { loadSetupSteps } from './capture-url.js';
6
7
  export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
7
8
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
8
9
  export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
2
  export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
3
  export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
4
+ export { loadSetupSteps } from './capture-url.js';
4
5
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
5
6
  export { coverageGaps } from './coverage.js';
6
7
  export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
package/dist/report.d.ts CHANGED
@@ -71,4 +71,5 @@ export type ReportResult = {
71
71
  export declare function summarizeProps(props: PropChange[]): PropChange[];
72
72
  /** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
73
73
  export declare function prettyLabel(p: string, cls: string): string;
74
+ export declare function excerptPair(before: string, after: string): [string, string];
74
75
  export declare function generateStyleMapReport(opts: ReportOptions): ReportResult;
package/dist/report.js CHANGED
@@ -292,7 +292,9 @@ const orderIdx = (p) => {
292
292
  return i === -1 ? PROP_ORDER.length : i;
293
293
  };
294
294
  function cleanVal(v) {
295
- let s = v.replace(/-?\d+\.\d+/g, (m) => String(Math.round(parseFloat(m) * 10) / 10));
295
+ // Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2
296
+ // and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
297
+ let s = v;
296
298
  if (!s.includes('(')) {
297
299
  const toks = s.split(' ');
298
300
  if (toks.length > 1 && new Set(toks).size === 1)
@@ -512,11 +514,48 @@ function regionHeading(regionPaths, findings) {
512
514
  // A "no value here" marker renders as an em dash; colours render as `#hex` so the
513
515
  // table cell shows GitHub's live swatch.
514
516
  const cell = (v) => (isNonValue(v) ? '—' : `\`${toHex(v)}\``);
517
+ // Long values (gradients, data URIs) would swamp the table, but truncating each
518
+ // side independently can show two IDENTICAL cells for a real diff: both
519
+ // sides of a gradient rendered as the same rgba while the actual change — a
520
+ // dropped `0px` stop — was elsewhere in the string. Instead, trim the shared
521
+ // prefix/suffix and show each side's differing substring with a little context.
522
+ const EXCERPT_AT = 64; // both sides at or under this → show whole values
523
+ const EXCERPT_CTX = 12; // chars of shared context kept around the diff
524
+ const EXCERPT_MAX = 96; // hard cap per excerpt; the diff itself may be huge
525
+ export function excerptPair(before, after) {
526
+ if (before.length <= EXCERPT_AT && after.length <= EXCERPT_AT)
527
+ return [before, after];
528
+ let p = 0;
529
+ while (p < before.length && p < after.length && before[p] === after[p])
530
+ p++;
531
+ let s = 0;
532
+ const maxS = Math.min(before.length, after.length) - p;
533
+ while (s < maxS && before[before.length - 1 - s] === after[after.length - 1 - s])
534
+ s++;
535
+ const cut = (v) => {
536
+ const start = Math.max(0, p - EXCERPT_CTX);
537
+ let end = Math.min(v.length, v.length - s + EXCERPT_CTX);
538
+ if (end - start > EXCERPT_MAX)
539
+ end = start + EXCERPT_MAX;
540
+ return (start > 0 ? '…' : '') + v.slice(start, end) + (end < v.length ? '…' : '');
541
+ };
542
+ return [cut(before), cut(after)];
543
+ }
544
+ /** Before/After cells as a pair, so long values excerpt around their actual diff. */
545
+ function cellPair(before, after) {
546
+ if (isNonValue(before) || isNonValue(after))
547
+ return [cell(before), cell(after)];
548
+ const [b, a] = excerptPair(before, after);
549
+ return [`\`${toHex(b)}\``, `\`${toHex(a)}\``];
550
+ }
515
551
  function beforeAfterTable(rows) {
516
552
  return [
517
553
  '| Property | Before | After |',
518
554
  '| --- | --- | --- |',
519
- ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.before)} | ${cell(r.after)} |`),
555
+ ...rows.map((r) => {
556
+ const [b, a] = cellPair(r.before, r.after);
557
+ return `| \`${r.prop}\` | ${b} | ${a} |`;
558
+ }),
520
559
  ];
521
560
  }
522
561
  // A brand-new element has no meaningful "before", so its resting style renders
@@ -546,10 +585,12 @@ function styleSection(styles, added) {
546
585
  function statesSection(states, added) {
547
586
  const rows = [];
548
587
  for (const st of states)
549
- for (const c of summarizeProps(st.props))
588
+ for (const c of summarizeProps(st.props)) {
589
+ const [b, a] = cellPair(c.before, c.after);
550
590
  rows.push(added
551
591
  ? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
552
- : `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
592
+ : `| \`:${st.state}\` | \`${c.prop}\` | ${b} → ${a} |`);
593
+ }
553
594
  if (!rows.length)
554
595
  return [];
555
596
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.3.0",
3
+ "version": "3.5.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",