styleproof 3.1.0 → 3.1.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,35 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.1] - 2026-06-27
11
+
12
+ ### Changed
13
+
14
+ - **Release workflow now verifies npm publication before tagging.** A release
15
+ fails before creating tags or GitHub Releases if `NPM_TOKEN` is missing, and
16
+ it verifies `styleproof@<version>` on npm after publishing or detecting an
17
+ already-published version.
18
+ - **Generated pre-push hooks now restore no-op map churn before committing.** The
19
+ hook captures through `styleproof-map`, runs a semantic diff against `HEAD`, and
20
+ restores `stylemaps/` when the refreshed map is identical. When the map really
21
+ changes, the hook prints the diff plus a concrete live-state/replay hint before
22
+ creating the map commit.
23
+
24
+ ### Fixed
25
+
26
+ - **Real cursor hover no longer contaminates the resting style map.** Capture now
27
+ parks the mouse over an ignored 1px hover sink before reading, matching the
28
+ existing focus blur behavior. The canonical resting map stays unhovered while
29
+ the forced `:hover` layer still records the hover delta.
30
+ - **`styleproof-map` now writes lean committed maps by default.** Recorded HAR
31
+ files are removed after successful capture so private API responses are not
32
+ accidentally committed by the default pre-push flow. Use `--keep-har` or
33
+ `STYLEPROOF_KEEP_HAR=1` for explicit record/replay workflows.
34
+ - **Generated pre-push hooks now clean untracked no-op artifacts.** When the
35
+ refreshed map is semantically identical to `HEAD`, the hook restores tracked
36
+ `stylemaps/` files and removes untracked files under `stylemaps/current`, so a
37
+ clean push leaves no generated capture debris behind.
38
+
10
39
  ## [3.1.0] - 2026-06-27
11
40
 
12
41
  ### Changed
@@ -47,6 +76,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
47
76
  generated tests are registered through the package runner. It now targets the
48
77
  capture suite with Playwright's grep path, matching the public
49
78
  `styleproof-init` → `styleproof-map` → `styleproof-diff` flow.
79
+ - **Clean StyleProof PR runs now leave a visible receipt.** The Action creates or
80
+ updates its PR comment with `No visual changes detected.` even when there is no
81
+ existing report comment yet, and `styleproof-init` scaffolds the same
82
+ no-change PR comment for the browser-less committed-map workflow.
50
83
  - **Layout-equivalent auto-margin drift no longer creates phantom diffs.** Some
51
84
  browser/forced-state combinations can report horizontal `margin-left` /
52
85
  `margin-right` / logical margin equivalents differently even when the captured
@@ -889,7 +922,8 @@ number)`), so each viewport band can capture at its own height. Default remains
889
922
  - `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
890
923
  property, and state that drifted (exit 1).
891
924
 
892
- [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.0...HEAD
925
+ [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.1...HEAD
926
+ [3.1.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.0...v3.1.1
893
927
  [3.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.2...v3.1.0
894
928
  [3.0.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.1...v3.0.2
895
929
  [3.0.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.0...v3.0.1
package/README.md CHANGED
@@ -16,6 +16,7 @@ On every PR, StyleProof diffs the PR head's computed-style map against the base
16
16
 
17
17
  - A **lean summary comment** linking to a committed side-by-side report — the report is the complete source of truth (**one section per distinct change**, with a before/after cropped screenshot cropped from the same rectangle so the two sides line up exactly, **plain-English bullets that tell you what to look for** — `columns: 2 → 3`, `recoloured cyan → amber` — and the exact property changes). The comment never duplicates the report, so the two can't drift, and it renders identically on public and private repos.
18
18
  - A single **Approve all changes** checkbox in the comment, driving a `StyleProof` commit status: red until one tick signs off every change, green when there are none. The reviewer who ticks it is recorded inline (_approved by @them_), sourced from the commit status so it survives a report re-run.
19
+ - **Clean runs leave a receipt too.** When no visual changes are detected, StyleProof still creates or updates the PR comment with `✓ No visual changes detected.`, so a green status has a visible record instead of only a silent check.
19
20
  - **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
20
21
  - The diff is always **head-vs-base**, so the report is _exactly what this PR changes_ — whether the maps are captured fresh in CI or (by default) committed pre-push and diffed browser-free in CI. See [Quickstart](#quickstart).
21
22
 
@@ -167,7 +168,7 @@ It scaffolds:
167
168
 
168
169
  - a **capture spec** (`e2e/styleproof.spec.ts`) describing your surfaces (a Next.js app gets its routes _and_ the coverage guard wired automatically — see below);
169
170
  - a **`playwright.config.ts`** that builds and serves a **production build** (never a flaky dev server) and captures surfaces **in parallel** (`fullyParallel`);
170
- - a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, commits it as a lean `.json.gz` under `stylemaps/`, and **pushes it with your branch — one `git push`, never two**;
171
+ - a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, restores no-op map churn, commits real map changes as lean `.json.gz` files under `stylemaps/`, and **pushes them with your branch — one `git push`, never two**;
171
172
  - a **browser-less CI workflow** that diffs your committed map against the base branch.
172
173
 
173
174
  Describe your surfaces — **omit `widths`** and StyleProof sweeps your real `@media` breakpoints automatically:
@@ -195,15 +196,18 @@ npx styleproof-map
195
196
  npx styleproof-diff
196
197
  ```
197
198
 
198
- `styleproof-map` captures this branch into `stylemaps/current`. `styleproof-diff`
199
- then compares that committed map against the base branch automatically: in GitHub
200
- Actions it uses the PR base; locally it checks `branch.<name>.gh-merge-base`, then
201
- the current GitHub PR base via `gh pr view` (handy for stacked PRs), then
199
+ `styleproof-map` captures this branch into `stylemaps/current` as lean committed
200
+ maps: HAR recordings are removed by default so private API responses do not land
201
+ in git. Keep them only for an explicit record/replay workflow with
202
+ `styleproof-map --keep-har` (or `STYLEPROOF_KEEP_HAR=1`). `styleproof-diff` then
203
+ compares the committed map against the base branch automatically: in GitHub
204
+ Actions it uses the PR base; locally it checks `branch.<name>.gh-merge-base`,
205
+ then the current GitHub PR base via `gh pr view` (handy for stacked PRs), then
202
206
  `origin/main`, `origin/master`, `main`, and `master`. Pin it when needed with
203
207
  `styleproof-diff main`, `styleproof-diff master`, or `styleproof-diff --base-ref
204
208
  origin/my-base`.
205
209
 
206
- **That's the whole loop.** Capture happens **pre-push on your machine** (where the app already builds and serves); the lean map is committed and pushed; and **CI is a browser-less diff of two precomputed maps** — no build, no browser, just a fast comparison (~5400× cheaper on the compare step than re-capturing both sides in CI). `main` always carries a base map, so every PR is _your code vs the committed base_. Require the `StyleProof` status in branch protection and an unverified visual change can't merge.
210
+ **That's the whole loop.** Capture happens **pre-push on your machine** (where the app already builds and serves); the hook first diffs the refreshed map against `HEAD` and restores it when the semantic map is unchanged, otherwise it commits and pushes the lean map; and **CI is a browser-less diff of two precomputed maps** — no build, no browser, just a fast comparison (~5400× cheaper on the compare step than re-capturing both sides in CI). `main` always carries a base map, so every PR is _your code vs the committed base_. Require the `StyleProof` status in branch protection and an unverified visual change can't merge.
207
211
 
208
212
  > **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same environment (your machine, or a pinned container). The self-check fails loudly on a non-deterministic capture, so drift never passes silently.
209
213
 
@@ -269,7 +273,7 @@ Anything still moving on its own after that is detected as a volatile region and
269
273
  | Handled for you — zero config | How |
270
274
  | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
271
275
  | In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
272
- | Animations, transitions, focus ring, caret | frozen / blurred before the map is read |
276
+ | Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately |
273
277
  | Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
274
278
  | Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
275
279
  | Layout-equivalent horizontal auto margins | ignored only when the captured element rectangle is unchanged |
@@ -309,6 +309,31 @@ remote="$1"
309
309
 
310
310
  ${PM.exec(`styleproof-map --spec ${specPath}`)}
311
311
 
312
+ if git cat-file -e HEAD:stylemaps/current 2>/dev/null; then
313
+ tmp="\${TMPDIR:-/tmp}/styleproof-pre-push-diff.$$"
314
+ set +e
315
+ ${PM.exec('styleproof-diff --base-ref HEAD --max=20')} > "$tmp"
316
+ code=$?
317
+ set -e
318
+
319
+ if [ "$code" -eq 2 ]; then
320
+ cat "$tmp" >&2
321
+ rm -f "$tmp"
322
+ exit 2
323
+ fi
324
+
325
+ if [ "$code" -eq 0 ]; then
326
+ rm -f "$tmp"
327
+ git restore --source=HEAD -- stylemaps
328
+ git clean -fdq -- stylemaps/current
329
+ exit 0
330
+ fi
331
+
332
+ cat "$tmp" >&2
333
+ rm -f "$tmp"
334
+ echo "StyleProof: style map changed. If this was not an intended visual change, pin live states or replay/fixture the data boundary before pushing." >&2
335
+ fi
336
+
312
337
  git add stylemaps
313
338
  git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
314
339
 
@@ -333,6 +358,8 @@ on: pull_request
333
358
 
334
359
  permissions:
335
360
  contents: read
361
+ issues: write
362
+ pull-requests: write
336
363
 
337
364
  jobs:
338
365
  styleproof:
@@ -343,8 +370,52 @@ jobs:
343
370
  fetch-depth: 0 # need the base ref to read its committed map
344
371
  ${PM.setup}
345
372
  - run: ${PM.install}
346
- - name: Diff this branch's committed map against the base
347
- run: ${PM.exec(`styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}"`)}
373
+ - id: diff
374
+ name: Diff this branch's committed map against the base
375
+ shell: bash
376
+ run: |
377
+ set +e
378
+ ${PM.exec(`styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}"`)} | tee styleproof-diff.txt
379
+ code=\${PIPESTATUS[0]}
380
+ set -e
381
+ echo "exit-code=\${code}" >> "$GITHUB_OUTPUT"
382
+ if [ "$code" -eq 0 ]; then
383
+ echo "result=clean" >> "$GITHUB_OUTPUT"
384
+ elif [ "$code" -eq 1 ] || [ "$code" -eq 3 ]; then
385
+ echo "result=changed" >> "$GITHUB_OUTPUT"
386
+ else
387
+ echo "result=error" >> "$GITHUB_OUTPUT"
388
+ fi
389
+ - name: Comment StyleProof result
390
+ if: >-
391
+ always() &&
392
+ github.event_name == 'pull_request' &&
393
+ steps.diff.outputs.result != '' &&
394
+ steps.diff.outputs.result != 'error'
395
+ uses: actions/github-script@v9
396
+ with:
397
+ script: |
398
+ const issue_number = context.payload.pull_request.number;
399
+ const marker = '<!-- styleproof-report -->';
400
+ const clean = '\${{ steps.diff.outputs.result }}' === 'clean';
401
+ const body = clean
402
+ ? marker + '\\n## 🗺️ StyleProof report\\n\\n✓ No visual changes detected.'
403
+ : marker + '\\n## 🗺️ StyleProof report\\n\\nVisual changes detected. See the StyleProof check logs for the computed-style diff.';
404
+ const { data: comments } = await github.rest.issues.listComments({
405
+ ...context.repo,
406
+ issue_number,
407
+ per_page: 100,
408
+ });
409
+ const existing = comments.find((comment) => comment.body && comment.body.includes(marker));
410
+ if (existing) {
411
+ await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
412
+ } else {
413
+ await github.rest.issues.createComment({ ...context.repo, issue_number, body });
414
+ }
415
+ - name: Fail on StyleProof diff
416
+ if: steps.diff.outputs.exit-code != '0'
417
+ shell: bash
418
+ run: exit \${{ steps.diff.outputs.exit-code }}
348
419
  `;
349
420
 
350
421
  function writeFileSafe(file, contents, { force: f } = {}) {
@@ -30,12 +30,13 @@ options:
30
30
  --base-dir <path> output root directory (default: stylemaps)
31
31
  --screenshots keep screenshots for reports (default: off for committed maps)
32
32
  --no-screenshots write lean .json.gz maps only (default)
33
+ --keep-har keep recorded HAR files for advanced replay workflows
33
34
  -h, --help show this help
34
35
 
35
36
  Examples:
36
37
  styleproof-map
37
38
  styleproof-map --spec e2e/styleproof.spec.ts
38
- styleproof-map --dir review --base-dir __stylemaps__ --screenshots
39
+ styleproof-map --dir review --base-dir __stylemaps__ --screenshots --keep-har
39
40
  `;
40
41
 
41
42
  const argv = process.argv.slice(2);
@@ -43,6 +44,7 @@ let spec = 'e2e/styleproof.spec.ts';
43
44
  let dir = process.env.STYLEMAP_DIR ?? 'current';
44
45
  let baseDir = process.env.STYLEPROOF_BASEDIR ?? 'stylemaps';
45
46
  let screenshots = process.env.STYLEPROOF_SCREENSHOTS ?? '0';
47
+ let keepHar = process.env.STYLEPROOF_KEEP_HAR === '1';
46
48
  const playwrightArgs = [];
47
49
 
48
50
  for (let i = 0; i < argv.length; i++) {
@@ -59,6 +61,7 @@ for (let i = 0; i < argv.length; i++) {
59
61
  else if (a.startsWith('--base-dir=')) baseDir = a.slice(11);
60
62
  else if (a === '--screenshots') screenshots = '1';
61
63
  else if (a === '--no-screenshots') screenshots = '0';
64
+ else if (a === '--keep-har') keepHar = true;
62
65
  else if (a.startsWith('--')) {
63
66
  console.error(unknownFlagMessage('styleproof-map', a));
64
67
  process.exit(2);
@@ -84,6 +87,15 @@ if (!fs.existsSync(spec)) {
84
87
  process.exit(2);
85
88
  }
86
89
 
90
+ function removeHarFiles(root) {
91
+ if (!fs.existsSync(root)) return;
92
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
93
+ const full = `${root}/${entry.name}`;
94
+ if (entry.isDirectory()) removeHarFiles(full);
95
+ else if (entry.isFile() && entry.name.endsWith('.har')) fs.rmSync(full, { force: true });
96
+ }
97
+ }
98
+
87
99
  const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
88
100
  const env = {
89
101
  ...process.env,
@@ -99,4 +111,6 @@ if (result.error) {
99
111
  console.error(playwrightMissingMessage(result.error.message));
100
112
  process.exit(2);
101
113
  }
102
- process.exit(result.status ?? 1);
114
+ const status = result.status ?? 1;
115
+ if (status === 0 && !keepHar) removeHarFiles(`${baseDir}/${dir}`);
116
+ process.exit(status);
package/dist/capture.js CHANGED
@@ -24,7 +24,26 @@ const FRAMEWORK_IGNORE = [
24
24
  'template',
25
25
  'next-route-announcer',
26
26
  '[id="__next-route-announcer__"]',
27
+ '[data-styleproof-hover-sink]',
27
28
  ];
29
+ function installHoverSink() {
30
+ let sink = document.querySelector('[data-styleproof-hover-sink]');
31
+ if (!sink) {
32
+ sink = document.createElement('div');
33
+ sink.setAttribute('data-styleproof-hover-sink', '');
34
+ document.body.appendChild(sink);
35
+ }
36
+ sink.setAttribute('style', [
37
+ 'position:fixed',
38
+ 'left:0',
39
+ 'top:0',
40
+ 'width:1px',
41
+ 'height:1px',
42
+ 'z-index:2147483647',
43
+ 'pointer-events:auto',
44
+ 'opacity:0',
45
+ ].join(';'));
46
+ }
28
47
  /** True if `path` is one of `roots` or a structural descendant of one. Shared by
29
48
  * the capture (excluding live regions) and the diff (skipping them). */
30
49
  export function isUnder(path, roots) {
@@ -582,15 +601,17 @@ export async function captureStyleMap(page, options = {}) {
582
601
  const stabilize = options.stabilize ?? true;
583
602
  const captureText = options.captureText ?? false;
584
603
  const captureComponent = options.captureComponent ?? false;
585
- // Neutralise real focus the same way FREEZE_CSS neutralises motion: blur
586
- // whatever element holds focus so every read below is the no-interaction
587
- // resting state. Real :focus is nondeterministic across runs (autofocus, late
588
- // hydration, a stray prior action) and contaminates BOTH layers — it bakes a
589
- // focus ring into the resting map, AND it cancels the forced-state :focus delta
590
- // (forcing :focus on an already-focused element changes nothing, so the ring
591
- // shows up as a delta on some runs but not others — a self-check "non-
592
- // deterministic" failure). Interaction states are certified deterministically
593
- // via CDP forcePseudoState below, never via whatever happened to be focused.
604
+ // Neutralise real hover/focus the same way FREEZE_CSS neutralises motion: park
605
+ // the pointer over an ignored 1px sink and blur whatever element holds focus so
606
+ // every read below is the no-interaction resting state. Real :hover/:focus is
607
+ // nondeterministic across runs (the last Playwright action, autofocus, late
608
+ // hydration, a stray prior action) and contaminates BOTH layers it bakes an
609
+ // interaction style into the resting map, AND it cancels that forced-state
610
+ // delta (forcing :focus on an already-focused element changes nothing). States
611
+ // are certified deterministically via CDP forcePseudoState below, never via
612
+ // whatever happened to be hovered or focused.
613
+ await page.evaluate(installHoverSink);
614
+ await page.mouse.move(0, 0);
594
615
  await page.evaluate(() => document.activeElement?.blur?.());
595
616
  // Inject the structural-path helper once so both capturePage and
596
617
  // markInteractiveElements (each serialized into the page by page.evaluate, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.1.0",
3
+ "version": "3.1.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",