styleproof 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,81 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.5.0] - 2026-06-26
11
+
12
+ ### Changed
13
+
14
+ - **`styleproof-init` scaffolds parallel surface capture (`fullyParallel: true`).**
15
+ StyleProof emits one test per surface × width, each an isolated page writing a
16
+ uniquely-keyed file — independent and safe to run concurrently. Without
17
+ `fullyParallel`, all surfaces sit in one spec file and capture **serially**; with it
18
+ they fan out across workers. Measured: 6 surfaces went from 12.3 s (1 worker) to
19
+ 4.9 s (4 workers) — **~2.5× faster**, byte-identical output. Profiling confirmed the
20
+ single-capture path is correctness-bound (the settle is a deterministic wait; forced
21
+ states must be isolated per element), so parallelism across surfaces — not
22
+ micro-optimising one capture — is the real lever.
23
+
24
+ ### Added
25
+
26
+ - **Dogfood: StyleProof certifies its own demo page in CI.** A new
27
+ `example/demo/index.html` plus `test/dogfood.e2e.spec.ts` run the full
28
+ capture → detect → diff pipeline on a real multi-element page every CI run:
29
+ auto-detection finds the demo's `@media` breakpoints, two captures certify
30
+ identical (determinism), and a planted restyle is caught. `test:e2e` now runs the
31
+ whole `*.e2e.spec.ts` suite. (Repo-only; `example/` isn't in the published package.)
32
+
33
+ - **`npm run bench` — measures the committed-map gate's CI speedup.** A reproducible
34
+ benchmark of one in-browser capture vs one precomputed-map diff, projected to a
35
+ sample app. On the dev fixture: capture ≈ 1 s/surface-width, diff ≈ 0.4 ms — so the
36
+ per-PR compare step is ~1000s× cheaper, before counting the build + serve the
37
+ committed-map CI also skips. (Internal tooling; not shipped in the package.)
38
+ - **The Action and `styleproof-report` support `--base-ref`.** The committed-map gate
39
+ now gets the full review experience, not just a bare diff: pass `base-ref` to the
40
+ Action (e.g. the PR base branch) and point `fresh-dir` at your committed maps — it
41
+ reads the base from git and produces the same before/after report + approval gate,
42
+ with no recapture. `styleproof-report --base-ref <gitref> <mapsDir>` does the same
43
+ on the CLI. The git-materialisation is now a shared `materializeRef` (in `gitref`)
44
+ used by both `styleproof-diff` and `styleproof-report`. `baseline-dir` is no longer
45
+ required when `base-ref` is set.
46
+
47
+ ## [2.4.0] - 2026-06-26
48
+
49
+ ### Added
50
+
51
+ - **Automatic breakpoint detection — `Surface.widths` is now optional.** Omit it and
52
+ StyleProof reads the app's real viewport breakpoints from the loaded CSSOM at
53
+ capture time and sweeps one width per `@media` band — no config, no guessing.
54
+ Because it reads the browser's parsed stylesheets (not your source), it's
55
+ framework-agnostic: Tailwind, CSS Modules, styled-components, Sass and vanilla all
56
+ resolve to the same `@media` rules. It is authoritative **or it fails**: an
57
+ unreadable cross-origin stylesheet throws (so a band is never silently missed)
58
+ rather than guessing. `min/max-width` and range syntax (`width >= …`) are handled,
59
+ `em`/`rem` resolved against the root font size; container/print/height queries are
60
+ correctly ignored. Set `widths` explicitly to pin the sweep or to cover a JS-only
61
+ (`matchMedia`) breakpoint that has no CSS rule. New exports: `detectViewportWidths`,
62
+ `mediaTextWidthBoundaries`, `widthsFromBoundaries`. **`styleproof-init` now scaffolds
63
+ surfaces with no `widths`**, so a fresh project gets zero-config breakpoint detection
64
+ by default.
65
+ - **Out-of-the-box committed-map gate — capture pre-push, CI just diffs.**
66
+ `styleproof-init` now scaffolds the whole flow as the default: a **pre-push hook**
67
+ (`.githooks/pre-push`) captures this branch's computed-style map against a
68
+ production build, commits it as a lean `.json.gz` under `stylemaps/`, and **pushes
69
+ it with your branch — one `git push`, never two** (the hook pushes the map commit
70
+ itself, because git would otherwise send only the pre-hook commit); and a
71
+ **browser-less CI workflow** that just diffs the committed map against the base. So
72
+ `main` always carries a base map and every PR is a fast comparison of precomputed
73
+ maps, never a recapture. (Capture belongs pre-push because it needs the app built +
74
+ served; the same-environment requirement is self-enforced by the self-check.)
75
+ - **`styleproof-diff --base-ref <gitref>` — diff against a committed base in git.**
76
+ `styleproof-diff --base-ref main <mapsDir>` materialises the captures committed at
77
+ `<mapsDir>` as of `main` and diffs them against your working `<mapsDir>` — the CI
78
+ half of the gate above. Reads the base purely through git (`ls-tree`/`show`, no
79
+ `tar`/deps; binary `.json.gz` preserved), into a temp dir cleaned up after.
80
+ - **`STYLEPROOF_BASEDIR` / `STYLEPROOF_SCREENSHOTS` env knobs** (same wiring style as
81
+ `STYLEPROOF_REPLAY_*`): redirect capture into a committed dir and drop screenshots
82
+ for lean, commit-friendly maps — without editing the spec. This is what lets the
83
+ pre-push hook capture committed maps from the stock generated spec.
84
+
10
85
  ## [2.3.1] - 2026-06-23
11
86
 
12
87
  ### Fixed
package/README.md CHANGED
@@ -292,19 +292,19 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
292
292
 
293
293
  **Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
294
294
 
295
- | Option | Default | Purpose |
296
- | ------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
297
- | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths, ignore?, height? }`. `go(page)` drives to a settled state. |
298
- | `expected` | _none_ | Your route/view universe. Emits a coverage-guard test (runs without a capture dir) that fails when a route has no surface and isn't excluded — so a new page can't ship uncaptured. |
299
- | `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale. |
300
- | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
301
- | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
302
- | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
303
- | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
304
- | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
305
- | `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. |
306
- | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
307
- | `baseDir` | `__stylemaps__` | Output root directory. |
295
+ | Option | Default | Purpose |
296
+ | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
297
+ | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
298
+ | `expected` | _none_ | Your route/view universe. Emits a coverage-guard test (runs without a capture dir) that fails when a route has no surface and isn't excluded — so a new page can't ship uncaptured. |
299
+ | `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale. |
300
+ | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
301
+ | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
302
+ | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
303
+ | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
304
+ | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
305
+ | `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. |
306
+ | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
307
+ | `baseDir` | `__stylemaps__` | Output root directory. |
308
308
 
309
309
  Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<style>`/… and `next-route-announcer`) are skipped automatically; a surface's `ignore` adds to that default, it doesn't replace it.
310
310
 
@@ -3,6 +3,7 @@
3
3
  * Diff two computed-style map captures (see styleproof).
4
4
  *
5
5
  * styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>]
6
+ * styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]
6
7
  *
7
8
  * Reports, per surface:
8
9
  * - DOM changes (elements added/removed/retagged) — a CSS-only refactor
@@ -13,18 +14,29 @@
13
14
  * longer does (or now changes differently) — the classic dropped
14
15
  * `hover:` variant a screenshot can never catch.
15
16
  *
17
+ * --base-ref reads the base from a git ref instead of a second directory: it
18
+ * materialises the captures committed at <mapsDir> as of <gitref> (e.g. `main`)
19
+ * and diffs them against your working <mapsDir>. That's the "base map lives on
20
+ * main, CI just diffs" flow — commit each branch's maps (pre-push), and the gate
21
+ * never recomputes the base. Both sides must be captured in the same environment
22
+ * (browser + fonts) for the diff to be meaningful.
23
+ *
16
24
  * Custom properties (--*) are ignored: they are inputs, not outcomes (see
17
25
  * README). Exit code 0 = identical, 1 = reviewable differences, 2 = usage/capture
18
26
  * error, 3 = only new surfaces (present on one side, no baseline to diff against).
19
27
  */
20
28
  import fs from 'node:fs';
21
29
  import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
30
+ import { materializeRef, GitRefError } from '../dist/gitref.js';
22
31
 
23
32
  const HELP = `styleproof-diff — certify a CSS refactor by diffing two computed-style captures
24
33
 
25
34
  usage: styleproof-diff <beforeDir> <afterDir> [options]
35
+ styleproof-diff --base-ref <gitref> <mapsDir> [options]
26
36
 
27
37
  options:
38
+ --base-ref <ref> diff <mapsDir> as committed at <ref> (e.g. main) against your
39
+ working <mapsDir> — base from git, no recapture
28
40
  --max <n> max lines printed per surface before truncating (default: 40)
29
41
  --json <file> also write the full structured diff to <file>
30
42
  -h, --help show this help
@@ -37,6 +49,7 @@ const argv = process.argv.slice(2);
37
49
  const args = [];
38
50
  let MAX = 40;
39
51
  let jsonOut = null;
52
+ let baseRef = null;
40
53
  for (let i = 0; i < argv.length; i++) {
41
54
  if (argv[i] === '-h' || argv[i] === '--help') {
42
55
  process.stdout.write(HELP);
@@ -45,21 +58,46 @@ for (let i = 0; i < argv.length; i++) {
45
58
  else if (argv[i].startsWith('--max=')) MAX = Number(argv[i].slice(6));
46
59
  else if (argv[i] === '--json') jsonOut = argv[++i];
47
60
  else if (argv[i].startsWith('--json=')) jsonOut = argv[i].slice(7);
61
+ else if (argv[i] === '--base-ref') baseRef = argv[++i];
62
+ else if (argv[i].startsWith('--base-ref=')) baseRef = argv[i].slice(11);
48
63
  else if (argv[i].startsWith('--')) {
49
64
  console.error(`unknown flag: ${argv[i]}`);
50
65
  process.exit(2);
51
66
  } else args.push(argv[i]);
52
67
  }
53
- if (args.length !== 2 || !Number.isFinite(MAX)) {
54
- console.error('usage: styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)');
55
- process.exit(2);
56
- }
57
- const [dirA, dirB] = args;
58
- for (const d of [dirA, dirB]) {
59
- if (!fs.existsSync(d)) {
60
- console.error(`no capture at ${d}`);
68
+
69
+ let dirA;
70
+ 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]}`);
61
79
  process.exit(2);
62
80
  }
81
+ try {
82
+ tmpBase = materializeRef(baseRef, args[0]);
83
+ } catch (e) {
84
+ console.error(e instanceof GitRefError ? `styleproof-diff --base-ref: ${e.message}` : String(e?.message ?? e));
85
+ process.exit(2);
86
+ }
87
+ dirA = tmpBase;
88
+ dirB = args[0];
89
+ } else {
90
+ if (args.length !== 2 || !Number.isFinite(MAX)) {
91
+ console.error('usage: styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)');
92
+ process.exit(2);
93
+ }
94
+ [dirA, dirB] = args;
95
+ for (const d of [dirA, dirB]) {
96
+ if (!fs.existsSync(d)) {
97
+ console.error(`no capture at ${d}`);
98
+ process.exit(2);
99
+ }
100
+ }
63
101
  }
64
102
 
65
103
  let result;
@@ -68,6 +106,8 @@ try {
68
106
  } catch (e) {
69
107
  console.error(e.message);
70
108
  process.exit(2);
109
+ } finally {
110
+ if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
71
111
  }
72
112
  const { surfaces, counts } = result;
73
113
 
@@ -102,10 +142,8 @@ if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null
102
142
 
103
143
  const total = counts.dom + counts.style + counts.state;
104
144
  const newSurfaces = surfaces.filter((s) => s.missing).length;
105
- const surfaceCount = new Set([
106
- ...fs.readdirSync(dirA).filter((f) => /\.json(\.gz)?$/.test(f)),
107
- ...fs.readdirSync(dirB).filter((f) => /\.json(\.gz)?$/.test(f)),
108
- ]).size;
145
+ // One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
146
+ const surfaceCount = surfaces.length;
109
147
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
110
148
  console.log(
111
149
  total === 0
@@ -105,9 +105,9 @@ async function settle(page: Page) {
105
105
  const HEADER = `/**
106
106
  * styleproof capture spec (generated by \`styleproof-init\`).
107
107
  *
108
- * Each surface is one deterministic page state; widths sweep one viewport per
109
- * @media band so breakpoint rules are certified too. Capture against a
110
- * PRODUCTION build — dev servers inject their own styles.
108
+ * Each surface is one deterministic page state. Omit \`widths\` and StyleProof
109
+ * detects your @media breakpoints from the loaded CSS and sweeps one viewport per
110
+ * band — no config. Capture against a PRODUCTION build — dev servers inject styles.
111
111
  *
112
112
  * STYLEMAP_DIR=before npx playwright test styleproof # capture baseline
113
113
  * ...refactor your CSS...
@@ -140,7 +140,9 @@ const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
140
140
  await settle(page);
141
141
  },
142
142
  ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
143
- widths: [1280, 768, 390],
143
+ // No widths StyleProof detects your @media breakpoints from the loaded CSS and
144
+ // sweeps one viewport per band. Pass an explicit array (e.g. 1280, 768, 390) to pin them (or to
145
+ // cover a JS-only matchMedia breakpoint that has no CSS @media rule).
144
146
  }));
145
147
 
146
148
  defineStyleMapCapture({
@@ -173,7 +175,9 @@ const SURFACES: Surface[] = [
173
175
  await settle(page);
174
176
  },
175
177
  ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
176
- widths: [1280, 768, 390],
178
+ // No widths StyleProof detects your @media breakpoints from the loaded CSS and
179
+ // sweeps one viewport per band. Pass an explicit array (e.g. 1280, 768, 390) to pin them (or to
180
+ // cover a JS-only matchMedia breakpoint that has no CSS @media rule).
177
181
  },
178
182
  // Add one surface per distinct page state: open menus, dialogs, selected
179
183
  // tabs, form errors — anything whose styling you want certified.
@@ -205,6 +209,14 @@ const CONFIG = `import { defineConfig, devices } from '@playwright/test';
205
209
  // in-flight data either way, but a production build removes the variance at the source.)
206
210
  export default defineConfig({
207
211
  timeout: 120_000,
212
+ // Capture surfaces in PARALLEL. StyleProof generates one test per surface × width,
213
+ // each an isolated page writing a uniquely-keyed file (\`<key>@<width>.json.gz\`), with
214
+ // per-page record/replay and frozen clock — so they're independent and safe to run
215
+ // concurrently. Without this, all surfaces sit in one spec file and capture serially;
216
+ // with it they fan out across workers, a near-linear speedup on a multi-surface app.
217
+ // (\`--shard\` splits them across CI machines too; they write disjoint files into one
218
+ // dir.) Tune \`workers\` to your machine if needed.
219
+ fullyParallel: true,
208
220
  use: {
209
221
  baseURL: process.env.BASE_URL || '${baseUrl}',
210
222
  },
@@ -221,6 +233,70 @@ export default defineConfig({
221
233
  });
222
234
  `;
223
235
 
236
+ // The out-of-the-box gate: capture happens PRE-PUSH on your machine (where the app
237
+ // already builds + serves) and the lean computed-style map is committed, so main
238
+ // always carries a base map and CI is a fast, browser-less diff of two precomputed
239
+ // maps. The hook redirects capture into a committed `stylemaps/` dir and drops
240
+ // screenshots via the STYLEPROOF_BASEDIR / STYLEPROOF_SCREENSHOTS env knobs.
241
+ const HOOK_PATH = '.githooks/pre-push';
242
+ const PREPUSH_HOOK = `#!/bin/sh
243
+ # StyleProof pre-push hook (generated by styleproof-init).
244
+ #
245
+ # Captures this branch's computed-style map, commits it, and pushes it WITH your
246
+ # push — one \`git push\`, never two. So main always carries a base map and CI just
247
+ # diffs two precomputed maps (no browser). Maps are lean .json.gz under stylemaps/.
248
+ # Activate once per clone: git config core.hooksPath .githooks
249
+ set -e
250
+
251
+ remote="$1"
252
+
253
+ # Re-entry guard: the map commit is pushed by this hook (below), which fires the
254
+ # hook a second time — skip the expensive capture on that pass.
255
+ [ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
256
+
257
+ STYLEMAP_DIR=current STYLEPROOF_BASEDIR=stylemaps STYLEPROOF_SCREENSHOTS=0 \\
258
+ npx playwright test ${specPath}
259
+
260
+ git add stylemaps
261
+ git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
262
+
263
+ # Map changed: commit it and push the updated branch ourselves so you never push
264
+ # twice (git pushes the pre-hook commit, so a new commit here wouldn't otherwise go).
265
+ # The re-entry guard stops the inner push from recapturing; then abort this original,
266
+ # now-stale push so nothing is sent twice.
267
+ git commit -m "chore(styleproof): update computed-style map"
268
+ echo "StyleProof: style map changed — committed it and pushing with your branch…" >&2
269
+ STYLEPROOF_SKIP_CAPTURE=1 git push "$remote" HEAD
270
+ echo "StyleProof: ✓ pushed your branch with the updated map — nothing more to do." >&2
271
+ echo "StyleProof: (git's 'failed to push some refs' below is expected; the push above already succeeded.)" >&2
272
+ exit 1
273
+ `;
274
+
275
+ const CI_PATH = '.github/workflows/styleproof.yml';
276
+ const CI_WORKFLOW = `name: StyleProof
277
+
278
+ # Maps are captured + committed pre-push (see .githooks/pre-push), so CI runs no
279
+ # browser — it just diffs this branch's committed map against the base branch's.
280
+ on: pull_request
281
+
282
+ permissions:
283
+ contents: read
284
+
285
+ jobs:
286
+ styleproof:
287
+ runs-on: ubuntu-latest
288
+ steps:
289
+ - uses: actions/checkout@v4
290
+ with:
291
+ fetch-depth: 0 # need the base ref to read its committed map
292
+ - uses: actions/setup-node@v4
293
+ with:
294
+ node-version: '20'
295
+ - run: npm ci
296
+ - name: Diff this branch's committed map against the base
297
+ run: npx styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}" stylemaps/current
298
+ `;
299
+
224
300
  function writeFileSafe(file, contents, { force: f } = {}) {
225
301
  const exists = fs.existsSync(file);
226
302
  if (exists && !f) return { wrote: false, exists: true };
@@ -263,15 +339,34 @@ if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
263
339
  wroteSomething = true;
264
340
  }
265
341
 
266
- console.log('\nNext steps (run against a PRODUCTION build, not a dev server):');
267
- console.log(' 1. capture the baseline:');
268
- console.log(' STYLEMAP_DIR=baseline npx playwright test styleproof');
269
- console.log(' 2. commit it:');
270
- console.log(' git add __stylemaps__/baseline && git commit -m "chore: stylemap baseline"');
271
- console.log(' 3. after a CSS change, certify it:');
272
- console.log(' STYLEMAP_DIR=after npx playwright test styleproof');
273
- console.log(' npx styleproof-diff __stylemaps__/baseline __stylemaps__/after');
274
- console.log(' an empty diff is the certificate; a non-empty one names every drift.');
342
+ // Pre-push capture+commit hook the out-of-the-box gate (capture local, CI diffs).
343
+ const hook = writeFileSafe(HOOK_PATH, PREPUSH_HOOK, { force });
344
+ if (hook.wrote) {
345
+ fs.chmodSync(HOOK_PATH, 0o755);
346
+ console.log(`${hook.exists ? 'overwrote' : 'created'} ${HOOK_PATH} (pre-push capture + commit)`);
347
+ wroteSomething = true;
348
+ } else {
349
+ console.log(`${HOOK_PATH} already exists — left untouched (use --force to overwrite)`);
350
+ }
351
+
352
+ // Browser-less CI diff — never overwrite an existing workflow.
353
+ const ci = writeFileSafe(CI_PATH, CI_WORKFLOW);
354
+ if (ci.wrote) {
355
+ console.log(`created ${CI_PATH} (browser-less CI diff)`);
356
+ wroteSomething = true;
357
+ } else {
358
+ console.log(`${CI_PATH} already exists — left untouched`);
359
+ }
360
+
361
+ console.log('\nHow the gate works — capture local, CI just diffs:');
362
+ console.log(' 1. Activate the pre-push hook (once per clone):');
363
+ console.log(' git config core.hooksPath .githooks');
364
+ console.log(' 2. Push your branch. The hook captures the computed-style map against a');
365
+ console.log(' PRODUCTION build, commits it (lean .json.gz under stylemaps/), and pushes it');
366
+ console.log(' with your branch — one `git push`, never two.');
367
+ console.log(' 3. On the PR, CI diffs your committed map against the base branch — no browser,');
368
+ console.log(' just a fast comparison of precomputed maps. An empty diff certifies no change.');
369
+ console.log(' So main always carries a base map and every PR is a quick diff, not a recapture.');
275
370
 
276
371
  if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
277
372
  process.exit(0);
@@ -10,13 +10,18 @@
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';
13
14
  import { generateStyleMapReport } from '../dist/report.js';
15
+ import { materializeRef, GitRefError } from '../dist/gitref.js';
14
16
 
15
17
  const HELP = `styleproof-report — reviewable before/after report from two captures
16
18
 
17
19
  usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options]
20
+ styleproof-report --base-ref <gitref> <mapsDir> --out <dir> [options]
18
21
 
19
22
  options:
23
+ --base-ref <ref> use <mapsDir> as committed at <ref> (e.g. main) as the
24
+ before side — base from git, no recapture
20
25
  --out <dir> output directory (default: styleproof-report)
21
26
  --image-base-url <url> prefix for image URLs in report.md (default: relative)
22
27
  --pad <px> padding around changed rects when cropping (default: 24)
@@ -47,6 +52,7 @@ let minWidth;
47
52
  let minHeight;
48
53
  let includeLayoutNoise = false;
49
54
  let includeContent = false;
55
+ let baseRef = null;
50
56
  for (let i = 0; i < argv.length; i++) {
51
57
  const a = argv[i];
52
58
  if (a === '-h' || a === '--help') {
@@ -70,14 +76,36 @@ for (let i = 0; i < argv.length; i++) {
70
76
  else if (a.startsWith('--include-layout-noise=')) includeLayoutNoise = a.slice(23) !== 'false';
71
77
  else if (a === '--include-content') includeContent = true;
72
78
  else if (a.startsWith('--include-content=')) includeContent = a.slice(18) !== 'false';
79
+ else if (a === '--base-ref') baseRef = argv[++i];
80
+ else if (a.startsWith('--base-ref=')) baseRef = a.slice(11);
73
81
  else if (a.startsWith('--')) {
74
82
  console.error(`unknown flag: ${a}`);
75
83
  process.exit(2);
76
84
  } else args.push(a);
77
85
  }
78
- if (args.length !== 2) {
79
- console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
80
- process.exit(2);
86
+ let beforeDir;
87
+ 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
+ }
94
+ try {
95
+ tmpBase = materializeRef(baseRef, args[0]);
96
+ } catch (e) {
97
+ console.error(e instanceof GitRefError ? `styleproof-report --base-ref: ${e.message}` : String(e?.message ?? e));
98
+ process.exit(2);
99
+ }
100
+ beforeDir = tmpBase;
101
+ afterDir = args[0];
102
+ } else {
103
+ if (args.length !== 2) {
104
+ console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
105
+ process.exit(2);
106
+ }
107
+ beforeDir = args[0];
108
+ afterDir = args[1];
81
109
  }
82
110
  for (const [name, val] of [
83
111
  ['--pad', pad],
@@ -99,8 +127,8 @@ if (foldDetailsAt !== undefined && Number.isNaN(foldDetailsAt)) {
99
127
  let result;
100
128
  try {
101
129
  result = generateStyleMapReport({
102
- beforeDir: args[0],
103
- afterDir: args[1],
130
+ beforeDir,
131
+ afterDir,
104
132
  outDir: flags.out,
105
133
  imageBaseUrl: flags.imageBaseUrl || undefined,
106
134
  pad,
@@ -114,6 +142,8 @@ try {
114
142
  } catch (e) {
115
143
  console.error(e.message);
116
144
  process.exit(2);
145
+ } finally {
146
+ if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
117
147
  }
118
148
 
119
149
  const newNote = result.newSurfaces ? ` (+${result.newSurfaces} new surface(s) with no baseline)` : '';
@@ -0,0 +1,30 @@
1
+ import type { Page } from '@playwright/test';
2
+ /**
3
+ * Parse the px width BOUNDARIES a single `@media` condition introduces — the widths
4
+ * at which its match flips. A `min-width: V` opens a band at `V`; a `max-width: V`
5
+ * keeps the band below active through `V`, so the next band opens at `V + 1`. Range
6
+ * syntax (`width >= V`, `V <= width`, `width < V`, …) is normalised the same way.
7
+ * `em`/`rem` are resolved against `rootFontPx`. Non-width conditions yield nothing.
8
+ */
9
+ export declare function mediaTextWidthBoundaries(mediaText: string, rootFontPx?: number): number[];
10
+ /**
11
+ * Turn breakpoint boundaries into one representative viewport width per band: the
12
+ * base band below the first boundary uses `baseWidth` (clamped strictly inside it),
13
+ * every other band its lower boundary. With no boundaries (no width `@media` rules)
14
+ * the layout is band-invariant, so a single `noQueryWidth` covers it. Ascending,
15
+ * de-duplicated.
16
+ */
17
+ export declare function widthsFromBoundaries(boundaries: number[], opts?: {
18
+ baseWidth?: number;
19
+ noQueryWidth?: number;
20
+ }): number[];
21
+ /**
22
+ * Detect the viewport widths to sweep for the currently-loaded page: read every
23
+ * `@media` width breakpoint from the live CSSOM and return one width per band.
24
+ * Throws if any stylesheet is unreadable (cross-origin) — detection is authoritative
25
+ * or it fails; it never guesses. Call after the page has loaded its styles.
26
+ */
27
+ export declare function detectViewportWidths(page: Page, opts?: {
28
+ baseWidth?: number;
29
+ noQueryWidth?: number;
30
+ }): Promise<number[]>;
@@ -0,0 +1,114 @@
1
+ /** Serialized into the browser by page.evaluate; cannot call module helpers. */
2
+ function collectMediaTexts() {
3
+ const mediaTexts = [];
4
+ const unreadable = [];
5
+ const walk = (rules) => {
6
+ for (const rule of Array.from(rules)) {
7
+ // CSSMediaRule has `.media`; a CSSContainerRule does not, so container
8
+ // queries (container-relative, not viewport) are correctly skipped here.
9
+ const media = rule.media;
10
+ if (media && typeof media.mediaText === 'string')
11
+ mediaTexts.push(media.mediaText);
12
+ // Descend into @supports / @layer / nested @media groups.
13
+ const nested = rule.cssRules;
14
+ if (nested) {
15
+ try {
16
+ walk(nested);
17
+ }
18
+ catch {
19
+ /* a nested rule list we can't read — ignore, top-level catch reports sheets */
20
+ }
21
+ }
22
+ }
23
+ };
24
+ for (const sheet of Array.from(document.styleSheets)) {
25
+ try {
26
+ const rules = sheet.cssRules; // throws for a cross-origin sheet with no CORS
27
+ if (rules)
28
+ walk(rules);
29
+ }
30
+ catch {
31
+ unreadable.push(sheet.href ?? '<inline>');
32
+ }
33
+ }
34
+ const rootFontPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
35
+ return { mediaTexts, unreadable, rootFontPx };
36
+ }
37
+ /**
38
+ * Parse the px width BOUNDARIES a single `@media` condition introduces — the widths
39
+ * at which its match flips. A `min-width: V` opens a band at `V`; a `max-width: V`
40
+ * keeps the band below active through `V`, so the next band opens at `V + 1`. Range
41
+ * syntax (`width >= V`, `V <= width`, `width < V`, …) is normalised the same way.
42
+ * `em`/`rem` are resolved against `rootFontPx`. Non-width conditions yield nothing.
43
+ */
44
+ export function mediaTextWidthBoundaries(mediaText, rootFontPx = 16) {
45
+ const t = mediaText.toLowerCase();
46
+ const out = new Set();
47
+ const px = (v, unit) => (unit === 'px' ? v : v * rootFontPx);
48
+ const add = (v) => {
49
+ const n = Math.round(v);
50
+ if (n > 0)
51
+ out.add(n);
52
+ };
53
+ let m;
54
+ const reMinMax = /\((min|max)-width\s*:\s*([\d.]+)(px|r?em)\)/g;
55
+ while ((m = reMinMax.exec(t)) !== null) {
56
+ const v = px(parseFloat(m[2]), m[3]);
57
+ if (m[1] === 'min')
58
+ add(v);
59
+ else
60
+ add(v + 1); // max-width: V → next band opens at V+1
61
+ }
62
+ // Range syntax: `width <op> Vpx` and the mirrored `Vpx <op> width`.
63
+ const flip = (op) => (op === '<=' ? '>=' : op === '<' ? '>' : op === '>=' ? '<=' : '<');
64
+ const addCmp = (v, op) => {
65
+ if (op === '>=')
66
+ add(v);
67
+ else if (op === '>')
68
+ add(v + 1);
69
+ else if (op === '<=')
70
+ add(v + 1);
71
+ else if (op === '<')
72
+ add(v);
73
+ };
74
+ const reRight = /width\s*(<=|>=|<|>)\s*([\d.]+)(px|r?em)/g;
75
+ while ((m = reRight.exec(t)) !== null)
76
+ addCmp(px(parseFloat(m[2]), m[3]), m[1]);
77
+ const reLeft = /([\d.]+)(px|r?em)\s*(<=|>=|<|>)\s*width/g;
78
+ while ((m = reLeft.exec(t)) !== null)
79
+ addCmp(px(parseFloat(m[1]), m[2]), flip(m[3]));
80
+ return [...out].sort((a, b) => a - b);
81
+ }
82
+ /**
83
+ * Turn breakpoint boundaries into one representative viewport width per band: the
84
+ * base band below the first boundary uses `baseWidth` (clamped strictly inside it),
85
+ * every other band its lower boundary. With no boundaries (no width `@media` rules)
86
+ * the layout is band-invariant, so a single `noQueryWidth` covers it. Ascending,
87
+ * de-duplicated.
88
+ */
89
+ export function widthsFromBoundaries(boundaries, opts = {}) {
90
+ const baseWidth = opts.baseWidth ?? 360;
91
+ const noQueryWidth = opts.noQueryWidth ?? 1280;
92
+ const bps = [...new Set(boundaries.map((n) => Math.round(n)))].filter((n) => n > 0).sort((a, b) => a - b);
93
+ if (bps.length === 0)
94
+ return [noQueryWidth];
95
+ const base = Math.min(baseWidth, bps[0] - 1);
96
+ return [...new Set([base, ...bps])].filter((n) => n > 0).sort((a, b) => a - b);
97
+ }
98
+ /**
99
+ * Detect the viewport widths to sweep for the currently-loaded page: read every
100
+ * `@media` width breakpoint from the live CSSOM and return one width per band.
101
+ * Throws if any stylesheet is unreadable (cross-origin) — detection is authoritative
102
+ * or it fails; it never guesses. Call after the page has loaded its styles.
103
+ */
104
+ export async function detectViewportWidths(page, opts = {}) {
105
+ const { mediaTexts, unreadable, rootFontPx } = await page.evaluate(collectMediaTexts);
106
+ if (unreadable.length > 0) {
107
+ throw new Error(`styleproof: can't detect breakpoints — ${unreadable.length} stylesheet(s) are unreadable ` +
108
+ `(cross-origin, no CORS): ${unreadable.join(', ')}. Detection reads every stylesheet to stay ` +
109
+ `100% accurate, so it fails rather than guess. Make them same-origin / CORS-readable, or set ` +
110
+ `\`widths\` on the surface to skip detection.`);
111
+ }
112
+ const boundaries = mediaTexts.flatMap((t) => mediaTextWidthBoundaries(t, rootFontPx));
113
+ return widthsFromBoundaries(boundaries, opts);
114
+ }
@@ -0,0 +1,13 @@
1
+ /** A readable failure materialising a base from git — CLIs map this to exit 2. */
2
+ export declare class GitRefError extends Error {
3
+ }
4
+ /**
5
+ * Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
6
+ * so a diff/report can use e.g. `main`'s committed map as the base with no checkout
7
+ * and no recapture — the "base map lives on main, CI just diffs" model.
8
+ *
9
+ * Reads purely through git (`ls-tree`/`show`, no `tar`/deps); binary `.json.gz`
10
+ * bytes come straight from `git show`. The caller owns cleanup of the returned dir.
11
+ * Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
12
+ */
13
+ export declare function materializeRef(ref: string, dir: string): string;
package/dist/gitref.js ADDED
@@ -0,0 +1,44 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ /** A readable failure materialising a base from git — CLIs map this to exit 2. */
6
+ export class GitRefError extends Error {
7
+ }
8
+ /**
9
+ * Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
10
+ * so a diff/report can use e.g. `main`'s committed map as the base with no checkout
11
+ * and no recapture — the "base map lives on main, CI just diffs" model.
12
+ *
13
+ * Reads purely through git (`ls-tree`/`show`, no `tar`/deps); binary `.json.gz`
14
+ * bytes come straight from `git show`. The caller owns cleanup of the returned dir.
15
+ * Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
16
+ */
17
+ export function materializeRef(ref, dir) {
18
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', maxBuffer: 1 << 28 });
19
+ if (top.status !== 0)
20
+ throw new GitRefError('must run inside a git repository');
21
+ const toplevel = top.stdout.trim();
22
+ // -z: NUL-separated, unquoted paths (handles spaces/unicode).
23
+ const ls = spawnSync('git', ['ls-tree', '-r', '-z', '--name-only', ref, '--', dir], {
24
+ encoding: 'utf8',
25
+ maxBuffer: 1 << 28,
26
+ });
27
+ if (ls.status !== 0)
28
+ throw new GitRefError(`cannot read ${dir} at ${ref}: ${(ls.stderr || '').trim()}`);
29
+ const files = ls.stdout.split('\0').filter(Boolean);
30
+ if (files.length === 0) {
31
+ throw new GitRefError(`no committed captures at ${dir} in ${ref} — commit the maps so the base lives in git`);
32
+ }
33
+ const dirRepoRel = path.relative(toplevel, path.resolve(dir));
34
+ const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-baseref-'));
35
+ for (const f of files) {
36
+ const show = spawnSync('git', ['show', `${ref}:${f}`], { maxBuffer: 1 << 28 }); // buffer — preserves .json.gz
37
+ if (show.status !== 0)
38
+ throw new GitRefError(`cannot read ${f} at ${ref}`);
39
+ const out = path.join(dest, path.relative(dirRepoRel, f));
40
+ fs.mkdirSync(path.dirname(out), { recursive: true });
41
+ fs.writeFileSync(out, show.stdout);
42
+ }
43
+ return dest;
44
+ }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
4
4
  export type { Surface, DefineOptions, CrawlOptions } from './runner.js';
5
5
  export { coverageGaps } from './coverage.js';
6
6
  export type { CoverageGaps } from './coverage.js';
7
+ export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
7
8
  export { discoverNextRoutes } from './routes.js';
8
9
  export type { DiscoveredRoute } from './routes.js';
9
10
  export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
2
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
3
3
  export { coverageGaps } from './coverage.js';
4
+ export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
4
5
  export { discoverNextRoutes } from './routes.js';
5
6
  export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
6
7
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
package/dist/runner.d.ts CHANGED
@@ -16,8 +16,14 @@ export type Surface = {
16
16
  go: (page: Page) => Promise<void>;
17
17
  /** Selectors for nondeterministic regions (live data, third-party embeds); skipped entirely. */
18
18
  ignore?: string[];
19
- /** Viewport widths to sweep — one per @media band, so breakpoint rules are verified too. */
20
- widths: number[];
19
+ /**
20
+ * Viewport widths to sweep — one per @media band, so breakpoint rules are verified
21
+ * too. OMIT to detect the app's real breakpoints from the loaded CSSOM and sweep one
22
+ * width per band automatically (no config); detection fails loudly if a stylesheet
23
+ * is cross-origin/unreadable rather than guess. Set it explicitly to pin the sweep
24
+ * or to cover a JS-only (`matchMedia`) breakpoint that has no CSS `@media` rule.
25
+ */
26
+ widths?: number[];
21
27
  /** Viewport height: a number, or a function of the width (default 800). */
22
28
  height?: number | ((width: number) => number);
23
29
  };
@@ -129,6 +135,19 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
129
135
  * forces it on either way.
130
136
  */
131
137
  export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
138
+ /**
139
+ * Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
140
+ * default. Lets a pre-push hook redirect capture into a COMMITTED dir (so main
141
+ * always carries a base map and CI just diffs precomputed maps) without editing
142
+ * the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
143
+ */
144
+ export declare function resolveBaseDir(baseDir: string | undefined, env?: string | undefined): string;
145
+ /**
146
+ * Whether to save full-page screenshots: explicit `screenshots` wins, else
147
+ * `STYLEPROOF_SCREENSHOTS=0` turns them off — for committed maps you want the lean
148
+ * `.json.gz` only, never PNGs in git history. On by default otherwise.
149
+ */
150
+ export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
132
151
  /** The capture settings every capturer shares (everything bar the surface set). */
133
152
  type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
134
153
  /**
package/dist/runner.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
6
  import { coverageGaps } from './coverage.js';
7
+ import { detectViewportWidths } from './breakpoints.js';
7
8
  import { selectCrawlLinks } from './crawl.js';
8
9
  /** One-line description of the first drift finding, for the self-check error. */
9
10
  function driftDesc(f) {
@@ -125,6 +126,23 @@ async function captureSurface(page, surface, width, s) {
125
126
  export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
126
127
  return env === '1' || !replayFrom;
127
128
  }
129
+ /**
130
+ * Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
131
+ * default. Lets a pre-push hook redirect capture into a COMMITTED dir (so main
132
+ * always carries a base map and CI just diffs precomputed maps) without editing
133
+ * the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
134
+ */
135
+ export function resolveBaseDir(baseDir, env = process.env.STYLEPROOF_BASEDIR) {
136
+ return baseDir ?? env ?? '__stylemaps__';
137
+ }
138
+ /**
139
+ * Whether to save full-page screenshots: explicit `screenshots` wins, else
140
+ * `STYLEPROOF_SCREENSHOTS=0` turns them off — for committed maps you want the lean
141
+ * `.json.gz` only, never PNGs in git history. On by default otherwise.
142
+ */
143
+ export function resolveScreenshots(screenshots, env = process.env.STYLEPROOF_SCREENSHOTS) {
144
+ return screenshots ?? env !== '0';
145
+ }
128
146
  /**
129
147
  * Apply the capture defaults once, so explicit-surface and crawl capture can't
130
148
  * drift — the replay boundary, frozen clock and self-check policy resolve to the
@@ -135,8 +153,8 @@ function resolveSettings(c) {
135
153
  const replayFrom = c.replayFrom ?? process.env.STYLEPROOF_REPLAY_FROM;
136
154
  return {
137
155
  dir: c.dir,
138
- baseDir: c.baseDir ?? '__stylemaps__',
139
- screenshots: c.screenshots ?? true,
156
+ baseDir: resolveBaseDir(c.baseDir),
157
+ screenshots: resolveScreenshots(c.screenshots),
140
158
  replayFrom,
141
159
  replayUrl: c.replayUrl ?? process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**',
142
160
  freezeClock: c.freezeClock ?? true,
@@ -182,10 +200,24 @@ export function defineStyleMapCapture(options) {
182
200
  test.describe('styleproof capture', () => {
183
201
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
184
202
  for (const surface of surfaces) {
185
- for (const width of surface.widths) {
186
- test(`${surface.key} @ ${width}`, ({ page }) => {
187
- test.setTimeout(180_000);
188
- return captureSurface(page, surface, width, settings);
203
+ if (surface.widths && surface.widths.length > 0) {
204
+ // Explicit widths: one parallelizable test per surface × width.
205
+ for (const width of surface.widths) {
206
+ test(`${surface.key} @ ${width}`, ({ page }) => {
207
+ test.setTimeout(180_000);
208
+ return captureSurface(page, surface, width, settings);
209
+ });
210
+ }
211
+ }
212
+ else {
213
+ // Auto widths: the band set isn't known until the page renders its CSS, so a
214
+ // single test loads once, detects the breakpoints, then sweeps each band.
215
+ test(`${surface.key} @ auto`, async ({ page }) => {
216
+ await surface.go(page);
217
+ const widths = await detectViewportWidths(page);
218
+ test.setTimeout(Math.max(180_000, widths.length * 60_000));
219
+ for (const width of widths)
220
+ await captureSurface(page, surface, width, settings);
189
221
  });
190
222
  }
191
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
4
  "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -61,7 +61,8 @@
61
61
  "format:check": "prettier --check \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
62
62
  "test": "npm run build && node --test test/*.test.mjs",
63
63
  "test:watch": "node --test --watch test/*.test.mjs",
64
- "test:e2e": "npm run build && playwright test test/smoke.e2e.spec.ts",
64
+ "test:e2e": "npm run build && playwright test",
65
+ "bench": "npm run build && node bench/gate-speed.mjs",
65
66
  "init": "node ./bin/styleproof-init.mjs",
66
67
  "prepare": "npm run build && husky",
67
68
  "prepublishOnly": "npm run clean && npm run build && npm run typecheck"