styleproof 4.3.0 → 4.4.1

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,43 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.4.1] - 2026-07-13
11
+
12
+ ### Changed
13
+
14
+ - **`styleproof-init` now scaffolds the least-work v4 map loop.** Pull-request
15
+ CI restores exact base/head bundles from `styleproof-maps`; a compatible base
16
+ hit plus head miss captures only the head, while a base miss rebuilds the
17
+ pair. Every fallback capture is bound to its explicit commit SHA and
18
+ published, so the same cold work is not repeated on later pull requests.
19
+ Temporary CI maps live under `runner.temp`, outside the checkout, and the
20
+ fallback installs Chromium before capture. The generated Action pin is `v4`.
21
+ - **The generated pre-push hook is restore-first too.** Re-pushing an already
22
+ published commit restores its exact bundle and skips the browser; a real miss
23
+ captures once and requires a clean SHA-keyed upload before the push proceeds.
24
+
25
+ ## [4.4.0] - 2026-07-13
26
+
27
+ ### Changed
28
+
29
+ - **Spec-driven captures now run in parallel across Playwright workers.** The
30
+ capture describe declares `parallel` mode itself, so surface×width tests fan
31
+ out over the consumer's `workers` even when the project pins
32
+ `fullyParallel: false` for its behaviour suite. Every generated test was
33
+ already independent (own page, own map/HAR files, own self-check; ledger and
34
+ manifest tests mkdir and tolerate any order), so the maps are byte-identical —
35
+ a real consumer's 150-capture run dropped from 24.5 to 6.0 minutes at 4
36
+ workers on the same box. A spec file whose OWN sibling tests read the captured
37
+ maps in file order can opt out with `parallel: false`. Found while
38
+ dogfooding against a large single-route consumer app.
39
+
40
+ ### Fixed
41
+
42
+ - **An absolute `STYLEMAP_DIR` (or `--dir`) is respected as-is.** It was joined
43
+ under `baseDir`, so `STYLEMAP_DIR=/abs/path styleproof-map` stranded the maps
44
+ at `.styleproof/maps/abs/path` where no consumer looks. Relative dirs nest
45
+ under `baseDir` unchanged. Also found dogfooding against a consumer app.
46
+
10
47
  ## [4.3.0] - 2026-07-13
11
48
 
12
49
  ### Changed
package/README.md CHANGED
@@ -104,8 +104,8 @@ Either way the generated spec runs as-is. It also wires everything around it so
104
104
  - widths you never set — **omit `widths`** and StyleProof sweeps your app's real `@media` breakpoints automatically;
105
105
  - determinism you never set up — network settle, frozen clock, animation freeze, and framework-noise filtering are all on by default (see [Deterministic by default](#deterministic-by-default));
106
106
  - `.gitignore` entries for `.styleproof/`, `test-results/`, and `playwright-report/`;
107
- - a **cache-first CI workflow** that restores reusable maps from the `styleproof-maps` branch and generates the report without a browser when both maps are already built;
108
- - a **pre-push hook** (`.husky/` if present, else `.githooks/`) that captures each pushed commit and publishes the bundle to the `styleproof-maps` branch — CI's hot path stays report-only, and maps never get committed to the PR branch;
107
+ - a **cache-first CI workflow** that restores reusable maps from the `styleproof-maps` branch, captures only a missing head when the base is compatible, and publishes every cold fallback so later runs stay browserless;
108
+ - a **pre-push hook** (`.husky/` if present, else `.githooks/`) that restores an already-published commit or captures and publishes it once — CI's hot path stays report-only, repeated pushes do no browser work, and maps never get committed to the PR branch;
109
109
  - the **approval workflow** (`styleproof-approve.yml`) that turns the `StyleProof` status green when a reviewer ticks **Approve all changes** — so the review gate is complete, not half-wired (it activates once the init PR merges, since GitHub runs `issue_comment` workflows only from your default branch).
110
110
 
111
111
  ### 2. Capture, then diff
@@ -133,9 +133,12 @@ automatically: in GitHub Actions it uses the PR base/head SHAs; locally it check
133
133
  pre-push hook `styleproof-init` installs runs `styleproof-map` on every push
134
134
  that can affect render (skip one with `STYLEPROOF_SKIP_CAPTURE=1 git push`).
135
135
  On the PR, CI first restores the base/head bundles and only generates the
136
- report — no build, no browser. If either bundle is missing or incompatible,
137
- CI recaptures both sides in the same pinned environment before reporting.
138
- Correctness wins over a stale cache, but the hot path is report-only.
136
+ report — no build, no browser. If only the head bundle is missing, CI keeps the
137
+ compatible restored base and captures/publishes only the head. If the base
138
+ bundle is missing or incompatible, CI recaptures and publishes both sides in
139
+ the same pinned environment before reporting. Cold fallback work therefore
140
+ becomes reusable instead of recurring on the next PR. Correctness wins over a
141
+ stale cache, but the hot path is report-only.
139
142
 
140
143
  > **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the **real browser build** (`browser().version()`) in its manifest — the npm `@playwright/test` version is only a proxy, and the actual Chromium binary can change while it holds constant (a `playwright install` re-download, a different `PLAYWRIGHT_BROWSERS_PATH`, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. This guard needs a `styleproof-manifest.json` on **both** sides. Since **v4** a two-directory `styleproof-diff`/`styleproof-report` where a side ships maps but no manifest **refuses to compare**: it exits `2` (usage/capture error) naming the bare side(s), because the environment can't be verified and captures from different browser builds or platforms would diff as false changes. Re-capture with current StyleProof — `styleproof-map`, or `styleproof-capture` for a one-shot design diff (both write a manifest); maps without one are unsupported. (A dir with _no maps at all_ is "no baseline yet", not a bare bundle — that stays the first-adoption review path, exit `3`.) **Installed fonts are your responsibility:** they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.
141
144
 
@@ -986,6 +989,7 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
986
989
  | `dataResidue` | `'gate'` | Name data-boundary (`replayUrl`) requests that **fail** during capture (network error / 4xx/5xx — the fallback branch got captured). Always warned + recorded; `'gate'` (the default) also blocks the diff on an unacknowledged one, `'warn'` is the opt-out that records + warns without gating. See [Data residue](#data-residue-a-failed-data-request-is-named-not-swallowed). |
987
990
  | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
988
991
  | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
992
+ | `parallel` | `true` | Run the generated capture tests across Playwright workers, even when the project config pins `fullyParallel: false` — every capture test is independent, so a multi-surface spec speeds up ~workers×. Set `false` only for a spec file whose own sibling tests read the captured maps in file order. |
989
993
  | `selfCheck` | on while recording | Capture each surface twice and fail on any difference — proves the capture is deterministic. Off on the replay run; `STYLEPROOF_SELFCHECK=1` forces both. |
990
994
  | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
991
995
  | `baseDir` | `__stylemaps__` | Output root directory. |
@@ -1011,7 +1015,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
1011
1015
 
1012
1016
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
1013
1017
 
1014
- - `styleproof-init` — scaffold the gate: the capture spec (inventory guard on; Next.js repos get route discovery + the coverage guard, others a crawl-by-default spec), a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, the cache-first report workflow, the approval workflow, and the pre-push publish hook. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
1018
+ - `styleproof-init` — scaffold the gate: the capture spec (inventory guard on; Next.js repos get route discovery + the coverage guard, others a crawl-by-default spec), a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, the restore-first report workflow, the approval workflow, and the restore-or-publish pre-push hook. One command. The CI hot path restores exact-SHA maps and runs no browser; a compatible base hit plus head miss captures only the head; a base miss captures the pair; every fallback is published for reuse. 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`.
1015
1019
  - `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.
1016
1020
  - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an unacknowledged failing data endpoint under an armed `dataResidue: 'gate'`, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **manifest-less side** — since **v4**, a two-directory compare where a side ships maps but no `styleproof-manifest.json` is refused loudly, naming the bare side(s), because the same-environment guard can't be enforced without one; re-capture with current StyleProof; **and** a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present — surfaces captured only on the **head** side (a surface present only on the **base** side is a **removed** surface, a reviewable change: exit `1`) — (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no maps at all (and hence no manifest), meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
1017
1021
  - `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).
@@ -366,13 +366,13 @@ export default defineConfig({
366
366
  const CI_PATH = '.github/workflows/styleproof.yml';
367
367
  const CI_WORKFLOW = `name: StyleProof
368
368
 
369
- # Cache-first v3 flow:
369
+ # Cache-first v4 flow:
370
370
  # - run \`styleproof-map\` locally after committing to build/upload this commit's map
371
371
  # outside CI when possible;
372
372
  # - CI restores base/head maps from the styleproof-maps branch and generates the
373
373
  # report without a browser;
374
- # - on cache miss, CI recaptures both sides in one pinned environment so the
375
- # comparison stays valid.
374
+ # - on a head-only miss, CI captures/publishes only the head; on a base miss it
375
+ # recaptures/publishes the pair in one pinned environment.
376
376
  on:
377
377
  pull_request:
378
378
  types: [opened, synchronize, reopened, closed]
@@ -400,13 +400,16 @@ ${PM.setup}
400
400
  run: |
401
401
  BASE_SHA="\${{ github.event.pull_request.base.sha }}"
402
402
  HEAD_SHA="\${{ github.event.pull_request.head.sha }}"
403
- rm -rf __stylemaps__
403
+ MAP_ROOT="\${{ runner.temp }}/styleproof-maps"
404
+ rm -rf "$MAP_ROOT"
404
405
  set +e
405
- ${PM.exec(`styleproof-map --restore --sha "$BASE_SHA" --dir base --base-dir __stylemaps__ --spec ${specPath}`)}
406
+ ${PM.exec(`styleproof-map --restore --sha "$BASE_SHA" --dir base --base-dir "$MAP_ROOT" --spec ${specPath}`)}
406
407
  base_code=$?
407
- ${PM.exec(`styleproof-map --restore --sha "$HEAD_SHA" --dir head --base-dir __stylemaps__ --spec ${specPath}`)}
408
+ ${PM.exec(`styleproof-map --restore --sha "$HEAD_SHA" --dir head --base-dir "$MAP_ROOT" --spec ${specPath}`)}
408
409
  head_code=$?
409
410
  set -e
411
+ echo "base-hit=$([ "$base_code" -eq 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
412
+ echo "head-hit=$([ "$head_code" -eq 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
410
413
  if [ "$base_code" -eq 0 ] && [ "$head_code" -eq 0 ]; then
411
414
  echo "capture-needed=false" >> "$GITHUB_OUTPUT"
412
415
  else
@@ -418,27 +421,40 @@ ${PM.setup}
418
421
  run: |
419
422
  BASE_SHA="\${{ github.event.pull_request.base.sha }}"
420
423
  HEAD_SHA="\${{ github.event.pull_request.head.sha }}"
421
- rm -rf __stylemaps__
422
-
423
- git checkout "$BASE_SHA"
424
- ${PM.install}
425
- if [ -f "${specPath}" ]; then
426
- ${PM.exec(`styleproof-map --spec ${specPath} --dir base --base-dir __stylemaps__ --keep-har --no-upload`)}
424
+ MAP_ROOT="\${{ runner.temp }}/styleproof-maps"
425
+
426
+ if [ "\${{ steps.maps.outputs.base-hit }}" != 'true' ]; then
427
+ # Without a compatible base bundle, rebuild and publish the pair in
428
+ # one pinned environment. This is the expensive cold path.
429
+ rm -rf "$MAP_ROOT"
430
+ git checkout "$BASE_SHA"
431
+ ${PM.install}
432
+ ${PM.exec('playwright install --with-deps chromium')}
433
+ if [ -f "${specPath}" ]; then
434
+ ${PM.exec(`styleproof-map --spec ${specPath} --dir base --base-dir "$MAP_ROOT" --keep-har --sha "$BASE_SHA" --upload`)}
435
+ else
436
+ mkdir -p "$MAP_ROOT/base"
437
+ fi
438
+
439
+ git checkout "$HEAD_SHA"
440
+ ${PM.install}
441
+ ${PM.exec('playwright install --with-deps chromium')}
427
442
  else
428
- mkdir -p __stylemaps__/base
443
+ # A compatible base hit proves the current head environment. Keep
444
+ # that restored base and capture only the missing head.
445
+ rm -rf "$MAP_ROOT/head"
446
+ ${PM.exec('playwright install --with-deps chromium')}
429
447
  fi
430
448
 
431
- git checkout "$HEAD_SHA"
432
- ${PM.install}
433
- if find __stylemaps__/base -name '*.har' -print -quit | grep -q .; then
434
- STYLEPROOF_REPLAY_FROM=__stylemaps__/base ${PM.exec(`styleproof-map --spec ${specPath} --dir head --base-dir __stylemaps__ --no-upload`)}
449
+ if find "$MAP_ROOT/base" -name '*.har' -print -quit | grep -q .; then
450
+ STYLEPROOF_REPLAY_FROM="$MAP_ROOT/base" ${PM.exec(`styleproof-map --spec ${specPath} --dir head --base-dir "$MAP_ROOT" --sha "$HEAD_SHA" --upload`)}
435
451
  else
436
- ${PM.exec(`styleproof-map --spec ${specPath} --dir head --base-dir __stylemaps__ --no-upload`)}
452
+ ${PM.exec(`styleproof-map --spec ${specPath} --dir head --base-dir "$MAP_ROOT" --sha "$HEAD_SHA" --upload`)}
437
453
  fi
438
- - uses: BenSheridanEdwards/StyleProof@v3
454
+ - uses: BenSheridanEdwards/StyleProof@v4
439
455
  with:
440
- baseline-dir: __stylemaps__/base
441
- fresh-dir: __stylemaps__/head
456
+ baseline-dir: \${{ runner.temp }}/styleproof-maps/base
457
+ fresh-dir: \${{ runner.temp }}/styleproof-maps/head
442
458
  require-approval: true
443
459
 
444
460
  prune:
@@ -600,7 +616,10 @@ const HOOK = `#!/bin/sh
600
616
  # Skip (e.g. a docs-only push): STYLEPROOF_SKIP_CAPTURE=1 git push
601
617
  set -e
602
618
  [ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
603
- ${PM.exec(`styleproof-map --spec ${specPath}`)}
619
+ head_sha="$(git rev-parse HEAD)"
620
+ if ! ${PM.exec(`styleproof-map --restore --sha "$head_sha" --dir current --base-dir .styleproof/maps --spec ${specPath}`)}; then
621
+ ${PM.exec(`styleproof-map --spec ${specPath} --sha "$head_sha" --upload`)}
622
+ fi
604
623
  ${PM.exec('styleproof-diff')} || true # advisory: show drift before CI does
605
624
  `;
606
625
  const hookDir = fs.existsSync('.husky') ? '.husky' : '.githooks';
@@ -627,8 +646,8 @@ if (touched.length) {
627
646
  console.log('\nHow the gate works — it runs on your first PR with no extra steps:');
628
647
  console.log(' 1. Commit and open a PR. CI captures the base and head surfaces in one pinned');
629
648
  console.log(' environment and posts the StyleProof report — no local step required.');
630
- console.log(' 2. The pre-push hook captures each pushed commit into .styleproof/ and publishes');
631
- console.log(' the bundle to the styleproof-maps branch; CI restores it by SHA and generates');
649
+ console.log(' 2. The pre-push hook restores an existing exact-SHA map or captures once and');
650
+ console.log(' publishes it to styleproof-maps; CI restores by SHA and generates');
632
651
  console.log(' the report without a browser. Maps never get committed to the PR branch.');
633
652
  console.log(' Skip a push that cannot affect render: STYLEPROOF_SKIP_CAPTURE=1 git push');
634
653
  console.log(' 3. Merge this PR. The approval workflow only runs from your default branch, so');
@@ -253,7 +253,9 @@ function runVariantCrawl(env) {
253
253
  if (status !== 0) process.exit(status);
254
254
  }
255
255
 
256
- const targetDir = path.join(baseDir, dir);
256
+ // An ABSOLUTE STYLEMAP_DIR/--dir is respected as-is; a relative one nests under
257
+ // baseDir (.styleproof/maps by default) — mirrors the runner's resolveOutputDir.
258
+ const targetDir = path.isAbsolute(dir) ? dir : path.join(baseDir, dir);
257
259
  // Sample the tree state the capture is ABOUT to render, so the manifest can bind the
258
260
  // map to it. A capture runs for minutes; if the source is edited or HEAD moves in that
259
261
  // window, the map renders one state but would otherwise be stamped clean@post-HEAD and
package/dist/runner.d.ts CHANGED
@@ -150,6 +150,14 @@ export type DefineOptions = {
150
150
  * `selfCheck` explicitly to override.
151
151
  */
152
152
  selfCheck?: boolean;
153
+ /**
154
+ * Run the generated capture tests in PARALLEL across Playwright workers
155
+ * (default true). Every capture test is independent, so parallel is safe and
156
+ * ~workers× faster on a multi-surface spec — even when the project config
157
+ * pins `fullyParallel: false`. Set false ONLY for a spec file whose OTHER
158
+ * tests read the captured maps in file order (an in-file assertion suite).
159
+ */
160
+ parallel?: boolean;
153
161
  /**
154
162
  * Opt-in content layer (default OFF). Record each element's own rendered text
155
163
  * so the report's optional content section can surface copy changes (run
@@ -239,6 +247,10 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
239
247
  * forces it on either way.
240
248
  */
241
249
  export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
250
+ /** Resolve a capture output dir: an ABSOLUTE `dir` is respected as-is (a user's
251
+ * `STYLEMAP_DIR=/abs/path` must not be buried under `baseDir`); a relative one
252
+ * nests under `baseDir` as before. */
253
+ export declare function resolveOutputDir(baseDir: string, dir: string): string;
242
254
  /**
243
255
  * Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
244
256
  * default. Lets CLIs and CI redirect capture into cache/fallback dirs without
package/dist/runner.js CHANGED
@@ -304,7 +304,7 @@ async function pinInputs(page, harName, s) {
304
304
  }
305
305
  }
306
306
  else {
307
- await page.routeFromHAR(path.join(s.baseDir, s.dir, harName), {
307
+ await page.routeFromHAR(path.join(resolveOutputDir(s.baseDir, s.dir), harName), {
308
308
  url: s.replayUrl,
309
309
  update: true,
310
310
  updateContent: 'embed', // single portable file, no sidecar resources
@@ -443,7 +443,7 @@ async function capturePopupCandidate(page, surface, width, height, s, options, c
443
443
  return;
444
444
  }
445
445
  }
446
- const stem = path.join(s.baseDir, s.dir, `${surface.key}-${popupId}@${width}`);
446
+ const stem = path.join(resolveOutputDir(s.baseDir, s.dir), `${surface.key}-${popupId}@${width}`);
447
447
  saveStyleMap(`${stem}.json.gz`, map);
448
448
  if (s.screenshots)
449
449
  await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
@@ -495,7 +495,7 @@ async function captureSurface(page, surface, width, s) {
495
495
  // Attach data-residue AFTER the self-check re-run so both runs' failures are folded
496
496
  // (deduped in the watcher). Warn always; the recorded residue is what the gate reads.
497
497
  attachDataResidue(map, residue.residue());
498
- const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
498
+ const stem = path.join(resolveOutputDir(s.baseDir, s.dir), `${surface.key}@${width}`);
499
499
  saveStyleMap(`${stem}.json.gz`, map);
500
500
  if (s.screenshots) {
501
501
  // captureStyleMap froze animations/transitions, so this is the same settled
@@ -533,6 +533,12 @@ function attachDataResidue(map, residue) {
533
533
  export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
534
534
  return env === '1' || !replayFrom;
535
535
  }
536
+ /** Resolve a capture output dir: an ABSOLUTE `dir` is respected as-is (a user's
537
+ * `STYLEMAP_DIR=/abs/path` must not be buried under `baseDir`); a relative one
538
+ * nests under `baseDir` as before. */
539
+ export function resolveOutputDir(baseDir, dir) {
540
+ return path.isAbsolute(dir) ? dir : path.join(baseDir, dir);
541
+ }
536
542
  /**
537
543
  * Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
538
544
  * default. Lets CLIs and CI redirect capture into cache/fallback dirs without
@@ -602,7 +608,7 @@ function resolveSettings(c) {
602
608
  */
603
609
  function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfaces) {
604
610
  test('styleproof coverage ledger', () => {
605
- const outDir = path.join(settings.baseDir, dir);
611
+ const outDir = resolveOutputDir(settings.baseDir, dir);
606
612
  fs.mkdirSync(outDir, { recursive: true });
607
613
  // Determinism basis: self-check ON proves it (a drift would have failed the capture);
608
614
  // else a replay run is deterministic by construction; else it's unproven.
@@ -648,7 +654,7 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfac
648
654
  function writeBrowserBuildTest(settings, dir) {
649
655
  test('styleproof browser build', ({ page }) => {
650
656
  const version = page.context().browser()?.version();
651
- const outDir = path.join(settings.baseDir, dir);
657
+ const outDir = resolveOutputDir(settings.baseDir, dir);
652
658
  writeBrowserBuildSidecar(outDir, version);
653
659
  writeCaptureManifest({ dir: outDir, screenshots: settings.screenshots });
654
660
  });
@@ -681,6 +687,16 @@ export function defineStyleMapCapture(options) {
681
687
  return;
682
688
  const settings = resolveSettings(options);
683
689
  test.describe('styleproof capture', () => {
690
+ // Every generated test is independent — its own page, its own map/HAR files,
691
+ // its own self-check; the ledger/manifest tests mkdir and tolerate any order.
692
+ // Declare the block PARALLEL so captures fan out across the consumer's
693
+ // Playwright workers even when the project pins `fullyParallel: false` for
694
+ // its behaviour suite (150 serial surface×width captures is a ~25-minute CI
695
+ // step; 4 workers make it ~4x faster with byte-identical maps).
696
+ // `parallel: false` keeps file order for specs whose own sibling tests read
697
+ // the captured maps.
698
+ if (options.parallel !== false)
699
+ test.describe.configure({ mode: 'parallel' });
684
700
  writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, captureSurfaces);
685
701
  writeBrowserBuildTest(settings, dir);
686
702
  for (const surface of captureSurfaces) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "4.3.0",
3
+ "version": "4.4.1",
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",