styleproof 2.3.0 → 2.4.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,58 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] - 2026-06-26
11
+
12
+ ### Added
13
+
14
+ - **Automatic breakpoint detection — `Surface.widths` is now optional.** Omit it and
15
+ StyleProof reads the app's real viewport breakpoints from the loaded CSSOM at
16
+ capture time and sweeps one width per `@media` band — no config, no guessing.
17
+ Because it reads the browser's parsed stylesheets (not your source), it's
18
+ framework-agnostic: Tailwind, CSS Modules, styled-components, Sass and vanilla all
19
+ resolve to the same `@media` rules. It is authoritative **or it fails**: an
20
+ unreadable cross-origin stylesheet throws (so a band is never silently missed)
21
+ rather than guessing. `min/max-width` and range syntax (`width >= …`) are handled,
22
+ `em`/`rem` resolved against the root font size; container/print/height queries are
23
+ correctly ignored. Set `widths` explicitly to pin the sweep or to cover a JS-only
24
+ (`matchMedia`) breakpoint that has no CSS rule. New exports: `detectViewportWidths`,
25
+ `mediaTextWidthBoundaries`, `widthsFromBoundaries`. **`styleproof-init` now scaffolds
26
+ surfaces with no `widths`**, so a fresh project gets zero-config breakpoint detection
27
+ by default.
28
+ - **Out-of-the-box committed-map gate — capture pre-push, CI just diffs.**
29
+ `styleproof-init` now scaffolds the whole flow as the default: a **pre-push hook**
30
+ (`.githooks/pre-push`) captures this branch's computed-style map against a
31
+ production build, commits it as a lean `.json.gz` under `stylemaps/`, and **pushes
32
+ it with your branch — one `git push`, never two** (the hook pushes the map commit
33
+ itself, because git would otherwise send only the pre-hook commit); and a
34
+ **browser-less CI workflow** that just diffs the committed map against the base. So
35
+ `main` always carries a base map and every PR is a fast comparison of precomputed
36
+ maps, never a recapture. (Capture belongs pre-push because it needs the app built +
37
+ served; the same-environment requirement is self-enforced by the self-check.)
38
+ - **`styleproof-diff --base-ref <gitref>` — diff against a committed base in git.**
39
+ `styleproof-diff --base-ref main <mapsDir>` materialises the captures committed at
40
+ `<mapsDir>` as of `main` and diffs them against your working `<mapsDir>` — the CI
41
+ half of the gate above. Reads the base purely through git (`ls-tree`/`show`, no
42
+ `tar`/deps; binary `.json.gz` preserved), into a temp dir cleaned up after.
43
+ - **`STYLEPROOF_BASEDIR` / `STYLEPROOF_SCREENSHOTS` env knobs** (same wiring style as
44
+ `STYLEPROOF_REPLAY_*`): redirect capture into a committed dir and drop screenshots
45
+ for lean, commit-friendly maps — without editing the spec. This is what lets the
46
+ pre-push hook capture committed maps from the stock generated spec.
47
+
48
+ ## [2.3.1] - 2026-06-23
49
+
50
+ ### Fixed
51
+
52
+ - **Animation freeze is now deterministic for content that mounts during the
53
+ settle.** Motion longhands (declared `animation`/`transition`) were read _before_
54
+ the settle, so an element that mounts while the page settles — a status glyph
55
+ gated on a snapshot fetch — missed that read: its declared `animation-duration`
56
+ was folded back on a run where it mounted early but left frozen to `0s` on a run
57
+ where it mounted late, surfacing as a self-check `animation-duration: 0s ↔ 1.6s`
58
+ non-deterministic failure. Motion is now read on the settled DOM (the freeze is
59
+ lifted only for that read), so a late-mounted animated element is captured
60
+ identically every run.
61
+
10
62
  ## [2.3.0] - 2026-06-23
11
63
 
12
64
  ### Added
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,31 @@
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';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { spawnSync } from 'node:child_process';
21
32
  import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
22
33
 
23
34
  const HELP = `styleproof-diff — certify a CSS refactor by diffing two computed-style captures
24
35
 
25
36
  usage: styleproof-diff <beforeDir> <afterDir> [options]
37
+ styleproof-diff --base-ref <gitref> <mapsDir> [options]
26
38
 
27
39
  options:
40
+ --base-ref <ref> diff <mapsDir> as committed at <ref> (e.g. main) against your
41
+ working <mapsDir> — base from git, no recapture
28
42
  --max <n> max lines printed per surface before truncating (default: 40)
29
43
  --json <file> also write the full structured diff to <file>
30
44
  -h, --help show this help
@@ -37,6 +51,7 @@ const argv = process.argv.slice(2);
37
51
  const args = [];
38
52
  let MAX = 40;
39
53
  let jsonOut = null;
54
+ let baseRef = null;
40
55
  for (let i = 0; i < argv.length; i++) {
41
56
  if (argv[i] === '-h' || argv[i] === '--help') {
42
57
  process.stdout.write(HELP);
@@ -45,21 +60,85 @@ for (let i = 0; i < argv.length; i++) {
45
60
  else if (argv[i].startsWith('--max=')) MAX = Number(argv[i].slice(6));
46
61
  else if (argv[i] === '--json') jsonOut = argv[++i];
47
62
  else if (argv[i].startsWith('--json=')) jsonOut = argv[i].slice(7);
63
+ else if (argv[i] === '--base-ref') baseRef = argv[++i];
64
+ else if (argv[i].startsWith('--base-ref=')) baseRef = argv[i].slice(11);
48
65
  else if (argv[i].startsWith('--')) {
49
66
  console.error(`unknown flag: ${argv[i]}`);
50
67
  process.exit(2);
51
68
  } else args.push(argv[i]);
52
69
  }
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);
70
+
71
+ /** Run git, exiting 2 with a clean message on failure. */
72
+ function git(gitArgs, { encoding = 'buffer' } = {}) {
73
+ const r = spawnSync('git', gitArgs, { encoding, maxBuffer: 1 << 28 });
74
+ return r;
75
+ }
76
+
77
+ /**
78
+ * Materialise the captures committed at <dir> as of <ref> into a fresh temp dir,
79
+ * so the base of the diff is e.g. main's map — no checkout, no recapture. Reads
80
+ * only via git (no `tar`/deps); binary `.json.gz` files come through `git show`.
81
+ */
82
+ function materializeRef(ref, dir) {
83
+ const top = git(['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
84
+ if (top.status !== 0) {
85
+ console.error('styleproof-diff --base-ref must run inside a git repository');
86
+ process.exit(2);
87
+ }
88
+ const toplevel = top.stdout.trim();
89
+ // -z: NUL-separated, unquoted paths (handles spaces/unicode).
90
+ const ls = git(['ls-tree', '-r', '-z', '--name-only', ref, '--', dir], { encoding: 'utf8' });
91
+ if (ls.status !== 0) {
92
+ console.error(`cannot read ${dir} at ${ref}: ${(ls.stderr || '').toString().trim()}`);
93
+ process.exit(2);
94
+ }
95
+ const files = ls.stdout.split('\0').filter(Boolean);
96
+ if (files.length === 0) {
97
+ console.error(`no committed captures at ${dir} in ${ref} — commit the maps so the base lives in git`);
98
+ process.exit(2);
99
+ }
100
+ const dirRepoRel = path.relative(toplevel, path.resolve(dir));
101
+ const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-baseref-'));
102
+ for (const f of files) {
103
+ const show = git(['show', `${ref}:${f}`]); // buffer — preserves .json.gz bytes
104
+ if (show.status !== 0) {
105
+ console.error(`cannot read ${f} at ${ref}`);
106
+ process.exit(2);
107
+ }
108
+ const out = path.join(dest, path.relative(dirRepoRel, f));
109
+ fs.mkdirSync(path.dirname(out), { recursive: true });
110
+ fs.writeFileSync(out, show.stdout);
111
+ }
112
+ return dest;
56
113
  }
57
- const [dirA, dirB] = args;
58
- for (const d of [dirA, dirB]) {
59
- if (!fs.existsSync(d)) {
60
- console.error(`no capture at ${d}`);
114
+
115
+ let dirA;
116
+ let dirB;
117
+ let tmpBase = null;
118
+ if (baseRef) {
119
+ if (args.length !== 1 || !Number.isFinite(MAX)) {
120
+ console.error('usage: styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]');
61
121
  process.exit(2);
62
122
  }
123
+ if (!fs.existsSync(args[0])) {
124
+ console.error(`no capture at ${args[0]}`);
125
+ process.exit(2);
126
+ }
127
+ tmpBase = materializeRef(baseRef, args[0]);
128
+ dirA = tmpBase;
129
+ dirB = args[0];
130
+ } else {
131
+ if (args.length !== 2 || !Number.isFinite(MAX)) {
132
+ console.error('usage: styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)');
133
+ process.exit(2);
134
+ }
135
+ [dirA, dirB] = args;
136
+ for (const d of [dirA, dirB]) {
137
+ if (!fs.existsSync(d)) {
138
+ console.error(`no capture at ${d}`);
139
+ process.exit(2);
140
+ }
141
+ }
63
142
  }
64
143
 
65
144
  let result;
@@ -68,6 +147,8 @@ try {
68
147
  } catch (e) {
69
148
  console.error(e.message);
70
149
  process.exit(2);
150
+ } finally {
151
+ if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
71
152
  }
72
153
  const { surfaces, counts } = result;
73
154
 
@@ -102,10 +183,8 @@ if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null
102
183
 
103
184
  const total = counts.dom + counts.style + counts.state;
104
185
  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;
186
+ // One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
187
+ const surfaceCount = surfaces.length;
109
188
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
110
189
  console.log(
111
190
  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.
@@ -221,6 +225,70 @@ export default defineConfig({
221
225
  });
222
226
  `;
223
227
 
228
+ // The out-of-the-box gate: capture happens PRE-PUSH on your machine (where the app
229
+ // already builds + serves) and the lean computed-style map is committed, so main
230
+ // always carries a base map and CI is a fast, browser-less diff of two precomputed
231
+ // maps. The hook redirects capture into a committed `stylemaps/` dir and drops
232
+ // screenshots via the STYLEPROOF_BASEDIR / STYLEPROOF_SCREENSHOTS env knobs.
233
+ const HOOK_PATH = '.githooks/pre-push';
234
+ const PREPUSH_HOOK = `#!/bin/sh
235
+ # StyleProof pre-push hook (generated by styleproof-init).
236
+ #
237
+ # Captures this branch's computed-style map, commits it, and pushes it WITH your
238
+ # push — one \`git push\`, never two. So main always carries a base map and CI just
239
+ # diffs two precomputed maps (no browser). Maps are lean .json.gz under stylemaps/.
240
+ # Activate once per clone: git config core.hooksPath .githooks
241
+ set -e
242
+
243
+ remote="$1"
244
+
245
+ # Re-entry guard: the map commit is pushed by this hook (below), which fires the
246
+ # hook a second time — skip the expensive capture on that pass.
247
+ [ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
248
+
249
+ STYLEMAP_DIR=current STYLEPROOF_BASEDIR=stylemaps STYLEPROOF_SCREENSHOTS=0 \\
250
+ npx playwright test ${specPath}
251
+
252
+ git add stylemaps
253
+ git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
254
+
255
+ # Map changed: commit it and push the updated branch ourselves so you never push
256
+ # twice (git pushes the pre-hook commit, so a new commit here wouldn't otherwise go).
257
+ # The re-entry guard stops the inner push from recapturing; then abort this original,
258
+ # now-stale push so nothing is sent twice.
259
+ git commit -m "chore(styleproof): update computed-style map"
260
+ echo "StyleProof: style map changed — committed it and pushing with your branch…" >&2
261
+ STYLEPROOF_SKIP_CAPTURE=1 git push "$remote" HEAD
262
+ echo "StyleProof: ✓ pushed your branch with the updated map — nothing more to do." >&2
263
+ echo "StyleProof: (git's 'failed to push some refs' below is expected; the push above already succeeded.)" >&2
264
+ exit 1
265
+ `;
266
+
267
+ const CI_PATH = '.github/workflows/styleproof.yml';
268
+ const CI_WORKFLOW = `name: StyleProof
269
+
270
+ # Maps are captured + committed pre-push (see .githooks/pre-push), so CI runs no
271
+ # browser — it just diffs this branch's committed map against the base branch's.
272
+ on: pull_request
273
+
274
+ permissions:
275
+ contents: read
276
+
277
+ jobs:
278
+ styleproof:
279
+ runs-on: ubuntu-latest
280
+ steps:
281
+ - uses: actions/checkout@v4
282
+ with:
283
+ fetch-depth: 0 # need the base ref to read its committed map
284
+ - uses: actions/setup-node@v4
285
+ with:
286
+ node-version: '20'
287
+ - run: npm ci
288
+ - name: Diff this branch's committed map against the base
289
+ run: npx styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}" stylemaps/current
290
+ `;
291
+
224
292
  function writeFileSafe(file, contents, { force: f } = {}) {
225
293
  const exists = fs.existsSync(file);
226
294
  if (exists && !f) return { wrote: false, exists: true };
@@ -263,15 +331,34 @@ if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
263
331
  wroteSomething = true;
264
332
  }
265
333
 
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.');
334
+ // Pre-push capture+commit hook the out-of-the-box gate (capture local, CI diffs).
335
+ const hook = writeFileSafe(HOOK_PATH, PREPUSH_HOOK, { force });
336
+ if (hook.wrote) {
337
+ fs.chmodSync(HOOK_PATH, 0o755);
338
+ console.log(`${hook.exists ? 'overwrote' : 'created'} ${HOOK_PATH} (pre-push capture + commit)`);
339
+ wroteSomething = true;
340
+ } else {
341
+ console.log(`${HOOK_PATH} already exists — left untouched (use --force to overwrite)`);
342
+ }
343
+
344
+ // Browser-less CI diff — never overwrite an existing workflow.
345
+ const ci = writeFileSafe(CI_PATH, CI_WORKFLOW);
346
+ if (ci.wrote) {
347
+ console.log(`created ${CI_PATH} (browser-less CI diff)`);
348
+ wroteSomething = true;
349
+ } else {
350
+ console.log(`${CI_PATH} already exists — left untouched`);
351
+ }
352
+
353
+ console.log('\nHow the gate works — capture local, CI just diffs:');
354
+ console.log(' 1. Activate the pre-push hook (once per clone):');
355
+ console.log(' git config core.hooksPath .githooks');
356
+ console.log(' 2. Push your branch. The hook captures the computed-style map against a');
357
+ console.log(' PRODUCTION build, commits it (lean .json.gz under stylemaps/), and pushes it');
358
+ console.log(' with your branch — one `git push`, never two.');
359
+ console.log(' 3. On the PR, CI diffs your committed map against the base branch — no browser,');
360
+ console.log(' just a fast comparison of precomputed maps. An empty diff certifies no change.');
361
+ console.log(' So main always carries a base map and every PR is a quick diff, not a recapture.');
275
362
 
276
363
  if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
277
364
  process.exit(0);
@@ -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
+ }
package/dist/capture.js CHANGED
@@ -562,15 +562,26 @@ export async function captureStyleMap(page, options = {}) {
562
562
  // can't share a module-scope function) call ONE definition — no duplicated source.
563
563
  // It persists on `window` for every evaluate below (no navigation between them).
564
564
  await page.evaluate(injectPathOf);
565
- // Motion longhands first (FREEZE_CSS would null them), then everything else.
566
- // Motion never carries text it's a settled end state of declared motion.
567
- const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
568
- await page.addStyleTag({ content: FREEZE_CSS });
565
+ // Freeze motion BEFORE settling: animating elements would otherwise read as
566
+ // perpetual churn during the settle, and any content that mounts during the
567
+ // settle must be frozen by the time we read it below.
568
+ const freezeTag = await page.addStyleTag({ content: FREEZE_CSS });
569
569
  // Settle: wait for async content to finish painting so base and head capture
570
570
  // the same loaded state, and collect any region still changing on its own
571
571
  // (a live stream/ticker) to exclude — animations are frozen above, so only
572
572
  // real content/layout churn lands here.
573
573
  const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
574
+ // Motion longhands (transition/animation) are read separately so declared
575
+ // motion is verified even though every other value is a frozen end state.
576
+ // Read them on the SETTLED DOM, not before it: capturing pre-settle missed any
577
+ // element that mounts DURING the settle (e.g. a late status glyph), so its
578
+ // declared `animation-duration` was folded back on the run where it happened to
579
+ // mount early but left frozen (0s) on the run where it mounted late — a
580
+ // self-check "non-deterministic" flip. Lift the freeze just for this read (it
581
+ // nulls motion to 0s), then re-apply it before reading everything else.
582
+ await freezeTag.evaluate((el) => el.remove());
583
+ const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
584
+ await page.addStyleTag({ content: FREEZE_CSS });
574
585
  const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
575
586
  dropVolatile(base.elements, volatile);
576
587
  warnUntraversed(base.shadowHosts, base.sameOriginFrames);
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.0",
3
+ "version": "2.4.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",