styleproof 2.5.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.1] - 2026-06-27
11
+
12
+ ### Added
13
+
14
+ - **First-class live states plus generic variants.** `Surface` now accepts
15
+ `liveStates`, each captured as `<surface>-<state>` with optional `setup`, `go`,
16
+ `widths`, `height`, and `ignore` overrides. This lets a spec certify both
17
+ `loading` and `loaded` (or `empty` / `error`) on the base branch and feature
18
+ branch, then compare matching states directly instead of relying on one moving
19
+ live page state. Reports label those captures as live states. Generic `variants`
20
+ remain available for non-live states such as nav-open or modal-open. `defineCrawlCapture`
21
+ accepts both and applies them to every discovered link surface.
22
+ - **Semantic live-state candidates are auto-detected.** Captures now record
23
+ diagnostic metadata for `[aria-live]`, implicit live-region roles such as
24
+ `role=status` / `role=alert`, and `aria-busy=true`. Stable candidates are still
25
+ captured and compared by default; only regions that actually keep changing are
26
+ excluded as volatile.
27
+
28
+ ### Changed
29
+
30
+ - **Self-check diagnostics now call out volatile root layout drift.** When a
31
+ capture contains volatile regions and the repeated self-check differs on
32
+ `html`/`body` layout properties, the error explains that ignored/live content can
33
+ still move document flow, includes any auto-detected live-state candidates, and
34
+ points users to deterministic `liveStates`.
35
+ - **Release proof now matches CI before publishing.** The release workflow and
36
+ `prepublishOnly` gate now include format checking plus browser e2e coverage before
37
+ npm publication, so a version cannot publish ahead of the full package proof gate.
38
+
39
+ ### Fixed
40
+
41
+ - **The composite Action now executes the checked-out Action version.** Report jobs
42
+ install StyleProof's runtime dependencies under `GITHUB_ACTION_PATH` and invoke the
43
+ local `bin/styleproof-*` entrypoints, so `uses: BenSheridanEdwards/StyleProof@v3`
44
+ cannot drift to a consumer workspace install or npm's latest package.
45
+
46
+ ## [3.0.0] - 2026-06-27
47
+
48
+ **Milestone: the committed-map gate is how StyleProof works now.** Capture runs
49
+ **pre-push** (parallel, with auto-detected `@media` breakpoints), the lean computed-
50
+ style map is committed and **pushed in a single `git push`**, and CI is a
51
+ **browser-less diff** of two precomputed maps — measured ~5400× cheaper on the
52
+ compare step, and it skips build + serve entirely. `styleproof-init` scaffolds **and
53
+ activates** the whole gate in one command. Coverage stays **full and sound**: every
54
+ surface is captured (parallelised) and what changed is determined by _measuring_ the
55
+ map, never by guessing which pages a code change touched. **No breaking API changes**
56
+ — existing specs and the classic capture-both-in-CI flow keep working unchanged; the
57
+ major marks the new default paradigm.
58
+
59
+ ### Changed
60
+
61
+ - **`styleproof-init` now activates the pre-push hook for you** (`git config
62
+ core.hooksPath .githooks`), so a single `styleproof-init` is all it takes — no
63
+ follow-up command. It never clobbers a repo that already manages hooks: if
64
+ `core.hooksPath` is already set or a `.husky/` dir exists, it leaves them alone and
65
+ prints the one-liner instead.
66
+
10
67
  ## [2.5.0] - 2026-06-26
11
68
 
12
69
  ### Changed
@@ -773,7 +830,30 @@ number)`), so each viewport band can capture at its own height. Default remains
773
830
  - `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
774
831
  property, and state that drifted (exit 1).
775
832
 
776
- [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.2.0...HEAD
833
+ [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.1...HEAD
834
+ [3.0.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.0...v3.0.1
835
+ [3.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.5.0...v3.0.0
836
+ [2.5.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.4.0...v2.5.0
837
+ [2.4.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.3.1...v2.4.0
838
+ [2.3.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.3.0...v2.3.1
839
+ [2.3.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.2.0...v2.3.0
840
+ [2.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.1.0...v2.2.0
841
+ [2.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.0.0...v2.1.0
842
+ [2.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.10.0...v2.0.0
843
+ [1.10.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.4...v1.10.0
844
+ [1.9.4]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.3...v1.9.4
845
+ [1.9.3]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.2...v1.9.3
846
+ [1.9.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.0...v1.9.1
847
+ [1.9.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.8.1...v1.9.0
848
+ [1.8.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.8.0...v1.8.1
849
+ [1.8.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.7.2...v1.8.0
850
+ [1.7.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.7.1...v1.7.2
851
+ [1.7.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.6.0...v1.7.0
852
+ [1.6.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.5.0...v1.6.0
853
+ [1.5.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.4.0...v1.5.0
854
+ [1.4.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.3.1...v1.4.0
855
+ [1.3.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.3.0...v1.3.1
856
+ [1.3.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.2.0...v1.3.0
777
857
  [1.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.1.0...v1.2.0
778
858
  [1.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.0.0...v1.1.0
779
859
  [1.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.7.0...v1.0.0
package/README.md CHANGED
@@ -12,12 +12,12 @@ Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:fo
12
12
 
13
13
  ## What the gate does
14
14
 
15
- On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base branch, diffs them, and posts a Markdown comment:
15
+ On every PR, StyleProof diffs the PR head's computed-style map against the base branch's, and posts a Markdown comment:
16
16
 
17
17
  - A **lean summary comment** linking to a committed side-by-side report — the report is the complete source of truth (**one section per distinct change**, with a before/after cropped screenshot cropped from the same rectangle so the two sides line up exactly, **plain-English bullets that tell you what to look for** — `columns: 2 → 3`, `recoloured cyan → amber` — and the exact property changes). The comment never duplicates the report, so the two can't drift, and it renders identically on public and private repos.
18
18
  - A single **Approve all changes** checkbox in the comment, driving a `StyleProof` commit status: red until one tick signs off every change, green when there are none. The reviewer who ticks it is recorded inline (_approved by @them_), sourced from the commit status so it survives a report re-run.
19
19
  - **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
20
- - No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
20
+ - The diff is always **head-vs-base**, so the report is _exactly what this PR changes_ — whether the maps are captured fresh in CI or (by default) committed pre-push and diffed browser-free in CI. See [Quickstart](#quickstart).
21
21
 
22
22
  ## Don't let a new page ship uncaptured
23
23
 
@@ -79,6 +79,42 @@ defineCrawlCapture({
79
79
 
80
80
  Each discovered link becomes a surface keyed by its URL (`/?tab=overview` → `overview`; pass `key` for a different scheme). The app only has to render its nav as real `<a href>` links — a button-only nav (`<button onClick>`) exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as for explicit surfaces; one Playwright test runs the whole sweep (the link set isn't known until the page renders).
81
81
 
82
+ **Live UI states: capture each state, not an average.** StyleProof automatically
83
+ detects semantic live-state candidates (`aria-live`, `role=status`, `role=alert`,
84
+ `aria-busy=true`) and keeps stable ones in the normal diff. If a stream, poll, or
85
+ live region represents product states you want certified (`loading`, `loaded`,
86
+ `empty`, `error`), list only those pinned states with `liveStates`. StyleProof
87
+ writes separate captures such as `dashboard-loading@1440` and
88
+ `dashboard-loaded@1440`, so the base branch's loading state compares to the
89
+ feature branch's loading state, and loaded compares to loaded.
90
+
91
+ ```ts
92
+ defineStyleMapCapture({
93
+ dir: process.env.STYLEMAP_DIR,
94
+ surfaces: [
95
+ {
96
+ key: 'dashboard',
97
+ go: (page) => page.goto('/dashboard'),
98
+ widths: [1440, 768],
99
+ liveStates: [
100
+ {
101
+ key: 'loading',
102
+ setup: (page) =>
103
+ page.route('**/api/widgets', (route) => route.fulfill({ json: { status: 'loading', widgets: [] } })),
104
+ },
105
+ {
106
+ key: 'loaded',
107
+ setup: (page) =>
108
+ page.route('**/api/widgets', (route) =>
109
+ route.fulfill({ json: { status: 'loaded', widgets: [{ label: 'Revenue' }] } }),
110
+ ),
111
+ },
112
+ ],
113
+ },
114
+ ],
115
+ });
116
+ ```
117
+
82
118
  ## What a report looks like
83
119
 
84
120
  One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
@@ -102,6 +138,10 @@ _landing @ 1280_
102
138
 
103
139
  StyleProof reads the browser's **computed styles** — the values it actually resolves — never your source CSS. Tailwind, CSS Modules, styled-components, Sass, vanilla CSS, inline styles: all produce the same computed output, and that's what it diffs. Elements are keyed by **DOM structure, not class name**, so a refactor that rewrites every `class` still lines up element-for-element.
104
140
 
141
+ ## Breakpoints, detected automatically
142
+
143
+ Omit `widths` on a surface and StyleProof reads your app's real `@media` breakpoints from the **loaded CSSOM** at capture time and sweeps one viewport per band — no config. It's framework-agnostic for the same reason the diff is: it reads the rules the browser actually parsed, not your source, so Tailwind / CSS Modules / Sass / vanilla all resolve to the same `@media` boundaries. And it's authoritative **or it fails** — an unreadable cross-origin stylesheet throws rather than silently miss a band; it never guesses. Pin `widths` explicitly when you want a fixed sweep, or to cover a JS-only (`matchMedia`) breakpoint that has no CSS rule.
144
+
105
145
  ## Certify a refactor
106
146
 
107
147
  The same engine has a second mode that proves a change touched _nothing_ visual: with `fail-on-diff: true`, any difference at all fails the job. It's the job StyleProof was born for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical. Reach for it on any change whose whole promise is "the output is unchanged": a utility-class migration, a design-system swap, a dependency or build-tooling bump. Zero diff is the contract; one drifting longhand is a regression to investigate, not a change to approve.
@@ -117,70 +157,60 @@ Requires **Node ≥ 18** (ESM), **`@playwright/test` ≥ 1.40** (peer dep). Forc
117
157
 
118
158
  ## Quickstart
119
159
 
120
- **1. Scaffold the capture spec** (`npx styleproof-init` writes `e2e/styleproof.spec.ts`), then describe your surfaces:
160
+ After installing (above), one command sets up and **activates** the whole gate:
161
+
162
+ ```bash
163
+ npx styleproof-init
164
+ ```
165
+
166
+ It scaffolds:
167
+
168
+ - a **capture spec** (`e2e/styleproof.spec.ts`) describing your surfaces (a Next.js app gets its routes _and_ the coverage guard wired automatically — see below);
169
+ - a **`playwright.config.ts`** that builds and serves a **production build** (never a flaky dev server) and captures surfaces **in parallel** (`fullyParallel`);
170
+ - a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, commits it as a lean `.json.gz` under `stylemaps/`, and **pushes it with your branch — one `git push`, never two**;
171
+ - a **browser-less CI workflow** that diffs your committed map against the base branch.
172
+
173
+ Describe your surfaces — **omit `widths`** and StyleProof sweeps your real `@media` breakpoints automatically:
121
174
 
122
175
  ```ts
123
176
  import { defineStyleMapCapture } from 'styleproof';
124
177
 
125
178
  defineStyleMapCapture({
126
- dir: process.env.STYLEMAP_DIR, // inert until set, so it lives safely beside other tests
127
179
  surfaces: [
128
180
  {
129
181
  key: 'landing',
130
- go: (page) => page.goto('/'), // that's it — StyleProof settles the page (in-flight data, fonts, animations) before it reads
131
- widths: [1280, 768, 390], // one viewport per @media band
182
+ go: (page) => page.goto('/'), // StyleProof settles the page (in-flight data, fonts, animations) before it reads
183
+ // no `widths` → auto-detected from your @media bands; set `widths: [1280, 768, 390]` to pin them
132
184
  },
133
185
  ],
186
+ dir: process.env.STYLEMAP_DIR,
134
187
  });
135
188
  ```
136
189
 
137
- **2. Wire CI to capture base and head, then hand both to the Action:**
190
+ **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
+
192
+ > **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
+
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:
138
195
 
139
196
  ```yaml
140
197
  # .github/workflows/styleproof.yml
141
- name: StyleProof
142
- on: pull_request
143
-
144
- jobs:
145
- styleproof:
146
- runs-on: ubuntu-latest
147
- permissions:
148
- contents: write # push the report branch
149
- pull-requests: write # post/update the comment
150
- statuses: write # set the StyleProof status
151
- steps:
152
- - uses: actions/checkout@v4
153
- with:
154
- fetch-depth: 0 # need the base branch too
155
-
156
- # capture the base branch
157
- - run: git checkout ${{ github.event.pull_request.base.sha }}
158
- - run: npm ci && npm run build && (npm run serve &) # your framework's build + serve
159
- - run: npx wait-on http://localhost:3000
160
- - run: STYLEMAP_DIR=base npx playwright test e2e/styleproof.spec.ts
161
-
162
- # capture the PR head — replay the base's recorded data so the diff is
163
- # code, not live-data drift (see "Deterministic by default" below)
164
- - run: git checkout ${{ github.event.pull_request.head.sha }}
165
- - run: npm ci && npm run build && (npm run serve &)
166
- - run: npx wait-on http://localhost:3000
167
- - run: STYLEMAP_DIR=head STYLEPROOF_REPLAY_FROM=__stylemaps__/base npx playwright test e2e/styleproof.spec.ts
168
-
169
- # report + gate
170
- - uses: BenSheridanEdwards/StyleProof@v2
171
- with:
172
- baseline-dir: __stylemaps__/base # captures land under baseDir (default __stylemaps__)
173
- fresh-dir: __stylemaps__/head
174
- require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
198
+ - uses: actions/checkout@v4
199
+ with: { fetch-depth: 0 } # need the base ref's committed map
200
+ - uses: BenSheridanEdwards/StyleProof@v3
201
+ with:
202
+ base-ref: origin/${{ github.event.pull_request.base.ref }} # read the base map from git
203
+ fresh-dir: stylemaps/current # your committed maps
204
+ require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
175
205
  ```
176
206
 
177
- **3. Copy [`example/styleproof-approve.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/example/styleproof-approve.yml) to `.github/workflows/` on your default branch** GitHub only runs `issue_comment` workflows from there, so the checkboxes do nothing until it's merged.
207
+ Then copy [`example/styleproof-approve.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/example/styleproof-approve.yml) to `.github/workflows/` **on your default branch** (GitHub only runs `issue_comment` workflows from there, so the approval checkbox is inert until it's merged).
178
208
 
179
- **4. Require the `StyleProof` status** in branch protection. Now an unsigned visual change can't merge.
209
+ **Prefer to capture in CI instead of committing maps?** For a repo with many outside contributors on different machines, StyleProof can capture **both** base and head in CI and diff them there — no committed maps. See **[Forks and Dependabot](#forks-and-dependabot)** for that flow (it's also the fork-safe split). The committed-map flow above is the default because it's far faster and simpler for a single team.
180
210
 
181
211
  ## Forks and Dependabot
182
212
 
183
- The single-workflow setup above runs the whole gate in one `pull_request` job which needs a **write** token to push the report branch, post the comment, and set the `StyleProof` status. That's fine for same-repo PRs, but **fork and Dependabot PRs run with a read-only `GITHUB_TOKEN`** (GitHub's security default for untrusted PRs). So the job can't post the status — and a required `StyleProof` check then sits `pending` forever, blocking the PR even though a dependency or fork change usually touches no UI at all.
213
+ If you **capture in CI** rather than committing maps (the alternative noted at the end of the Quickstart — a better fit when many outside contributors push from different machines), the simplest setup runs the whole gate in one `pull_request` job that captures base + head and diffs them. That job needs a **write** token to push the report branch, post the comment, and set the `StyleProof` status. That's fine for same-repo PRs, but **fork and Dependabot PRs run with a read-only `GITHUB_TOKEN`** (GitHub's security default for untrusted PRs). So the job can't post the status — and a required `StyleProof` check then sits `pending` forever, blocking the PR even though a dependency or fork change usually touches no UI at all.
184
214
 
185
215
  Fix it by splitting capture from reporting, the way the approve workflow is already split out:
186
216
 
@@ -191,18 +221,20 @@ That last point is why this works where `pull_request_target` does not: StylePro
191
221
 
192
222
  **Where the PR identity comes from.** The report stage comments on the PR and sets the `StyleProof` status against a specific PR number and head commit, so those values have to be trustworthy. It takes them from the trusted `workflow_run` event — `head_sha`, then the event's `pull_requests`, with a commit→PR lookup against that **same trusted head SHA** for fork PRs (whose association the event doesn't carry directly) — and **never** from the downloaded artifact. The artifact is produced by the untrusted capture job, so treating anything in it as identity would let a malicious PR point the privileged comment and status at a victim PR or an arbitrary commit (a confused-deputy attack). The artifact therefore carries only the style-map captures, consumed purely as diff input.
193
223
 
194
- Copy both `capture` and `report` files to `.github/workflows/` (the `report` one must be on your default branch, like `styleproof-approve.yml`), then require the `StyleProof` status as in step 4. The single-job `styleproof.yml` above remains fine for repos that never see fork or bot PRs.
224
+ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one must be on your default branch, like `styleproof-approve.yml`), then require the `StyleProof` status in branch protection. A single combined `pull_request` job that captures base + head and diffs them is fine for repos that never see fork or bot PRs; this split is only needed for untrusted PRs.
195
225
 
196
226
  **Deterministic by default — no fixtures required.** A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you:
197
227
 
198
- - **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (see the CI step above); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
228
+ - **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (set on the head capture); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
199
229
  - **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.
200
230
  - **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.
201
231
  - **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.
202
232
 
203
233
  > 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.
204
234
 
205
- **Live pages just work.** Before each capture, StyleProof settles the page, and the settle is **network-aware**: it holds while the page's data requests are in flight (excluding long-lived `EventSource`/WebSocket streams, which never finish) _and_ until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured **loaded, not mid-load** — and, crucially, it **can't false-settle on the loading state before a slow backend's response arrives**. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it. Anything still moving on its own after that is detected as a live region and excluded from the diff, so a stream or ticker never reads as a change — no manual `ignore` needed. `defineStyleMapCapture` arms the request tracker before each `go()` automatically; for a direct `captureStyleMap` call, arm one before you navigate with `trackInflightRequests(page)` and pass `{ pendingRequests }`. Disable or tune with `{ stabilize: false }` / `{ stabilize: { quietFor, timeout, waitForRequests } }`.
235
+ **Live pages just work when the intended state is deterministic.** Before each capture, StyleProof settles the page, and the settle is **network-aware**: it holds while the page's data requests are in flight (excluding long-lived `EventSource`/WebSocket streams, which never finish) _and_ until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured **loaded, not mid-load** — and, crucially, it **can't false-settle on the loading state before a slow backend's response arrives**. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it.
236
+
237
+ Anything still moving on its own after that is detected as a volatile region and excluded from direct element comparison, so a stream or ticker never reads as a change just because its value changed. That is not the same as certifying every state of the live UI: an ignored or volatile subtree can still change `html`/`body` layout if its height changes. When those states matter, make them deterministic `liveStates` (`loading`, `loaded`, `empty`, `error`) and capture each on both branches. Self-check and reports automatically mention detected live-state candidates when volatile layout drift appears. `defineStyleMapCapture` arms the request tracker before each `go()` automatically; for a direct `captureStyleMap` call, arm one before you navigate with `trackInflightRequests(page)` and pass `{ pendingRequests }`. Disable or tune with `{ stabilize: false }` / `{ stabilize: { quietFor, timeout, waitForRequests } }`.
206
238
 
207
239
  **At a glance — almost everything is automatic.** The few knobs exist only for what StyleProof can't know about your app, and each says why:
208
240
 
@@ -212,7 +244,8 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
212
244
  | Animations, transitions, focus ring, caret | frozen / blurred before the map is read |
213
245
  | Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
214
246
  | Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
215
- | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from the diff |
247
+ | Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
248
+ | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
216
249
  | Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
217
250
 
218
251
  | You set this — only because it's app-specific | Why it exists |
@@ -220,6 +253,8 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
220
253
  | `STYLEPROOF_REPLAY_FROM` (record / replay) | Base and head capture at different times against a live backend; replaying the base's recorded data pins the head to the same inputs, so the diff is **your code, not data drift**. The one piece of real setup. |
221
254
  | `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
222
255
  | `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
256
+ | `liveStates: [{ key, setup, go }]` | A live feature has real states to certify. Capture each state on base and head (`surface-loading`, `surface-loaded`) instead of relying on a single moving page state. |
257
+ | `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open or modal-open states. |
223
258
  | `clockTime` | Your styling keys off a **specific** date, not just "now". |
224
259
  | `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
225
260
 
@@ -262,15 +297,16 @@ When a PR **adds** an element, StyleProof now reports its **full resting compute
262
297
 
263
298
  ## Reference
264
299
 
265
- **Action `BenSheridanEdwards/StyleProof@v2`** — key inputs:
300
+ **Action `BenSheridanEdwards/StyleProof@v3`** — key inputs:
266
301
 
267
- | Input | Default | Purpose |
268
- | ------------------ | ------------ | -------------------------------------------------------------------------- |
269
- | `baseline-dir` | _required_ | Base-branch captures. |
270
- | `fresh-dir` | _required_ | PR-head captures to compare. |
271
- | `require-approval` | `false` | Review-gate mode: set the `StyleProof` status instead of failing. |
272
- | `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
273
- | `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
302
+ | Input | Default | Purpose |
303
+ | ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
304
+ | `fresh-dir` | _required_ | PR-head (or committed) captures to compare. |
305
+ | `base-ref` | _none_ | Read the baseline from a git ref (e.g. the PR base branch) instead of `baseline-dir` the committed-map flow. Needs `fetch-depth: 0`. |
306
+ | `baseline-dir` | _required\*_ | Base-branch captures dir. _\*Not required when `base-ref` is set._ |
307
+ | `require-approval` | `false` | Review-gate mode: set the `StyleProof` status instead of failing. |
308
+ | `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
309
+ | `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
274
310
 
275
311
  Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
276
312
 
@@ -292,38 +328,42 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
292
328
 
293
329
  **Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
294
330
 
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. |
331
+ | Option | Default | Purpose |
332
+ | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
333
+ | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
334
+ | `liveStates` | _none_ | Optional pinned live product states. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<state>` and is labeled as a live state in reports. |
335
+ | `variants` | _none_ | Optional non-live deterministic states under a surface. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<variant>` so base/head compare matching states. |
336
+ | `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. |
337
+ | `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. |
338
+ | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
339
+ | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset this run **records** its HAR for the comparison to use. |
340
+ | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
341
+ | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
342
+ | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
343
+ | `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. |
344
+ | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
345
+ | `baseDir` | `__stylemaps__` | Output root directory. |
308
346
 
309
347
  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
348
 
311
349
  **Capture env vars** (wire CI without editing the spec):
312
350
 
313
- | Env | Purpose |
314
- | ------------------------ | ----------------------------------------------------------------------------- |
315
- | `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
316
- | `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from set this on the **head** capture. |
317
- | `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
318
- | `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
351
+ | Env | Purpose |
352
+ | ------------------------ | --------------------------------------------------------------------------------------------- |
353
+ | `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
354
+ | `STYLEPROOF_BASEDIR` | Output root dir (default `__stylemaps__`). The committed-map hook sets this to a tracked dir. |
355
+ | `STYLEPROOF_SCREENSHOTS` | `0` to skip full-page screenshots — lean, commit-friendly `.json.gz`-only maps. |
356
+ | `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from set this on the **head** capture. |
357
+ | `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
358
+ | `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
319
359
 
320
360
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
321
361
 
322
- - `styleproof-init` — scaffold the capture spec (and, if none exists, a starter `playwright.config.ts` whose `webServer` **builds and serves a production build**, so captures never run against a flaky dev server).
323
- - `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).
324
- - `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).
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.
325
365
 
326
- A programmatic API (`captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, …) is also exported. 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.
366
+ 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.
327
367
 
328
368
  ## License
329
369
 
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
23
24
  import { discoverNextRoutes } from '../dist/index.js';
24
25
 
25
26
  const HELP = `styleproof-init — scaffold a styleproof capture spec
@@ -238,7 +239,8 @@ export default defineConfig({
238
239
  // always carries a base map and CI is a fast, browser-less diff of two precomputed
239
240
  // maps. The hook redirects capture into a committed `stylemaps/` dir and drops
240
241
  // screenshots via the STYLEPROOF_BASEDIR / STYLEPROOF_SCREENSHOTS env knobs.
241
- const HOOK_PATH = '.githooks/pre-push';
242
+ const HOOK_DIR = '.githooks';
243
+ const HOOK_PATH = `${HOOK_DIR}/pre-push`;
242
244
  const PREPUSH_HOOK = `#!/bin/sh
243
245
  # StyleProof pre-push hook (generated by styleproof-init).
244
246
  #
@@ -358,9 +360,33 @@ if (ci.wrote) {
358
360
  console.log(`${CI_PATH} already exists — left untouched`);
359
361
  }
360
362
 
363
+ // Activate the hook so ONE `styleproof-init` is all it takes — but never clobber a
364
+ // repo that already manages its hooks (husky, or an existing core.hooksPath); there
365
+ // we leave it and print the one-liner instead.
366
+ const git = (a) => spawnSync('git', a, { encoding: 'utf8' });
367
+ const inGitRepo = git(['rev-parse', '--is-inside-work-tree']).status === 0;
368
+ const existingHooksPath = inGitRepo ? git(['config', '--local', '--get', 'core.hooksPath']).stdout.trim() : '';
369
+ const managesHooks = existingHooksPath !== '' || fs.existsSync('.husky');
370
+ let hookActivated = false;
371
+ if (inGitRepo && !managesHooks) {
372
+ if (git(['config', 'core.hooksPath', HOOK_DIR]).status === 0) {
373
+ hookActivated = true;
374
+ console.log(`activated the pre-push hook (core.hooksPath → ${HOOK_DIR})`);
375
+ wroteSomething = true;
376
+ }
377
+ }
378
+
361
379
  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');
380
+ if (hookActivated) {
381
+ console.log(' 1. Pre-push hook is active — nothing to do.');
382
+ } else if (managesHooks) {
383
+ console.log(` 1. Your repo already manages git hooks (${existingHooksPath || '.husky'}), so the hook`);
384
+ console.log(` wasn't auto-activated. Point your hook runner at ${HOOK_DIR}/pre-push, or run:`);
385
+ console.log(` git config core.hooksPath ${HOOK_DIR}`);
386
+ } else {
387
+ console.log(' 1. Activate the pre-push hook (once per clone):');
388
+ console.log(` git config core.hooksPath ${HOOK_DIR}`);
389
+ }
364
390
  console.log(' 2. Push your branch. The hook captures the computed-style map against a');
365
391
  console.log(' PRODUCTION build, commits it (lean .json.gz under stylemaps/), and pushes it');
366
392
  console.log(' with your branch — one `git push`, never two.');
package/dist/capture.d.ts CHANGED
@@ -55,7 +55,23 @@ export type ElementEntry = {
55
55
  props?: Record<string, string>;
56
56
  };
57
57
  };
58
+ export type CaptureMetadata = {
59
+ surfaceKey?: string;
60
+ variantKey?: string;
61
+ variantKind?: 'variant' | 'live-state';
62
+ };
63
+ export type LiveRegionCandidate = {
64
+ path: string;
65
+ tag: string;
66
+ cls: string;
67
+ reason: string;
68
+ role?: string;
69
+ ariaLive?: string;
70
+ ariaBusy?: string;
71
+ };
58
72
  export type StyleMap = {
73
+ /** Optional runner-supplied context; ignored by the certification diff. */
74
+ metadata?: CaptureMetadata;
59
75
  defaults: Record<string, Props>;
60
76
  elements: Record<string, ElementEntry>;
61
77
  states: Record<string, Record<string, Record<string, Props>>>;
@@ -74,6 +90,13 @@ export type StyleMap = {
74
90
  * stream/ticker never reads as a change. Empty/absent on a fully-settled page.
75
91
  */
76
92
  volatile?: string[];
93
+ /**
94
+ * Semantic live-state candidates detected automatically from the DOM, such as
95
+ * `[aria-live]`, `role=status`, `role=alert`, or `aria-busy=true`. These are
96
+ * diagnostics only: stable candidates are still captured and compared; only
97
+ * regions that actually keep changing are auto-excluded via `volatile`.
98
+ */
99
+ liveCandidates?: LiveRegionCandidate[];
77
100
  /**
78
101
  * Colour-valued `:root` custom properties (design/theme tokens), normalised to
79
102
  * the same `rgb(...)` form the longhands resolve to — `{ "--red-200": "rgb(254,
@@ -171,6 +194,8 @@ export type CaptureOptions = {
171
194
  * in flight when you called it (arm one yourself before `goto` if that matters).
172
195
  */
173
196
  pendingRequests?: () => number;
197
+ /** Advanced/internal: metadata to persist with the capture for report context. */
198
+ metadata?: CaptureMetadata;
174
199
  };
175
200
  /** True if `path` is one of `roots` or a structural descendant of one. Shared by
176
201
  * the capture (excluding live regions) and the diff (skipping them). */
package/dist/capture.js CHANGED
@@ -229,6 +229,41 @@ function capturePage({ ignore, motionOnly, captureText, captureComponent }) {
229
229
  frame.remove();
230
230
  return { defaults, elements, shadowHosts, sameOriginFrames };
231
231
  }
232
+ function detectLiveCandidates({ ignore }) {
233
+ const pathOf = window.__spPathOf;
234
+ const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
235
+ const reasonsFor = (role, ariaLive, ariaBusy) => {
236
+ const reasons = [];
237
+ if (ariaLive && ariaLive !== 'off')
238
+ reasons.push(`aria-live=${ariaLive}`);
239
+ if (['alert', 'log', 'marquee', 'status', 'timer'].includes(role))
240
+ reasons.push(`role=${role}`);
241
+ if (ariaBusy === 'true')
242
+ reasons.push('aria-busy=true');
243
+ return reasons;
244
+ };
245
+ return [document.documentElement, document.body, ...document.querySelectorAll('body *')]
246
+ .filter((el) => !skipSel || !el.matches(skipSel))
247
+ .flatMap((el) => {
248
+ const role = (el.getAttribute('role') ?? '').trim().toLowerCase();
249
+ const ariaLive = (el.getAttribute('aria-live') ?? '').trim().toLowerCase();
250
+ const ariaBusy = (el.getAttribute('aria-busy') ?? '').trim().toLowerCase();
251
+ const reasons = reasonsFor(role, ariaLive, ariaBusy);
252
+ return reasons.length
253
+ ? [
254
+ {
255
+ path: pathOf(el),
256
+ tag: el.tagName.toLowerCase(),
257
+ cls: el.getAttribute('class') || '',
258
+ reason: reasons.join(', '),
259
+ ...(role ? { role } : {}),
260
+ ...(ariaLive ? { ariaLive } : {}),
261
+ ...(ariaBusy ? { ariaBusy } : {}),
262
+ },
263
+ ]
264
+ : [];
265
+ });
266
+ }
232
267
  /** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
233
268
  // Serialized into the browser by page.evaluate; cannot call module helpers.
234
269
  function snapSubtree({ selector, index }) {
@@ -571,6 +606,10 @@ export async function captureStyleMap(page, options = {}) {
571
606
  // (a live stream/ticker) to exclude — animations are frozen above, so only
572
607
  // real content/layout churn lands here.
573
608
  const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
609
+ // Detect semantic live-state candidates automatically, but don't exclude them
610
+ // merely for being live regions. Stable status/alert/log UI is product UI and
611
+ // should still be captured; this metadata only improves reports and diagnostics.
612
+ const liveCandidates = await page.evaluate(detectLiveCandidates, { ignore: FRAMEWORK_IGNORE });
574
613
  // Motion longhands (transition/animation) are read separately so declared
575
614
  // motion is verified even though every other value is a frozen end state.
576
615
  // Read them on the SETTLED DOM, not before it: capturing pre-settle missed any
@@ -595,11 +634,13 @@ export async function captureStyleMap(page, options = {}) {
595
634
  }
596
635
  const tokens = await page.evaluate(capturePageTokens);
597
636
  return {
637
+ ...(options.metadata ? { metadata: options.metadata } : {}),
598
638
  defaults: base.defaults,
599
639
  elements: base.elements,
600
640
  states,
601
641
  ...(statesSkipped ? { statesSkipped: true } : {}),
602
642
  ...(volatile.length ? { volatile } : {}),
643
+ ...(liveCandidates.length ? { liveCandidates } : {}),
603
644
  ...(Object.keys(tokens).length ? { tokens } : {}),
604
645
  };
605
646
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
- export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
2
+ export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, Rect } from './capture.js';
3
3
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
4
- export type { Surface, DefineOptions, CrawlOptions } from './runner.js';
4
+ export type { Surface, SurfaceLiveState, SurfaceVariant, DefineOptions, CrawlOptions } from './runner.js';
5
5
  export { coverageGaps } from './coverage.js';
6
6
  export type { CoverageGaps } from './coverage.js';
7
7
  export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
package/dist/report.js CHANGED
@@ -354,14 +354,12 @@ export function prettyLabel(p, cls) {
354
354
  }
355
355
  const surfaceBase = (s) => s.replace(/@\d+$/, '');
356
356
  const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
357
- /** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
358
- function formatSurfaceList(surfaces) {
359
- const byBase = new Map();
360
- for (const s of surfaces) {
361
- const arr = byBase.get(surfaceBase(s)) ?? [];
362
- arr.push(surfaceWidth(s));
363
- byBase.set(surfaceBase(s), arr);
364
- }
357
+ function pushSurfaceWidth(byBase, base, surface) {
358
+ const arr = byBase.get(base) ?? [];
359
+ arr.push(surfaceWidth(surface));
360
+ byBase.set(base, arr);
361
+ }
362
+ function renderSurfaceGroups(byBase) {
365
363
  return [...byBase]
366
364
  .map(([base, ws]) => {
367
365
  const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
@@ -369,6 +367,53 @@ function formatSurfaceList(surfaces) {
369
367
  })
370
368
  .join(' · ');
371
369
  }
370
+ /** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
371
+ function formatSurfaceList(surfaces) {
372
+ const byBase = new Map();
373
+ for (const s of surfaces)
374
+ pushSurfaceWidth(byBase, surfaceBase(s), s);
375
+ return renderSurfaceGroups(byBase);
376
+ }
377
+ function surfaceContext(...maps) {
378
+ const metadata = maps.find((m) => m?.metadata)?.metadata;
379
+ if (!metadata?.variantKey)
380
+ return '';
381
+ return metadata.variantKind === 'live-state'
382
+ ? `live state \`${metadata.variantKey}\``
383
+ : `variant \`${metadata.variantKey}\``;
384
+ }
385
+ function formatSurfaceWithContext(surface, ...maps) {
386
+ const context = surfaceContext(...maps);
387
+ return context ? `${formatSurfaceList([surface])} · ${context}` : formatSurfaceList([surface]);
388
+ }
389
+ function formatSurfaceListWithContext(surfaces, beforeDir) {
390
+ const byBase = new Map();
391
+ for (const surface of surfaces) {
392
+ const map = loadStyleMap(findCapture(beforeDir, surface));
393
+ const context = surfaceContext(map);
394
+ const base = context ? `${surfaceBase(surface)} · ${context}` : surfaceBase(surface);
395
+ pushSurfaceWidth(byBase, base, surface);
396
+ }
397
+ return renderSurfaceGroups(byBase);
398
+ }
399
+ function liveCandidateLabel(candidate) {
400
+ const label = candidate.cls ? `${candidate.tag}.${candidate.cls.split(/\s+/)[0]}` : candidate.tag;
401
+ return `${label} (${candidate.reason})`;
402
+ }
403
+ function captureFiles(dir) {
404
+ return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.json(\.gz)?$/.test(f)) : [];
405
+ }
406
+ function collectLiveCandidateLabels(beforeDir, afterDir) {
407
+ const seen = new Set();
408
+ for (const dir of [beforeDir, afterDir]) {
409
+ for (const file of captureFiles(dir)) {
410
+ const map = loadStyleMap(path.join(dir, file));
411
+ for (const candidate of map.liveCandidates ?? [])
412
+ seen.add(liveCandidateLabel(candidate));
413
+ }
414
+ }
415
+ return [...seen].sort();
416
+ }
372
417
  // Grid-track longhands compute to width-dependent pixels (`282px ×2` at one width,
373
418
  // `282px 228px` at another), so the SAME responsive change would otherwise get a
374
419
  // different signature per width. Key them by track COUNT — what actually
@@ -753,6 +798,7 @@ export function generateStyleMapReport(opts) {
753
798
  const includeNoise = opts.includeLayoutNoise ?? false;
754
799
  const includeContent = opts.includeContent ?? false;
755
800
  const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
801
+ const liveCandidateLabels = volatileCount > 0 ? collectLiveCandidateLabels(beforeDir, afterDir) : [];
756
802
  fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
757
803
  // Focus each surface on styling intent: drop reflow-casualty props, suppress
758
804
  // forced-state echoes of base changes, and remove non-value noise (see
@@ -826,7 +872,10 @@ export function generateStyleMapReport(opts) {
826
872
  }
827
873
  }
828
874
  if (volatileCount > 0) {
829
- md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check._`);
875
+ const candidates = liveCandidateLabels.length
876
+ ? ` Auto-detected live-state candidate(s): ${liveCandidateLabels.slice(0, 5).join('; ')}.`
877
+ : '';
878
+ md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check.${candidates}_`);
830
879
  }
831
880
  if (contentSection.count > 0 && (changeGroups.length > 0 || missing.length > 0)) {
832
881
  md.push('', `📝 _${contentSection.count} advisory content change(s) below — they don't affect the check._`);
@@ -856,9 +905,6 @@ export function generateStyleMapReport(opts) {
856
905
  // Read top-to-bottom: one section per crop, in page order.
857
906
  const topY = (g) => (visible(g.after) ? g.after.y : visible(g.before) ? g.before.y : Infinity);
858
907
  groups.sort((a, b) => topY(a) - topY(b));
859
- const surfaceList = cg.surfaces.length > 1
860
- ? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceList(cg.surfaces)}_`
861
- : `_${formatSurfaceList(cg.surfaces)}_`;
862
908
  const surfaceJson = {
863
909
  surfaces: cg.surfaces,
864
910
  representative: sd.surface,
@@ -870,6 +916,9 @@ export function generateStyleMapReport(opts) {
870
916
  // sit directly under the screenshot that shows them — never a wall of
871
917
  // changes spanning several crops with no way to tell which is which.
872
918
  const regionFindings = surfaceFindings.filter((f) => g.paths.some((root) => f.path === root || f.path.startsWith(root + ' > ')));
919
+ const surfaceList = cg.surfaces.length > 1
920
+ ? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceListWithContext(cg.surfaces, beforeDir)}_`
921
+ : `_${formatSurfaceWithContext(sd.surface, mapA, mapB)}_`;
873
922
  md.push('', `### ${regionHeading(g.paths, regionFindings)}`, '', surfaceList);
874
923
  const region = visible(g.after) ? g.after : g.before;
875
924
  let images = {};
@@ -901,7 +950,7 @@ export function generateStyleMapReport(opts) {
901
950
  // Clean before|after shown by default (the real UI); the annotated twin —
902
951
  // boxes marking each change — sits one click away under a toggle. Plain
903
952
  // images (no link wrap) so a click opens the full-resolution file.
904
- md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<sub>◀ before · after ▶ — ${sd.surface}</sub>`, '', '<details>', '<summary>🔍 Highlight what changed</summary>', '', `![annotated before ◀ │ ▶ after](${img(images.annotated)})`, '', `<sub>magenta boxes mark each change — ${sd.surface}</sub>`, '', '</details>');
953
+ md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<sub>◀ before · after ▶ — ${formatSurfaceWithContext(sd.surface, mapA, mapB)}</sub>`, '', '<details>', '<summary>🔍 Highlight what changed</summary>', '', `![annotated before ◀ │ ▶ after](${img(images.annotated)})`, '', `<sub>magenta boxes mark each change — ${formatSurfaceWithContext(sd.surface, mapA, mapB)}</sub>`, '', '</details>');
905
954
  }
906
955
  else if (!region) {
907
956
  md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
@@ -923,8 +972,9 @@ export function generateStyleMapReport(opts) {
923
972
  for (const p of missing) {
924
973
  const side = p.sd.missing === 'before' ? 'after' : 'before';
925
974
  const srcDir = side === 'after' ? afterDir : beforeDir;
975
+ const map = loadStyleMap(findCapture(srcDir, p.sd.surface));
926
976
  const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
927
- md.push('', `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`, '', `_${formatSurfaceList([p.sd.surface])}_`);
977
+ md.push('', `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`, '', `_${formatSurfaceWithContext(p.sd.surface, map)}_`);
928
978
  const surfaceJson = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
929
979
  if (png) {
930
980
  cropSeq++;
@@ -932,7 +982,7 @@ export function generateStyleMapReport(opts) {
932
982
  const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h).png;
933
983
  const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
934
984
  writePng(path.join(outDir, `${stem}.png`), crop);
935
- md.push('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<sub>${side} · ${p.sd.surface}${png.height > h ? ' (top of page)' : ''}</sub>`);
985
+ md.push('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top of page)' : ''}</sub>`);
936
986
  surfaceJson.image = `${stem}.png`;
937
987
  }
938
988
  else {
package/dist/runner.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type CaptureMetadata, type LiveRegionCandidate } from './capture.js';
2
+ import { type Finding } from './diff.js';
1
3
  import { type LinkMatch } from './crawl.js';
2
4
  import type { Page } from '@playwright/test';
3
5
  /**
@@ -26,7 +28,37 @@ export type Surface = {
26
28
  widths?: number[];
27
29
  /** Viewport height: a number, or a function of the width (default 800). */
28
30
  height?: number | ((width: number) => number);
31
+ /**
32
+ * Optional deterministic states of this same surface. Each variant becomes its
33
+ * own capture (`<surface>-<variant>@<width>`), so base/head compare loading to
34
+ * loading and loaded to loaded instead of treating live UI as one fuzzy state.
35
+ */
36
+ variants?: SurfaceVariant[];
37
+ /**
38
+ * First-class live product states for this surface. Use this for loading,
39
+ * loaded, empty, error, streaming, etc. StyleProof records them as live-state
40
+ * variants so reports and diagnostics can explain why the capture was split.
41
+ */
42
+ liveStates?: SurfaceLiveState[];
43
+ };
44
+ export type SurfaceVariant = {
45
+ /** Capture key suffix, joined as `<surface.key>-<variant.key>`. */
46
+ key: string;
47
+ /**
48
+ * Seed the state before the parent surface navigates: route mocks, fixture data,
49
+ * localStorage/sessionStorage, feature flags, etc.
50
+ */
51
+ setup?: (page: Page) => Promise<void>;
52
+ /** Drive or assert the variant after the parent surface reaches its base state. */
53
+ go?: (page: Page) => Promise<void>;
54
+ /** Extra ignored selectors for this variant, appended to the parent surface's ignore list. */
55
+ ignore?: string[];
56
+ /** Override the parent viewport widths for this variant. */
57
+ widths?: number[];
58
+ /** Override the parent viewport height for this variant. */
59
+ height?: number | ((width: number) => number);
29
60
  };
61
+ export type SurfaceLiveState = SurfaceVariant;
30
62
  export type DefineOptions = {
31
63
  surfaces: Surface[];
32
64
  /**
@@ -112,6 +144,11 @@ export type DefineOptions = {
112
144
  */
113
145
  captureComponent?: boolean;
114
146
  };
147
+ export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
148
+ type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
149
+ metadata?: CaptureMetadata;
150
+ };
151
+ export declare function expandSurfaceVariants(surface: Surface): ExpandedSurface[];
115
152
  /**
116
153
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
117
154
  * server. A long-lived stream can't round-trip through a HAR entry: recording
@@ -181,6 +218,10 @@ export type CrawlOptions = CaptureConfig & {
181
218
  height?: number | ((width: number) => number);
182
219
  /** Selectors skipped on every surface (live regions, third-party embeds). */
183
220
  ignore?: string[];
221
+ /** Deterministic variants captured for every discovered link surface. */
222
+ variants?: SurfaceVariant[];
223
+ /** First-class live product states captured for every discovered link surface. */
224
+ liveStates?: SurfaceLiveState[];
184
225
  /** Max ms to wait for the crawl root's links to render before reading them
185
226
  * (an SPA hydrates its nav client-side). Default 15000. */
186
227
  linkTimeout?: number;
package/dist/runner.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { test, expect } from '@playwright/test';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
4
+ import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
6
  import { coverageGaps } from './coverage.js';
7
7
  import { detectViewportWidths } from './breakpoints.js';
@@ -13,6 +13,77 @@ function driftDesc(f) {
13
13
  const p = f.props[0];
14
14
  return p ? `${f.path} ${p.prop}: ${p.before} → ${p.after}` : f.path;
15
15
  }
16
+ const ROOT_LAYOUT_PATHS = new Set(['html', 'body']);
17
+ const ROOT_LAYOUT_PROPS = new Set([
18
+ 'block-size',
19
+ 'height',
20
+ 'inline-size',
21
+ 'width',
22
+ 'min-block-size',
23
+ 'min-height',
24
+ 'max-block-size',
25
+ 'max-height',
26
+ 'perspective-origin',
27
+ 'transform-origin',
28
+ ]);
29
+ function hasRootLayoutDrift(drift) {
30
+ return drift.some((f) => f.kind === 'style' && ROOT_LAYOUT_PATHS.has(f.path) && f.props.some((p) => ROOT_LAYOUT_PROPS.has(p.prop)));
31
+ }
32
+ function liveCandidateDesc(candidate) {
33
+ const label = candidate.cls ? `${candidate.tag}.${candidate.cls.split(/\s+/)[0]}` : candidate.tag;
34
+ return `${label} (${candidate.reason}) at ${candidate.path}`;
35
+ }
36
+ export function selfCheckErrorMessage(surfaceKey, drift, volatile = [], liveCandidates = []) {
37
+ const first = drift[0];
38
+ let message = `styleproof self-check failed: ${surfaceKey} is non-deterministic — ` +
39
+ `${drift.length} computed-style difference(s) between two captures of the same commit. ` +
40
+ `Likely a replay gap (a request not in the baseline HAR) or unseeded randomness.`;
41
+ if (volatile.length && hasRootLayoutDrift(drift)) {
42
+ message +=
43
+ ` Volatile regions were detected in this capture; root/body layout drift usually means live content ` +
44
+ `is still changing document flow. Model those live states with \`liveStates\` instead ` +
45
+ `of only ignoring the region.`;
46
+ if (liveCandidates.length) {
47
+ message += ` Auto-detected live-state candidate(s): ${liveCandidates
48
+ .slice(0, 3)
49
+ .map(liveCandidateDesc)
50
+ .join('; ')}.`;
51
+ }
52
+ }
53
+ return first ? `${message} First: ${driftDesc(first)}` : message;
54
+ }
55
+ function mergeIgnore(...groups) {
56
+ const merged = [...new Set(groups.flatMap((g) => g ?? []))];
57
+ return merged.length ? merged : undefined;
58
+ }
59
+ function expandOne(surface, variant, variantKind) {
60
+ return {
61
+ key: `${surface.key}-${variant.key}`,
62
+ go: async (page) => {
63
+ await variant.setup?.(page);
64
+ await surface.go(page);
65
+ await variant.go?.(page);
66
+ },
67
+ ignore: mergeIgnore(surface.ignore, variant.ignore),
68
+ widths: variant.widths ?? surface.widths,
69
+ height: variant.height ?? surface.height,
70
+ metadata: { surfaceKey: surface.key, variantKey: variant.key, variantKind },
71
+ };
72
+ }
73
+ export function expandSurfaceVariants(surface) {
74
+ const variants = surface.variants ?? [];
75
+ const liveStates = surface.liveStates ?? [];
76
+ if (!variants.length && !liveStates.length) {
77
+ const { variants: _variants, liveStates: _liveStates, ...base } = surface;
78
+ void _variants;
79
+ void _liveStates;
80
+ return [{ ...base, metadata: { surfaceKey: surface.key } }];
81
+ }
82
+ return [
83
+ ...variants.map((variant) => expandOne(surface, variant, 'variant')),
84
+ ...liveStates.map((state) => expandOne(surface, state, 'live-state')),
85
+ ];
86
+ }
16
87
  /**
17
88
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
18
89
  * server. A long-lived stream can't round-trip through a HAR entry: recording
@@ -77,10 +148,8 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
77
148
  const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText, pendingRequests: pending });
78
149
  const drift = diffStyleMaps(first, again);
79
150
  if (drift.length) {
80
- throw new Error(`styleproof self-check failed: ${surface.key} is non-deterministic — ` +
81
- `${drift.length} computed-style difference(s) between two captures of the same commit. ` +
82
- `Likely a replay gap (a request not in the baseline HAR) or unseeded randomness. ` +
83
- `First: ${driftDesc(drift[0])}`);
151
+ const liveCandidates = [...(first.liveCandidates ?? []), ...(again.liveCandidates ?? [])];
152
+ throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
84
153
  }
85
154
  }
86
155
  /** Drive one surface at one width to a settled state and save its style map (+ screenshot).
@@ -101,6 +170,7 @@ async function captureSurface(page, surface, width, s) {
101
170
  captureText: s.captureText,
102
171
  captureComponent: s.captureComponent,
103
172
  pendingRequests: requests.pending,
173
+ metadata: surface.metadata,
104
174
  });
105
175
  if (s.selfCheck)
106
176
  await assertDeterministic(page, surface, map, s.captureText, requests.pending);
@@ -180,6 +250,7 @@ function resolveSettings(c) {
180
250
  export function defineStyleMapCapture(options) {
181
251
  const { surfaces, expected, exclude = {}, dir } = options;
182
252
  const settings = resolveSettings(options);
253
+ const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
183
254
  // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
184
255
  // a route added without a surface fails the app's own tests — long before, and
185
256
  // independent of, a capture run. This is the one gap captures can't catch: a
@@ -199,7 +270,7 @@ export function defineStyleMapCapture(options) {
199
270
  }
200
271
  test.describe('styleproof capture', () => {
201
272
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
202
- for (const surface of surfaces) {
273
+ for (const surface of captureSurfaces) {
203
274
  if (surface.widths && surface.widths.length > 0) {
204
275
  // Explicit widths: one parallelizable test per surface × width.
205
276
  for (const width of surface.widths) {
@@ -243,7 +314,7 @@ export function defineStyleMapCapture(options) {
243
314
  * ```
244
315
  */
245
316
  export function defineCrawlCapture(options) {
246
- const { from, match, key, widths, height, ignore, linkTimeout = 15_000, dir } = options;
317
+ const { from, match, key, widths, height, ignore, variants, liveStates, linkTimeout = 15_000, dir } = options;
247
318
  const settings = resolveSettings(options);
248
319
  test.describe('styleproof crawl-capture', () => {
249
320
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
@@ -258,28 +329,30 @@ export function defineCrawlCapture(options) {
258
329
  throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
259
330
  `<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
260
331
  }
332
+ const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
333
+ key: link.key,
334
+ go: async (p) => {
335
+ await p.goto(link.url, { waitUntil: 'load' });
336
+ },
337
+ widths,
338
+ ignore,
339
+ height,
340
+ variants,
341
+ liveStates,
342
+ }));
261
343
  // Budget the whole sweep up front: one test captures every surface, and
262
344
  // captureSurface no longer sets its own timeout, so size it to the work found.
263
- test.setTimeout(Math.max(180_000, links.length * widths.length * 60_000));
345
+ test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 0), 0) * 60_000));
264
346
  // 2. Capture each discovered surface. Aggregate failures so one bad surface
265
347
  // reports without skipping the rest — they're an independent set, not a chain.
266
348
  const failures = [];
267
- for (const link of links) {
268
- for (const width of widths) {
269
- const surface = {
270
- key: link.key,
271
- go: async (p) => {
272
- await p.goto(link.url, { waitUntil: 'load' });
273
- },
274
- widths: [width],
275
- ignore,
276
- height,
277
- };
349
+ for (const surface of captureSurfaces) {
350
+ for (const width of surface.widths ?? []) {
278
351
  try {
279
352
  await captureSurface(page, surface, width, settings);
280
353
  }
281
354
  catch (e) {
282
- failures.push(`${link.key} @ ${width}: ${e.message}`);
355
+ failures.push(`${surface.key} @ ${width}: ${e.message}`);
283
356
  }
284
357
  }
285
358
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "2.5.0",
3
+ "version": "3.0.1",
4
4
  "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -65,7 +65,7 @@
65
65
  "bench": "npm run build && node bench/gate-speed.mjs",
66
66
  "init": "node ./bin/styleproof-init.mjs",
67
67
  "prepare": "npm run build && husky",
68
- "prepublishOnly": "npm run clean && npm run build && npm run typecheck"
68
+ "prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:e2e"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "@playwright/test": ">=1.40"