styleproof 3.0.1 → 3.1.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,65 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.0] - 2026-06-27
11
+
12
+ ### Changed
13
+
14
+ - **`styleproof-init` now follows the repo's package manager.** Generated
15
+ Playwright configs, pre-push hooks, and browser-less CI workflows detect
16
+ `bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or `package-lock.json`
17
+ and scaffold matching build, install, `styleproof-map`, and `styleproof-diff`
18
+ commands instead of assuming npm everywhere.
19
+ - **The committed-map flow is now a three-command CLI path.** Run
20
+ `styleproof-init`, `styleproof-map`, then `styleproof-diff`. `styleproof-map`
21
+ captures the current branch into `stylemaps/current`, while `styleproof-diff`
22
+ now defaults to that map directory and infers the base ref from GitHub Actions,
23
+ `branch.<name>.gh-merge-base`, `origin/main`, `origin/master`, `main`, or
24
+ `master` (or accepts `styleproof-diff main` / `--base-ref main`). The zero-diff
25
+ success line says `0 changed surfaces across N captured surface(s)` instead of
26
+ the confusing `N surfaces identical` / `0 surfaces identical` wording, and the
27
+ `--json` payload includes `compared` so consumers can show the same count.
28
+ - **`styleproof-report` now mirrors the diff CLI defaults.** Run
29
+ `styleproof-report` with no args to generate the side-by-side report from
30
+ `stylemaps/current` against the inferred base branch, or pass
31
+ `styleproof-report main` / `--base-ref main` to pin the base while keeping the
32
+ committed-map directory default.
33
+ - **No-arg diff/report now understand stacked PRs locally.** After
34
+ `GITHUB_BASE_REF` and explicit `branch.<name>.gh-merge-base` config, the shared
35
+ base-ref inference asks `gh pr view` for the current PR base before falling back
36
+ to `main`/`master`, so stacked branches compare against their real review base
37
+ out of the box.
38
+
39
+ ### Fixed
40
+
41
+ - **CLI errors now lead with the recovery step.** Missing specs, unknown flags,
42
+ absent working maps, and missing committed base maps now print a concrete
43
+ `Next:` line such as `run styleproof-map`, pass `--maps-dir <dir>`, or commit
44
+ captures on the base branch.
45
+ - **`styleproof-map` now runs generated capture tests correctly.** The CLI no
46
+ longer passes the spec path as a Playwright file filter, because StyleProof's
47
+ generated tests are registered through the package runner. It now targets the
48
+ capture suite with Playwright's grep path, matching the public
49
+ `styleproof-init` → `styleproof-map` → `styleproof-diff` flow.
50
+ - **Layout-equivalent auto-margin drift no longer creates phantom diffs.** Some
51
+ browser/forced-state combinations can report horizontal `margin-left` /
52
+ `margin-right` / logical margin equivalents differently even when the captured
53
+ document-space rectangle is unchanged. StyleProof now drops only those
54
+ margin-longhand differences when the element's rect is identical on both sides,
55
+ including inside forced `:hover`/`:focus`/`:active` deltas. If the rect moves,
56
+ the margin change still reports.
57
+
58
+ ## [3.0.2] - 2026-06-27
59
+
60
+ ### Fixed
61
+
62
+ - **The composite Action now builds its checked-out source before running local
63
+ bins.** Because `dist/` is intentionally gitignored, the v3 Action ref could
64
+ install runtime dependencies and then fail when `bin/styleproof-report.mjs`
65
+ imported `../dist/report.js`. The Action runtime now installs the checkout's dev
66
+ toolchain with scripts disabled, runs `npm run build`, and executes the checked-out
67
+ entrypoints from that built source.
68
+
10
69
  ## [3.0.1] - 2026-06-27
11
70
 
12
71
  ### Added
@@ -830,7 +889,9 @@ number)`), so each viewport band can capture at its own height. Default remains
830
889
  - `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
831
890
  property, and state that drifted (exit 1).
832
891
 
833
- [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.1...HEAD
892
+ [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.0...HEAD
893
+ [3.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.2...v3.1.0
894
+ [3.0.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.1...v3.0.2
834
895
  [3.0.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.0...v3.0.1
835
896
  [3.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.5.0...v3.0.0
836
897
  [2.5.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.4.0...v2.5.0
package/README.md CHANGED
@@ -187,11 +187,33 @@ defineStyleMapCapture({
187
187
  });
188
188
  ```
189
189
 
190
+ The manual loop is deliberately three commands:
191
+
192
+ ```bash
193
+ npx styleproof-init
194
+ npx styleproof-map
195
+ npx styleproof-diff
196
+ ```
197
+
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
202
+ `origin/main`, `origin/master`, `main`, and `master`. Pin it when needed with
203
+ `styleproof-diff main`, `styleproof-diff master`, or `styleproof-diff --base-ref
204
+ origin/my-base`.
205
+
190
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.
191
207
 
192
208
  > **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.
193
209
 
194
- **Want the side-by-side report + one-click approval** (not just a pass/fail diff)? Use the Action, pointing it at the base ref:
210
+ **Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
211
+ styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
212
+ `stylemaps/current` defaults as `styleproof-diff`. Pin the base with
213
+ `styleproof-report main` or keep the manual form with `styleproof-report before
214
+ after --out report`.
215
+
216
+ **Want the side-by-side report + one-click approval**? Use the Action, pointing it at the base ref:
195
217
 
196
218
  ```yaml
197
219
  # .github/workflows/styleproof.yml
@@ -229,6 +251,12 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
229
251
  - **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
230
252
  - **Self-check** — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR. **On by default while recording** (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. `STYLEPROOF_SELFCHECK=1` forces it on for both; `selfCheck: false` opts out.
231
253
  - **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
254
+ - **Layout-equivalent margin noise is normalised.** If the browser reports
255
+ horizontal auto-centering margins (`margin-left`/`margin-right` and logical
256
+ equivalents) differently but the captured document-space rectangle is
257
+ identical, StyleProof treats that as the same rendered layout, including in
258
+ forced `:hover`/`:focus`/`:active` deltas. If the box moves or resizes, the
259
+ margin change still reports.
232
260
 
233
261
  > Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
234
262
 
@@ -244,6 +272,7 @@ Anything still moving on its own after that is detected as a volatile region and
244
272
  | Animations, transitions, focus ring, caret | frozen / blurred before the map is read |
245
273
  | Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
246
274
  | Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
275
+ | Layout-equivalent horizontal auto margins | ignored only when the captured element rectangle is unchanged |
247
276
  | Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
248
277
  | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
249
278
  | Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
@@ -359,9 +388,10 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
359
388
 
360
389
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
361
390
 
362
- - `styleproof-init` — scaffold **and activate** the whole gate: the capture spec, a `playwright.config.ts` (production-build `webServer`, parallel capture), the pre-push capture-and-push hook, and the browser-less CI workflow. One command.
363
- - `styleproof-diff <beforeDir> <afterDir>` the certify gate; exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against). Use **`styleproof-diff --base-ref <gitref> <mapsDir>`** to diff your committed maps against a git ref (the committed-map flow) — no second dir, no recapture.
364
- - `styleproof-report <beforeDir> <afterDir> --out <dir>` render the diff to a Markdown report with before/after crops. Add `--include-content` for the opt-in, advisory content section (see above); **`--base-ref <gitref> <mapsDir>`** reads the base from git like the diff CLI.
391
+ - `styleproof-init` — scaffold **and activate** the whole gate: the capture spec, a `playwright.config.ts` (production-build `webServer`, parallel capture), the pre-push capture-and-push hook, and the browser-less CI workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default) instead of assuming npm.
392
+ - `styleproof-map` capture the current branch's computed-style map through Playwright. By default it writes lean committed maps to `stylemaps/current`; pass `--spec`, `--dir`, `--base-dir`, or `--screenshots` when you need a custom capture.
393
+ - `styleproof-diff` the certify gate. With no args, it diffs `stylemaps/current` against the inferred base branch (`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. Exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
394
+ - `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports `stylemaps/current` against the inferred base branch; `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).
365
395
 
366
396
  A programmatic API is also exported — `captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, and the breakpoint helpers `detectViewportWidths` / `widthsFromBoundaries`, among others. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/StyleProof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/StyleProof/tree/main/example) workflows.
367
397
 
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * Diff two computed-style map captures (see styleproof).
4
4
  *
5
+ * styleproof-diff [baseRef] [--maps-dir <dir>] [--max N] [--json <file>]
6
+ * styleproof-diff --base-ref <gitref> [mapsDir] [--max N] [--json <file>]
5
7
  * styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>]
6
- * styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]
7
8
  *
8
9
  * Reports, per surface:
9
10
  * - DOM changes (elements added/removed/retagged) — a CSS-only refactor
@@ -26,17 +27,31 @@
26
27
  * error, 3 = only new surfaces (present on one side, no baseline to diff against).
27
28
  */
28
29
  import fs from 'node:fs';
30
+ import path from 'node:path';
29
31
  import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
30
- import { materializeRef, GitRefError } from '../dist/gitref.js';
32
+ import { cleanupBaseRefCaptureDirs, resolveBaseRefCaptureDirs } from '../dist/cli-base-ref.js';
33
+ import {
34
+ cliErrorMessage,
35
+ isHelpArg,
36
+ missingManualCaptureMessage,
37
+ showHelpAndExit,
38
+ unknownFlagMessage,
39
+ } from '../dist/cli-errors.js';
31
40
 
32
- const HELP = `styleproof-diff — certify a CSS refactor by diffing two computed-style captures
41
+ const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
42
+ const DEFAULT_MAPS_DIR = 'stylemaps/current';
33
43
 
34
- usage: styleproof-diff <beforeDir> <afterDir> [options]
35
- styleproof-diff --base-ref <gitref> <mapsDir> [options]
44
+ const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
45
+
46
+ usage: ${COMMAND} [baseRef] [options]
47
+ ${COMMAND} --base-ref <gitref> [mapsDir] [options]
48
+ ${COMMAND} <beforeDir> <afterDir> [options]
36
49
 
37
50
  options:
38
51
  --base-ref <ref> diff <mapsDir> as committed at <ref> (e.g. main) against your
39
52
  working <mapsDir> — base from git, no recapture
53
+ --maps-dir <dir> committed map dir for the base-ref flow
54
+ (default: ${DEFAULT_MAPS_DIR})
40
55
  --max <n> max lines printed per surface before truncating (default: 40)
41
56
  --json <file> also write the full structured diff to <file>
42
57
  -h, --help show this help
@@ -50,51 +65,54 @@ const args = [];
50
65
  let MAX = 40;
51
66
  let jsonOut = null;
52
67
  let baseRef = null;
68
+ let mapsDir = DEFAULT_MAPS_DIR;
53
69
  for (let i = 0; i < argv.length; i++) {
54
- if (argv[i] === '-h' || argv[i] === '--help') {
55
- process.stdout.write(HELP);
56
- process.exit(0);
57
- } else if (argv[i] === '--max') MAX = Number(argv[++i]);
70
+ if (isHelpArg(argv[i])) showHelpAndExit(HELP);
71
+ else if (argv[i] === '--max') MAX = Number(argv[++i]);
58
72
  else if (argv[i].startsWith('--max=')) MAX = Number(argv[i].slice(6));
59
73
  else if (argv[i] === '--json') jsonOut = argv[++i];
60
74
  else if (argv[i].startsWith('--json=')) jsonOut = argv[i].slice(7);
61
75
  else if (argv[i] === '--base-ref') baseRef = argv[++i];
62
76
  else if (argv[i].startsWith('--base-ref=')) baseRef = argv[i].slice(11);
77
+ else if (argv[i] === '--maps-dir') mapsDir = argv[++i];
78
+ else if (argv[i].startsWith('--maps-dir=')) mapsDir = argv[i].slice(11);
63
79
  else if (argv[i].startsWith('--')) {
64
- console.error(`unknown flag: ${argv[i]}`);
80
+ console.error(unknownFlagMessage(COMMAND, argv[i]));
65
81
  process.exit(2);
66
82
  } else args.push(argv[i]);
67
83
  }
68
84
 
69
85
  let dirA;
70
86
  let dirB;
71
- let tmpBase = null;
72
- if (baseRef) {
73
- if (args.length !== 1 || !Number.isFinite(MAX)) {
74
- console.error('usage: styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]');
75
- process.exit(2);
76
- }
77
- if (!fs.existsSync(args[0])) {
78
- console.error(`no capture at ${args[0]}`);
87
+ let baseCapture = null;
88
+ if (baseRef || args.length <= 1) {
89
+ if (!Number.isFinite(MAX)) {
90
+ console.error(`usage: ${COMMAND} [baseRef] [--maps-dir <dir>] [--max N] [--json <file>]`);
79
91
  process.exit(2);
80
92
  }
81
93
  try {
82
- tmpBase = materializeRef(baseRef, args[0]);
94
+ baseCapture = resolveBaseRefCaptureDirs({
95
+ command: COMMAND,
96
+ baseRef,
97
+ mapsDir,
98
+ args,
99
+ usage: `usage: ${COMMAND} --base-ref <gitref> [mapsDir] [--max N] [--json <file>]`,
100
+ });
83
101
  } catch (e) {
84
- console.error(e instanceof GitRefError ? `styleproof-diff --base-ref: ${e.message}` : String(e?.message ?? e));
102
+ console.error(cliErrorMessage(e));
85
103
  process.exit(2);
86
104
  }
87
- dirA = tmpBase;
88
- dirB = args[0];
105
+ dirA = baseCapture.beforeDir;
106
+ dirB = baseCapture.afterDir;
89
107
  } else {
90
108
  if (args.length !== 2 || !Number.isFinite(MAX)) {
91
- console.error('usage: styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)');
109
+ console.error(`usage: ${COMMAND} <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)`);
92
110
  process.exit(2);
93
111
  }
94
112
  [dirA, dirB] = args;
95
113
  for (const d of [dirA, dirB]) {
96
114
  if (!fs.existsSync(d)) {
97
- console.error(`no capture at ${d}`);
115
+ console.error(missingManualCaptureMessage(COMMAND, d));
98
116
  process.exit(2);
99
117
  }
100
118
  }
@@ -107,9 +125,9 @@ try {
107
125
  console.error(e.message);
108
126
  process.exit(2);
109
127
  } finally {
110
- if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
128
+ cleanupBaseRefCaptureDirs(baseCapture);
111
129
  }
112
- const { surfaces, counts } = result;
130
+ const { surfaces, counts, compared } = result;
113
131
 
114
132
  for (const sd of surfaces) {
115
133
  if (sd.missing) {
@@ -138,7 +156,7 @@ for (const sd of surfaces) {
138
156
  if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
139
157
  }
140
158
 
141
- if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null, 2));
159
+ if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces, compared }, null, 2));
142
160
 
143
161
  const total = counts.dom + counts.style + counts.state;
144
162
  const newSurfaces = surfaces.filter((s) => s.missing).length;
@@ -148,7 +166,7 @@ const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline
148
166
  console.log(
149
167
  total === 0
150
168
  ? newSurfaces === 0
151
- ? `\n✓ ${surfaceCount} surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches`
169
+ ? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
152
170
  : `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — shown for reference, no reviewable change`
153
171
  : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
154
172
  );
@@ -22,6 +22,7 @@ import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { spawnSync } from 'node:child_process';
24
24
  import { discoverNextRoutes } from '../dist/index.js';
25
+ import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
25
26
 
26
27
  const HELP = `styleproof-init — scaffold a styleproof capture spec
27
28
 
@@ -42,13 +43,11 @@ What it writes:
42
43
  - playwright.config.ts, only if absent (an existing one is left untouched)
43
44
 
44
45
  After running, capture a baseline against a PRODUCTION build:
45
- STYLEMAP_DIR=baseline npx playwright test styleproof
46
+ npx styleproof-map
46
47
 
47
48
  To certify a refactor:
48
- STYLEMAP_DIR=before npx playwright test styleproof
49
- # ...refactor your CSS...
50
- STYLEMAP_DIR=after npx playwright test styleproof
51
- npx styleproof-diff __stylemaps__/before __stylemaps__/after
49
+ npx styleproof-map
50
+ npx styleproof-diff
52
51
  `;
53
52
 
54
53
  const argv = process.argv.slice(2);
@@ -57,10 +56,8 @@ let baseUrl = 'http://localhost:3000';
57
56
  let force = false;
58
57
  for (let i = 0; i < argv.length; i++) {
59
58
  const a = argv[i];
60
- if (a === '-h' || a === '--help') {
61
- process.stdout.write(HELP);
62
- process.exit(0);
63
- } else if (a === '--dir') specPath = argv[++i];
59
+ if (isHelpArg(a)) showHelpAndExit(HELP);
60
+ else if (a === '--dir') specPath = argv[++i];
64
61
  else if (a.startsWith('--dir=')) specPath = a.slice(6);
65
62
  else if (a === '--base-url') baseUrl = argv[++i];
66
63
  else if (a.startsWith('--base-url=')) baseUrl = a.slice(11);
@@ -110,10 +107,8 @@ const HEADER = `/**
110
107
  * detects your @media breakpoints from the loaded CSS and sweeps one viewport per
111
108
  * band — no config. Capture against a PRODUCTION build — dev servers inject styles.
112
109
  *
113
- * STYLEMAP_DIR=before npx playwright test styleproof # capture baseline
114
- * ...refactor your CSS...
115
- * STYLEMAP_DIR=after npx playwright test styleproof # capture again
116
- * npx styleproof-diff __stylemaps__/before __stylemaps__/after
110
+ * npx styleproof-map # capture this branch into stylemaps/current
111
+ * npx styleproof-diff # compare against the inferred base branch
117
112
  */`;
118
113
 
119
114
  // Next.js detected: derive both surfaces and the coverage guard from the app's
@@ -198,6 +193,62 @@ defineStyleMapCapture({
198
193
  });
199
194
  `;
200
195
 
196
+ const PACKAGE_MANAGERS = {
197
+ npm: {
198
+ label: 'npm',
199
+ run: (script) => `npm run ${script}`,
200
+ exec: (command) => `npx ${command}`,
201
+ install: 'npm ci',
202
+ setup: ` - uses: actions/setup-node@v4
203
+ with:
204
+ node-version: '20'
205
+ cache: npm`,
206
+ },
207
+ yarn: {
208
+ label: 'Yarn v1',
209
+ run: (script) => `npx -y yarn@1.22.22 ${script}`,
210
+ exec: (command) => `npx -y yarn@1.22.22 ${command}`,
211
+ install: 'npx -y yarn@1.22.22 install --frozen-lockfile --non-interactive',
212
+ setup: ` - uses: actions/setup-node@v4
213
+ with:
214
+ node-version: '20'
215
+ cache: yarn
216
+ cache-dependency-path: yarn.lock`,
217
+ },
218
+ pnpm: {
219
+ label: 'pnpm',
220
+ run: (script) => `npx -y pnpm ${script}`,
221
+ exec: (command) => `npx -y pnpm exec ${command}`,
222
+ install: 'npx -y pnpm install --frozen-lockfile',
223
+ setup: ` - uses: actions/setup-node@v4
224
+ with:
225
+ node-version: '20'
226
+ cache: pnpm
227
+ cache-dependency-path: pnpm-lock.yaml`,
228
+ },
229
+ bun: {
230
+ label: 'Bun',
231
+ run: (script) => `bun run ${script}`,
232
+ exec: (command) => `bunx ${command}`,
233
+ install: 'bun install --frozen-lockfile',
234
+ setup: ` - uses: actions/setup-node@v4
235
+ with:
236
+ node-version: '20'
237
+ - uses: oven-sh/setup-bun@v2`,
238
+ },
239
+ };
240
+
241
+ function detectPackageManager(root) {
242
+ if (fs.existsSync(path.join(root, 'bun.lock')) || fs.existsSync(path.join(root, 'bun.lockb'))) {
243
+ return PACKAGE_MANAGERS.bun;
244
+ }
245
+ if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return PACKAGE_MANAGERS.pnpm;
246
+ if (fs.existsSync(path.join(root, 'yarn.lock'))) return PACKAGE_MANAGERS.yarn;
247
+ return PACKAGE_MANAGERS.npm;
248
+ }
249
+
250
+ const PM = detectPackageManager(process.cwd());
251
+
201
252
  const CONFIG = `import { defineConfig, devices } from '@playwright/test';
202
253
 
203
254
  // Generated by styleproof-init.
@@ -225,7 +276,7 @@ export default defineConfig({
225
276
  // accidentally capture a dev server. Adjust the command to your framework (e.g.
226
277
  // \`vite build && vite preview\`); a server already running locally is reused.
227
278
  webServer: {
228
- command: 'npm run build && npm run start',
279
+ command: '${PM.run('build')} && ${PM.run('start')}',
229
280
  url: process.env.BASE_URL || '${baseUrl}',
230
281
  reuseExistingServer: !process.env.CI,
231
282
  timeout: 600_000, // a cold production build can take a few minutes
@@ -256,8 +307,7 @@ remote="$1"
256
307
  # hook a second time — skip the expensive capture on that pass.
257
308
  [ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
258
309
 
259
- STYLEMAP_DIR=current STYLEPROOF_BASEDIR=stylemaps STYLEPROOF_SCREENSHOTS=0 \\
260
- npx playwright test ${specPath}
310
+ ${PM.exec(`styleproof-map --spec ${specPath}`)}
261
311
 
262
312
  git add stylemaps
263
313
  git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
@@ -291,12 +341,10 @@ jobs:
291
341
  - uses: actions/checkout@v4
292
342
  with:
293
343
  fetch-depth: 0 # need the base ref to read its committed map
294
- - uses: actions/setup-node@v4
295
- with:
296
- node-version: '20'
297
- - run: npm ci
344
+ ${PM.setup}
345
+ - run: ${PM.install}
298
346
  - name: Diff this branch's committed map against the base
299
- run: npx styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}" stylemaps/current
347
+ run: ${PM.exec(`styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}"`)}
300
348
  `;
301
349
 
302
350
  function writeFileSafe(file, contents, { force: f } = {}) {
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capture the current branch's computed-style map.
4
+ *
5
+ * The zero-config path is the committed-map flow scaffolded by styleproof-init:
6
+ * styleproof-map
7
+ *
8
+ * It runs Playwright against e2e/styleproof.spec.ts with:
9
+ * STYLEMAP_DIR=current
10
+ * STYLEPROOF_BASEDIR=stylemaps
11
+ * STYLEPROOF_SCREENSHOTS=0
12
+ */
13
+ import fs from 'node:fs';
14
+ import { spawnSync } from 'node:child_process';
15
+ import {
16
+ isHelpArg,
17
+ missingSpecMessage,
18
+ playwrightMissingMessage,
19
+ showHelpAndExit,
20
+ unknownFlagMessage,
21
+ } from '../dist/cli-errors.js';
22
+
23
+ const HELP = `styleproof-map — capture this branch's computed-style map
24
+
25
+ usage: styleproof-map [options] [-- <playwright args>]
26
+
27
+ options:
28
+ --spec <path> StyleProof spec that must exist (default: e2e/styleproof.spec.ts)
29
+ --dir <label> output label under --base-dir (default: current)
30
+ --base-dir <path> output root directory (default: stylemaps)
31
+ --screenshots keep screenshots for reports (default: off for committed maps)
32
+ --no-screenshots write lean .json.gz maps only (default)
33
+ -h, --help show this help
34
+
35
+ Examples:
36
+ styleproof-map
37
+ styleproof-map --spec e2e/styleproof.spec.ts
38
+ styleproof-map --dir review --base-dir __stylemaps__ --screenshots
39
+ `;
40
+
41
+ const argv = process.argv.slice(2);
42
+ let spec = 'e2e/styleproof.spec.ts';
43
+ let dir = process.env.STYLEMAP_DIR ?? 'current';
44
+ let baseDir = process.env.STYLEPROOF_BASEDIR ?? 'stylemaps';
45
+ let screenshots = process.env.STYLEPROOF_SCREENSHOTS ?? '0';
46
+ const playwrightArgs = [];
47
+
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i];
50
+ if (isHelpArg(a)) showHelpAndExit(HELP);
51
+ else if (a === '--') {
52
+ playwrightArgs.push(...argv.slice(i + 1));
53
+ break;
54
+ } else if (a === '--spec') spec = argv[++i];
55
+ else if (a.startsWith('--spec=')) spec = a.slice(7);
56
+ else if (a === '--dir') dir = argv[++i];
57
+ else if (a.startsWith('--dir=')) dir = a.slice(6);
58
+ else if (a === '--base-dir') baseDir = argv[++i];
59
+ else if (a.startsWith('--base-dir=')) baseDir = a.slice(11);
60
+ else if (a === '--screenshots') screenshots = '1';
61
+ else if (a === '--no-screenshots') screenshots = '0';
62
+ else if (a.startsWith('--')) {
63
+ console.error(unknownFlagMessage('styleproof-map', a));
64
+ process.exit(2);
65
+ } else {
66
+ spec = a;
67
+ }
68
+ }
69
+
70
+ if (!spec) {
71
+ console.error('--spec requires a path');
72
+ process.exit(2);
73
+ }
74
+ if (!dir) {
75
+ console.error('--dir requires a label');
76
+ process.exit(2);
77
+ }
78
+ if (!baseDir) {
79
+ console.error('--base-dir requires a path');
80
+ process.exit(2);
81
+ }
82
+ if (!fs.existsSync(spec)) {
83
+ console.error(missingSpecMessage(spec));
84
+ process.exit(2);
85
+ }
86
+
87
+ const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
88
+ const env = {
89
+ ...process.env,
90
+ STYLEMAP_DIR: dir,
91
+ STYLEPROOF_BASEDIR: baseDir,
92
+ STYLEPROOF_SCREENSHOTS: screenshots,
93
+ };
94
+ const result = spawnSync(command, ['test', '--grep', 'styleproof capture', ...playwrightArgs], {
95
+ stdio: 'inherit',
96
+ env,
97
+ });
98
+ if (result.error) {
99
+ console.error(playwrightMissingMessage(result.error.message));
100
+ process.exit(2);
101
+ }
102
+ process.exit(result.status ?? 1);
@@ -10,18 +10,24 @@
10
10
  * need the .png screenshots that `defineStyleMapCapture` saves by default.
11
11
  * Exit code 0 = no changes, 1 = report generated, 2 = usage error.
12
12
  */
13
- import fs from 'node:fs';
14
13
  import { generateStyleMapReport } from '../dist/report.js';
15
- import { materializeRef, GitRefError } from '../dist/gitref.js';
14
+ import { cleanupBaseRefCaptureDirs, resolveBaseRefCaptureDirs } from '../dist/cli-base-ref.js';
15
+ import { cliErrorMessage, isHelpArg, showHelpAndExit, unknownFlagMessage } from '../dist/cli-errors.js';
16
16
 
17
- const HELP = `styleproof-report — reviewable before/after report from two captures
17
+ const COMMAND = 'styleproof-report';
18
+ const DEFAULT_MAPS_DIR = 'stylemaps/current';
18
19
 
19
- usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options]
20
- styleproof-report --base-ref <gitref> <mapsDir> --out <dir> [options]
20
+ const HELP = `${COMMAND} reviewable before/after report from two captures
21
+
22
+ usage: ${COMMAND} [baseRef] [options]
23
+ ${COMMAND} --base-ref <gitref> [mapsDir] [options]
24
+ ${COMMAND} <beforeDir> <afterDir> [options]
21
25
 
22
26
  options:
23
- --base-ref <ref> use <mapsDir> as committed at <ref> (e.g. main) as the
24
- before side — base from git, no recapture
27
+ --base-ref <ref> report <mapsDir> as committed at <ref> (e.g. main)
28
+ against your working <mapsDir> — base from git, no recapture
29
+ --maps-dir <dir> committed map dir for the base-ref flow
30
+ (default: ${DEFAULT_MAPS_DIR})
25
31
  --out <dir> output directory (default: styleproof-report)
26
32
  --image-base-url <url> prefix for image URLs in report.md (default: relative)
27
33
  --pad <px> padding around changed rects when cropping (default: 24)
@@ -53,12 +59,11 @@ let minHeight;
53
59
  let includeLayoutNoise = false;
54
60
  let includeContent = false;
55
61
  let baseRef = null;
62
+ let mapsDir = DEFAULT_MAPS_DIR;
56
63
  for (let i = 0; i < argv.length; i++) {
57
64
  const a = argv[i];
58
- if (a === '-h' || a === '--help') {
59
- process.stdout.write(HELP);
60
- process.exit(0);
61
- } else if (a === '--out') flags.out = argv[++i];
65
+ if (isHelpArg(a)) showHelpAndExit(HELP);
66
+ else if (a === '--out') flags.out = argv[++i];
62
67
  else if (a.startsWith('--out=')) flags.out = a.slice(6);
63
68
  else if (a === '--image-base-url') flags.imageBaseUrl = argv[++i];
64
69
  else if (a.startsWith('--image-base-url=')) flags.imageBaseUrl = a.slice(17);
@@ -78,27 +83,31 @@ for (let i = 0; i < argv.length; i++) {
78
83
  else if (a.startsWith('--include-content=')) includeContent = a.slice(18) !== 'false';
79
84
  else if (a === '--base-ref') baseRef = argv[++i];
80
85
  else if (a.startsWith('--base-ref=')) baseRef = a.slice(11);
86
+ else if (a === '--maps-dir') mapsDir = argv[++i];
87
+ else if (a.startsWith('--maps-dir=')) mapsDir = a.slice(11);
81
88
  else if (a.startsWith('--')) {
82
- console.error(`unknown flag: ${a}`);
89
+ console.error(unknownFlagMessage(COMMAND, a));
83
90
  process.exit(2);
84
91
  } else args.push(a);
85
92
  }
86
93
  let beforeDir;
87
94
  let afterDir;
88
- let tmpBase = null;
89
- if (baseRef) {
90
- if (args.length !== 1) {
91
- console.error('usage: styleproof-report --base-ref <gitref> <mapsDir> --out <dir> [options]');
92
- process.exit(2);
93
- }
95
+ let baseCapture = null;
96
+ if (baseRef || args.length <= 1) {
94
97
  try {
95
- tmpBase = materializeRef(baseRef, args[0]);
98
+ baseCapture = resolveBaseRefCaptureDirs({
99
+ command: COMMAND,
100
+ baseRef,
101
+ mapsDir,
102
+ args,
103
+ usage: 'usage: styleproof-report --base-ref <gitref> [mapsDir] [--out <dir>] [options]',
104
+ });
96
105
  } catch (e) {
97
- console.error(e instanceof GitRefError ? `styleproof-report --base-ref: ${e.message}` : String(e?.message ?? e));
106
+ console.error(cliErrorMessage(e));
98
107
  process.exit(2);
99
108
  }
100
- beforeDir = tmpBase;
101
- afterDir = args[0];
109
+ beforeDir = baseCapture.beforeDir;
110
+ afterDir = baseCapture.afterDir;
102
111
  } else {
103
112
  if (args.length !== 2) {
104
113
  console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
@@ -143,7 +152,7 @@ try {
143
152
  console.error(e.message);
144
153
  process.exit(2);
145
154
  } finally {
146
- if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
155
+ cleanupBaseRefCaptureDirs(baseCapture);
147
156
  }
148
157
 
149
158
  const newNote = result.newSurfaces ? ` (+${result.newSurfaces} new surface(s) with no baseline)` : '';
@@ -0,0 +1,15 @@
1
+ export interface BaseRefCaptureDirs {
2
+ beforeDir: string;
3
+ afterDir: string;
4
+ baseRef: string;
5
+ mapsDir: string;
6
+ tmpBase: string;
7
+ }
8
+ export declare function resolveBaseRefCaptureDirs(options: {
9
+ command: string;
10
+ baseRef: string | null;
11
+ mapsDir: string;
12
+ args: string[];
13
+ usage: string;
14
+ }): BaseRefCaptureDirs;
15
+ export declare function cleanupBaseRefCaptureDirs(captureDirs: BaseRefCaptureDirs | null): void;
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import { inferBaseRef, materializeRef, GitRefError } from './gitref.js';
3
+ import { baseInferenceMessage, baseMapsMessage, missingWorkingMapsMessage } from './cli-errors.js';
4
+ function errorMessage(error) {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+ function exitUsage(usage) {
8
+ throw new Error(usage);
9
+ }
10
+ function inferBaseRefOrExit(command) {
11
+ try {
12
+ return inferBaseRef();
13
+ }
14
+ catch (e) {
15
+ throw new Error(e instanceof GitRefError ? baseInferenceMessage(command, e.message) : errorMessage(e), {
16
+ cause: e,
17
+ });
18
+ }
19
+ }
20
+ function selectBaseRef(options) {
21
+ const { command, args, usage } = options;
22
+ if (options.baseRef) {
23
+ if (args.length > 1)
24
+ exitUsage(usage);
25
+ return { baseRef: options.baseRef, mapsDir: args[0] ?? options.mapsDir };
26
+ }
27
+ if (args.length === 1)
28
+ return { baseRef: args[0], mapsDir: options.mapsDir };
29
+ return { baseRef: inferBaseRefOrExit(command), mapsDir: options.mapsDir };
30
+ }
31
+ function materializeBaseRefOrExit(command, baseRef, mapsDir) {
32
+ try {
33
+ return materializeRef(baseRef, mapsDir);
34
+ }
35
+ catch (e) {
36
+ throw new Error(e instanceof GitRefError ? baseMapsMessage(command, e.message, baseRef, mapsDir) : errorMessage(e), {
37
+ cause: e,
38
+ });
39
+ }
40
+ }
41
+ export function resolveBaseRefCaptureDirs(options) {
42
+ const { command } = options;
43
+ const { baseRef, mapsDir } = selectBaseRef(options);
44
+ if (!fs.existsSync(mapsDir)) {
45
+ throw new Error(missingWorkingMapsMessage(command, mapsDir));
46
+ }
47
+ const tmpBase = materializeBaseRefOrExit(command, baseRef, mapsDir);
48
+ return { beforeDir: tmpBase, afterDir: mapsDir, baseRef, mapsDir, tmpBase };
49
+ }
50
+ export function cleanupBaseRefCaptureDirs(captureDirs) {
51
+ if (captureDirs)
52
+ fs.rmSync(captureDirs.tmpBase, { recursive: true, force: true });
53
+ }
@@ -0,0 +1,10 @@
1
+ export declare function isHelpArg(arg: string | undefined): boolean;
2
+ export declare function showHelpAndExit(help: string): never;
3
+ export declare function cliErrorMessage(error: unknown): string;
4
+ export declare function unknownFlagMessage(command: string, flag: string): string;
5
+ export declare function missingSpecMessage(spec: string): string;
6
+ export declare function playwrightMissingMessage(message: string): string;
7
+ export declare function missingWorkingMapsMessage(command: string, dir: string): string;
8
+ export declare function missingManualCaptureMessage(command: string, dir: string): string;
9
+ export declare function baseInferenceMessage(command: string, message: string): string;
10
+ export declare function baseMapsMessage(command: string, message: string, baseRef: string, mapsDir: string): string;
@@ -0,0 +1,50 @@
1
+ export function isHelpArg(arg) {
2
+ return arg === '-h' || arg === '--help';
3
+ }
4
+ export function showHelpAndExit(help) {
5
+ process.stdout.write(help);
6
+ process.exit(0);
7
+ }
8
+ export function cliErrorMessage(error) {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+ export function unknownFlagMessage(command, flag) {
12
+ return `${command}: unknown flag: ${flag}\nNext: run ${command} --help to see supported options.`;
13
+ }
14
+ export function missingSpecMessage(spec) {
15
+ return [
16
+ `styleproof-map: no StyleProof spec at ${spec}`,
17
+ 'Next: run styleproof-init to scaffold the spec, or pass --spec <path> if your capture spec lives elsewhere.',
18
+ ].join('\n');
19
+ }
20
+ export function playwrightMissingMessage(message) {
21
+ return [
22
+ `styleproof-map: could not run Playwright (${message})`,
23
+ 'Next: install @playwright/test, then run npx playwright install chromium.',
24
+ ].join('\n');
25
+ }
26
+ export function missingWorkingMapsMessage(command, dir) {
27
+ return [
28
+ `${command}: no capture at ${dir}`,
29
+ `Next: run styleproof-map to create ${dir}, then commit the updated maps.`,
30
+ 'If your maps live elsewhere, pass --maps-dir <dir>.',
31
+ ].join('\n');
32
+ }
33
+ export function missingManualCaptureMessage(command, dir) {
34
+ return [
35
+ `${command}: no capture at ${dir}`,
36
+ `Next: pass existing capture directories, or run styleproof-map first and use ${command} [baseRef].`,
37
+ ].join('\n');
38
+ }
39
+ export function baseInferenceMessage(command, message) {
40
+ return [
41
+ `${command}: ${message}`,
42
+ `Next: pass a base ref explicitly, e.g. ${command} main, or set git config branch.<name>.gh-merge-base main.`,
43
+ ].join('\n');
44
+ }
45
+ export function baseMapsMessage(command, message, baseRef, mapsDir) {
46
+ return [
47
+ `${command}: ${message}`,
48
+ `Next: make sure ${baseRef} contains committed captures at ${mapsDir}. On the base branch, run styleproof-map and commit ${mapsDir}.`,
49
+ ].join('\n');
50
+ }
package/dist/diff.d.ts CHANGED
@@ -61,6 +61,7 @@ export declare function diffStyleMapDirs(dirA: string, dirB: string): {
61
61
  surfaces: SurfaceDiff[];
62
62
  counts: DiffCounts;
63
63
  volatile: number;
64
+ compared: number;
64
65
  };
65
66
  export declare function diffContentMaps(a: StyleMap, b: StyleMap): ContentChange[];
66
67
  /** Per-surface content diff across two capture dirs (opt-in layer). Mirrors
package/dist/diff.js CHANGED
@@ -13,6 +13,20 @@ function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
13
13
  }
14
14
  return changed;
15
15
  }
16
+ const LAYOUT_EQUIVALENT_MARGIN_PROPS = new Set([
17
+ 'margin-left',
18
+ 'margin-right',
19
+ 'margin-inline-start',
20
+ 'margin-inline-end',
21
+ ]);
22
+ function sameRect(a, b) {
23
+ return !!a && !!b && a.every((v, i) => v === b[i]);
24
+ }
25
+ function dropLayoutEquivalentMarginProps(props, a, b) {
26
+ if (!sameRect(a?.rect, b?.rect))
27
+ return props;
28
+ return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
29
+ }
16
30
  /** Union of both captures' live-region paths — skipped by every diff layer so a
17
31
  * region volatile on either side never reads as a change. */
18
32
  function volatilePaths(a, b) {
@@ -80,7 +94,8 @@ export function diffStyleMaps(a, b) {
80
94
  // tag defaults for maps written before that fix.
81
95
  const pdefsA = pseudo ? (a.defaults[ea.tag + pseudo] ?? defsA) : defsA;
82
96
  const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
83
- const props = diffProps(propsA, propsB, pdefsA, pdefsB, '(unset)', '(unset)');
97
+ const rawProps = diffProps(propsA, propsB, pdefsA, pdefsB, '(unset)', '(unset)');
98
+ const props = pseudo ? rawProps : dropLayoutEquivalentMarginProps(rawProps, ea, eb);
84
99
  if (props.length)
85
100
  findings.push({ kind: 'style', path: p, cls: ea.cls, pseudo, props });
86
101
  }
@@ -115,8 +130,9 @@ export function diffStyleMaps(a, b) {
115
130
  const db = sb[state] ?? {};
116
131
  for (const sub of new Set([...Object.keys(da), ...Object.keys(db)])) {
117
132
  const props = diffProps(da[sub] ?? {}, db[sub] ?? {}, {}, {}, '(state does not change it)', '(state no longer changes it)');
118
- if (props.length)
119
- findings.push({ kind: 'state', path: p, cls, state, sub, props });
133
+ const filtered = dropLayoutEquivalentMarginProps(props, a.elements[sub], b.elements[sub]);
134
+ if (filtered.length)
135
+ findings.push({ kind: 'state', path: p, cls, state, sub, props: filtered });
120
136
  }
121
137
  }
122
138
  }
@@ -167,7 +183,7 @@ export function diffStyleMapDirs(dirA, dirB) {
167
183
  if (findings.length)
168
184
  surfaces.push({ surface, findings });
169
185
  }
170
- return { surfaces, counts, volatile };
186
+ return { surfaces, counts, volatile, compared: names.length };
171
187
  }
172
188
  /**
173
189
  * Diff the OPT-IN content layer: elements whose own rendered text changed.
package/dist/gitref.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** A readable failure materialising a base from git — CLIs map this to exit 2. */
2
2
  export declare class GitRefError extends Error {
3
3
  }
4
+ /** Infer the committed-map base ref for local and GitHub Actions CLI runs. */
5
+ export declare function inferBaseRef(env?: NodeJS.ProcessEnv): string;
4
6
  /**
5
7
  * Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
6
8
  * so a diff/report can use e.g. `main`'s committed map as the base with no checkout
package/dist/gitref.js CHANGED
@@ -5,6 +5,50 @@ import path from 'node:path';
5
5
  /** A readable failure materialising a base from git — CLIs map this to exit 2. */
6
6
  export class GitRefError extends Error {
7
7
  }
8
+ function git(args) {
9
+ return spawnSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 28 });
10
+ }
11
+ function gitOutput(args) {
12
+ const r = git(args);
13
+ return r.status === 0 ? r.stdout.trim() : '';
14
+ }
15
+ function refExists(ref) {
16
+ return git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).status === 0;
17
+ }
18
+ function firstExistingRef(refs) {
19
+ return refs.find(refExists);
20
+ }
21
+ function baseRefCandidate(ref) {
22
+ const refs = ref.startsWith('origin/') || ref.startsWith('refs/') ? [ref] : [`origin/${ref}`, ref];
23
+ return firstExistingRef(refs) ?? refs[0];
24
+ }
25
+ function ghPrBaseRef() {
26
+ const r = spawnSync('gh', ['pr', 'view', '--json', 'baseRefName', '--jq', '.baseRefName'], {
27
+ encoding: 'utf8',
28
+ maxBuffer: 1 << 20,
29
+ });
30
+ return r.status === 0 ? r.stdout.trim() : '';
31
+ }
32
+ /** Infer the committed-map base ref for local and GitHub Actions CLI runs. */
33
+ export function inferBaseRef(env = process.env) {
34
+ if (env.GITHUB_BASE_REF) {
35
+ return baseRefCandidate(env.GITHUB_BASE_REF);
36
+ }
37
+ const branch = gitOutput(['branch', '--show-current']);
38
+ if (branch) {
39
+ const configured = gitOutput(['config', `branch.${branch}.gh-merge-base`]);
40
+ if (configured) {
41
+ return baseRefCandidate(configured);
42
+ }
43
+ }
44
+ const prBase = ghPrBaseRef();
45
+ if (prBase)
46
+ return baseRefCandidate(prBase);
47
+ const fallback = firstExistingRef(['origin/main', 'origin/master', 'main', 'master']);
48
+ if (fallback)
49
+ return fallback;
50
+ throw new GitRefError('could not infer a base branch (tried GITHUB_BASE_REF, branch.<name>.gh-merge-base, gh pr view, origin/main, origin/master, main, master)');
51
+ }
8
52
  /**
9
53
  * Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
10
54
  * so a diff/report can use e.g. `main`'s committed map as the base with no checkout
@@ -15,7 +59,7 @@ export class GitRefError extends Error {
15
59
  * Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
16
60
  */
17
61
  export function materializeRef(ref, dir) {
18
- const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', maxBuffer: 1 << 28 });
62
+ const top = git(['rev-parse', '--show-toplevel']);
19
63
  if (top.status !== 0)
20
64
  throw new GitRefError('must run inside a git repository');
21
65
  const toplevel = top.stdout.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.0.1",
3
+ "version": "3.1.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",
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "bin": {
39
39
  "styleproof-init": "./bin/styleproof-init.mjs",
40
+ "styleproof-map": "./bin/styleproof-map.mjs",
40
41
  "styleproof-diff": "./bin/styleproof-diff.mjs",
41
42
  "styleproof-report": "./bin/styleproof-report.mjs"
42
43
  },