styleproof 4.4.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,21 @@ 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
+
10
25
  ## [4.4.0] - 2026-07-13
11
26
 
12
27
  ### 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
 
@@ -1012,7 +1015,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
1012
1015
 
1013
1016
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
1014
1017
 
1015
- - `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`.
1016
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.
1017
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.
1018
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "4.4.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",