styleproof 3.1.4 → 3.1.5

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,55 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.5] - 2026-06-29
11
+
12
+ ### Added
13
+
14
+ - Added `harvestStyleVariants` and the `styleproof-variants` CLI to discover
15
+ one-step UI state variants from a running app. The crawler tries semantic
16
+ controls, keeps only actions that change computed styles, dedupes equivalent
17
+ outcomes, and reports live-state candidates that need fixtures or opt-outs.
18
+ - **Captured maps now include semantic overlay proof metadata.** Visible dialog,
19
+ menu, listbox, modal, popover, tooltip, and toast roots that are present in the
20
+ captured DOM are recorded under `overlays`, filtered to paths that are actually
21
+ in the computed-style map. Downstream suites can now assert that a popup map
22
+ captured `role="dialog"`, `aria-modal`, `role="menu"`, `role="listbox"`, or
23
+ hot-toast text instead of only asserting that a popup file exists.
24
+ - **Component inventory coverage is now available through
25
+ `discoverComponentFiles`.** It scans explicit component roots across common
26
+ framework file types and returns stable `component-*` keys, so teams can wire a
27
+ Storybook/Ladle/custom catalog into `expected` and fail CI when a component file
28
+ has no rendered StyleProof surface or reviewed exclusion. `componentCatalogSurfaces`
29
+ turns the same inventory into catalog URL capture surfaces so the surface list
30
+ and expected list cannot drift apart.
31
+
32
+ ### Changed
33
+
34
+ - **`styleproof-init` now points modal, popover, menu, tab, and form-error
35
+ captures at `variants`.** The generated generic spec includes commented
36
+ `dialog-open` and `popover-open` examples under the base surface, so app-owned
37
+ UI states compare state-to-state instead of becoming orphan root surfaces.
38
+ - **The coverage guard now checks expanded variant keys.** Projects can put
39
+ required states such as `dashboard-user-menu-open`, `dashboard-toast-visible`,
40
+ or component catalog keys in `expected`; StyleProof fails unless those exact
41
+ surfaces are captured or explicitly excluded.
42
+ - **Non-live variants now keep the base capture.** Dialog, menu, popover, toast,
43
+ and overlay captures augment the owning surface instead of replacing its normal
44
+ page capture. `liveStates` continue to model explicit pinned live states.
45
+ - **Automatic popup capture now treats modal attributes, dropdown roles, and
46
+ toast roots as default overlay candidates.** The default selector includes
47
+ `[aria-modal="true"]`, `role="menu"`, `role="listbox"`, hot-toast/Sonner-style
48
+ toast markers, and status/alert toast roots. Popup matching also compares a
49
+ semantic/text signature, so a reused mount or toast host can still be captured
50
+ when its visible state changes.
51
+
52
+ ### Fixed
53
+
54
+ - Computed-style diffs now ignore sub-pixel `transform-origin` and
55
+ `perspective-origin` jitter while still reporting meaningful origin changes.
56
+
57
+ ## [3.1.4] - 2026-06-29
58
+
10
59
  ### Fixed
11
60
 
12
61
  - **Popup capture no longer collapses distinct triggers that reuse the same
@@ -1009,7 +1058,9 @@ number)`), so each viewport band can capture at its own height. Default remains
1009
1058
  - `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
1010
1059
  property, and state that drifted (exit 1).
1011
1060
 
1012
- [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.3...HEAD
1061
+ [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.5...HEAD
1062
+ [3.1.5]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.4...v3.1.5
1063
+ [3.1.4]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.3...v3.1.4
1013
1064
  [3.1.3]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.2...v3.1.3
1014
1065
  [3.1.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.1...v3.1.2
1015
1066
  [3.1.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.0...v3.1.1
package/README.md CHANGED
@@ -1,24 +1,118 @@
1
1
  # StyleProof
2
2
 
3
- **Know exactly what every PR changes visually, and sign it off.** StyleProof captures the browser's _computed_ styles (not pixels), diffs your PR's HEAD against its base branch, and posts a per-change report on the PR, so a styling change never ships without someone confirming it was intended.
3
+ **StyleProof is a PR gate for visual CSS changes.** You tell it which app states
4
+ matter. It opens those states in a real browser, records the browser's computed
5
+ styles, compares the PR head against the base branch, and posts a reviewable PR
6
+ report. Intentional visual changes get approved; unexpected ones block or fail.
4
7
 
5
8
  [![npm version](https://img.shields.io/npm/v/styleproof.svg)](https://www.npmjs.com/package/styleproof)
6
9
  [![CI](https://github.com/BenSheridanEdwards/StyleProof/actions/workflows/ci.yml/badge.svg)](https://github.com/BenSheridanEdwards/StyleProof/actions)
7
10
  [![license](https://img.shields.io/npm/l/styleproof.svg)](https://github.com/BenSheridanEdwards/StyleProof/blob/main/LICENSE)
8
11
 
9
- ## Why
10
-
11
- Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:focus` / `:active`, can't see hidden or off-screen elements, can't reach between-breakpoint rules, and blur away sub-pixel drift. StyleProof reads the **computed style** of every element instead — every resolved longhand, every pseudo-element, the deltas `:hover` / `:focus` / `:active` apply (forced via CDP, no mouse), swept across each `@media` breakpoint.
12
-
13
- ## What the gate does
12
+ ## Contents
13
+
14
+ - [Why](#why)
15
+ - [The short version](#the-short-version)
16
+ - [What it catches](#what-it-catches)
17
+ - [What you own](#what-you-own)
18
+ - [What the PR gets](#what-the-pr-gets)
19
+ - [Don't let a new page ship uncaptured](#dont-let-a-new-page-ship-uncaptured)
20
+ - [What a report looks like](#what-a-report-looks-like)
21
+ - [Works with any styling system](#works-with-any-styling-system)
22
+ - [Breakpoints, detected automatically](#breakpoints-detected-automatically)
23
+ - [Certify a refactor](#certify-a-refactor)
24
+ - [Install](#install)
25
+ - [Quickstart](#quickstart)
26
+ - [Forks and Dependabot](#forks-and-dependabot)
27
+ - [Optional: content layer](#optional-content-layer-advisory)
28
+ - [Optional: React component layer](#optional-react-component-layer-advisory)
29
+ - [Newly-added elements show their full style](#newly-added-elements-show-their-full-style)
30
+ - [Reference](#reference)
31
+ - [Blocking without branch protection](#blocking-without-branch-protection)
32
+ - [License](#license)
14
33
 
15
- On every PR, StyleProof diffs the PR head's computed-style map against the base branch's, and posts a Markdown comment:
34
+ ## Why
16
35
 
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
- - A single **Approve all changes** checkbox in the comment, driving a `StyleProof` commit status: red until one tick signs off every change, green when there are none. The reviewer who ticks it is recorded inline (_approved by @them_), sourced from the commit status so it survives a report re-run.
19
- - **Clean runs leave a receipt too.** When no visual changes are detected, StyleProof still creates or updates the PR comment with `✓ No visual changes detected.`, so a green status has a visible record instead of only a silent check.
20
- - **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
21
- - The diff is always **head-vs-base**, so the report is _exactly what this PR changes_ — whether the maps are restored from the `styleproof-maps` cache or recaptured in CI on a cache miss. See [Quickstart](#quickstart).
36
+ Use StyleProof when a PR can change CSS, design tokens, component classes,
37
+ layout, or hidden/open UI states and you want CI to say whether the browser's
38
+ rendered styles actually changed. Unit and e2e tests prove behavior; StyleProof
39
+ proves the visual contract for the states you declared.
40
+
41
+ It is useful for:
42
+
43
+ - certifying a "no visual change" refactor such as CSS Modules to Tailwind;
44
+ - reviewing intentional visual changes with exact before/after style evidence;
45
+ - catching accidental changes that only appear at one breakpoint;
46
+ - catching open-state regressions in dialogs, dropdowns, listboxes, popovers, and
47
+ toasts;
48
+ - failing CI when a required route, component, or UI state exists but has no
49
+ capture.
50
+
51
+ The important boundary: StyleProof only certifies states it can reach. If a state
52
+ matters, list it as a surface, variant, popup, live state, or component-catalog
53
+ surface. `expected` turns that inventory into a guard, so an uncaptured new page,
54
+ component, modal, dropdown, or toast fails as missing coverage instead of
55
+ silently passing.
56
+
57
+ ## The short version
58
+
59
+ 1. A **surface** is one UI state to certify: a route, tab, modal-open state,
60
+ dropdown-open state, toast-visible state, loading state, etc.
61
+ 2. You list surfaces in a Playwright-style spec.
62
+ 3. StyleProof opens each surface at real breakpoint widths and records computed
63
+ styles for every captured element.
64
+ 4. On a PR, it compares base vs head and reports exactly which rendered styles
65
+ changed.
66
+ 5. The PR gets a `StyleProof` status: green when nothing changed, red until
67
+ someone approves intentional changes, or failing when certification mode is
68
+ configured.
69
+
70
+ StyleProof is not a screenshot diff. Screenshots appear in the report so humans
71
+ can see the change, but the gate compares browser-computed CSS: resolved
72
+ longhands, pseudo-elements, layout boxes, motion longhands, and forced
73
+ `:hover`/`:focus`/`:active` deltas.
74
+
75
+ ## What it catches
76
+
77
+ - A button recoloured by a token, utility class, CSS module, inline style, or
78
+ design-system change.
79
+ - A layout shift at one breakpoint but not another.
80
+ - A dropped `:hover`, `:focus`, or `:active` style.
81
+ - A modal, menu, listbox, popover, sheet, or toast whose open state changed.
82
+ - A supposedly no-op refactor, such as CSS-to-Tailwind, that changed rendered
83
+ output.
84
+
85
+ ## What you own
86
+
87
+ StyleProof can only certify states it reaches. You own the app-specific list of
88
+ states that matter:
89
+
90
+ - routes and views belong in `surfaces`;
91
+ - open states belong in `variants` or `popups`;
92
+ - loading/loaded/empty/error states belong in `liveStates`;
93
+ - component catalogs can be wired through `discoverComponentFiles`;
94
+ - required-but-not-yet-captured states belong in `expected`, where the coverage
95
+ guard fails until they are captured or explicitly excluded with a reason.
96
+
97
+ That boundary is deliberate. StyleProof should not guess destructive flows,
98
+ auth-only fixtures, or which product state your component needs. It should make
99
+ missing coverage loud.
100
+
101
+ ## What the PR gets
102
+
103
+ On every PR, StyleProof posts a small summary comment that links to the committed
104
+ full report. The report groups each distinct visual change with:
105
+
106
+ - before/after crops from the same page rectangle;
107
+ - highlighted crops that box the changed element;
108
+ - a plain-English summary such as `columns: 2 -> 3` or
109
+ `background brand-cyan -> brand-amber`;
110
+ - the exact computed CSS properties that changed.
111
+
112
+ In review-gate mode, one **Approve all changes** checkbox turns the `StyleProof`
113
+ status green for that commit. Clean runs still leave a receipt: `No visual
114
+ changes detected.` New surfaces are shown as new baselines; coverage gaps are
115
+ handled by `expected`.
22
116
 
23
117
  ## Don't let a new page ship uncaptured
24
118
 
@@ -80,6 +174,136 @@ defineCrawlCapture({
80
174
 
81
175
  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).
82
176
 
177
+ **Component inventory: fail when the catalog misses a component.** StyleProof
178
+ cannot render arbitrary component files by itself across frameworks; props,
179
+ providers, loaders, portals, and app shell context are app-owned. What it can do
180
+ reliably is inventory component files and make your catalog/story route prove it
181
+ has a capture for each one:
182
+
183
+ ```ts
184
+ import { componentCatalogSurfaces, defineStyleMapCapture, discoverComponentFiles } from 'styleproof';
185
+
186
+ const COMPONENTS = discoverComponentFiles({
187
+ roots: ['src/components'],
188
+ ignore: [/\/icons\//],
189
+ });
190
+
191
+ defineStyleMapCapture({
192
+ surfaces: componentCatalogSurfaces(COMPONENTS, {
193
+ url: (component) => `/styleproof/components/${component.key}`,
194
+ widths: [390, 1024],
195
+ }),
196
+ expected: COMPONENTS.map((component) => component.key),
197
+ exclude: {
198
+ 'component-payment-card': 'needs a billing provider fixture',
199
+ },
200
+ dir: process.env.STYLEMAP_DIR,
201
+ });
202
+ ```
203
+
204
+ Use Storybook, Ladle, a framework route, or a tiny app-specific catalog for
205
+ `/styleproof/components/:key`. The inventory feeds both `surfaces` and
206
+ `expected`, so a new component file appears immediately and CI fails until it has
207
+ a rendered surface or an explicit exclusion.
208
+
209
+ **Dialogs, popovers and menus: capture the open state as a variant.** StyleProof
210
+ cannot guess which app-specific button opens a modal, but once you tell it the
211
+ interaction, it compares matching states on base and head (`home-dialog-open` to
212
+ `home-dialog-open`). Keep these under the route/view that owns them:
213
+
214
+ ```ts
215
+ const SURFACES: Surface[] = [
216
+ {
217
+ key: 'home',
218
+ go: (page) => page.goto('/'),
219
+ variants: [
220
+ {
221
+ key: 'dialog-open',
222
+ go: async (page) => {
223
+ await page.getByRole('button', { name: /open settings/i }).click();
224
+ await page.getByRole('dialog').waitFor();
225
+ },
226
+ },
227
+ {
228
+ key: 'popover-open',
229
+ go: async (page) => {
230
+ await page.getByRole('button', { name: /more/i }).click();
231
+ await page.locator('[popover], [role="menu"]').first().waitFor();
232
+ },
233
+ },
234
+ ],
235
+ },
236
+ ];
237
+ ```
238
+
239
+ Non-live `variants` add captures; the owning surface still captures too. Use
240
+ `liveStates` instead when the default live state is too fuzzy and only pinned
241
+ states such as `loading`, `loaded`, `empty`, or `error` should be compared.
242
+
243
+ When `popups: true` is enabled, StyleProof also tries visible safe triggers and
244
+ captures opened dialogs, menus, listboxes, modal roots, popovers, tooltips, and
245
+ toast/status roots. Each saved map includes `overlays` proof metadata for
246
+ semantic roots that were actually present in the computed-style map, so tests can
247
+ assert a capture reached `role="dialog"`, `aria-modal`, `role="menu"`,
248
+ `role="listbox"`, or hot-toast text.
249
+
250
+ **Harvest one-step variants.** Routes are not the whole UI: drawers, tabs,
251
+ dialogs, empty form errors, selects, and other one-step states need their own
252
+ captures. `styleproof-variants` opens a running app, tries semantic controls
253
+ (`[aria-expanded]`, tabs, summaries, selects, required forms, etc.), captures a
254
+ baseline and post-action StyleMap, and keeps only actions that change computed
255
+ styles. It also reports live-state candidates that need fixtures or opt-outs.
256
+
257
+ ```bash
258
+ styleproof-variants --base-url http://localhost:3000 --route / --route settings=/settings
259
+ ```
260
+
261
+ Use it as a manifest generator, not a replacement for review. The generated JSON
262
+ is deliberately not read by `styleproof-map`; approve the states you want, then
263
+ copy them into your capture spec as ordinary `variants` / `liveStates`.
264
+
265
+ ```json
266
+ {
267
+ "routes": [
268
+ {
269
+ "key": "settings",
270
+ "url": "/settings",
271
+ "variants": [
272
+ {
273
+ "key": "plan-selected",
274
+ "action": "select-option",
275
+ "selector": "select[aria-label=\"Plan\"]",
276
+ "value": "pro"
277
+ }
278
+ ],
279
+ "liveStates": [{ "key": "status", "fixtureRequired": true }],
280
+ "skipped": []
281
+ }
282
+ ]
283
+ }
284
+ ```
285
+
286
+ ```ts
287
+ defineStyleMapCapture({
288
+ surfaces: [
289
+ {
290
+ key: 'settings',
291
+ go: (page) => page.goto('/settings'),
292
+ variants: [
293
+ {
294
+ key: 'plan-selected',
295
+ go: (page) => page.locator('select[aria-label="Plan"]').selectOption('pro'),
296
+ },
297
+ ],
298
+ },
299
+ ],
300
+ });
301
+ ```
302
+
303
+ Destructive labels are skipped, duplicate computed-style outcomes are deduped,
304
+ and `--strict` exits non-zero when live-state fixtures or skipped candidates
305
+ remain unresolved.
306
+
83
307
  **Live UI states: capture each state, not an average.** StyleProof automatically
84
308
  detects semantic live-state candidates (`aria-live`, `role=status`, `role=alert`,
85
309
  `aria-busy=true`) and keeps stable ones in the normal diff. If a stream, poll, or
@@ -312,16 +536,16 @@ Anything still moving on its own after that is detected as a volatile region and
312
536
  | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
313
537
  | Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
314
538
 
315
- | You set this — only because it's app-specific | Why it exists |
316
- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
317
- | `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. |
318
- | `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
319
- | `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
320
- | `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. |
321
- | `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open or modal-open states. |
322
- | `popups: true` | Visible click-triggered overlays should be discovered automatically. Captures each matching trigger's persistent dialogs, popovers, menus, listboxes, and open data-state overlays as `surface-popup-XX`; keep hover-only or destructive states as explicit `variants`. |
323
- | `clockTime` | Your styling keys off a **specific** date, not just "now". |
324
- | `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
539
+ | You set this — only because it's app-specific | Why it exists |
540
+ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
541
+ | `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. |
542
+ | `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
543
+ | `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
544
+ | `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. |
545
+ | `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open, modal-open, toast-visible, or overlay-expanded states. |
546
+ | `popups: true` | Visible click-triggered overlays should be discovered automatically. Captures each matching trigger's persistent dialogs, modal roots, popovers, menus, listboxes, toast/status roots, and open data-state overlays as `surface-popup-XX`; keep hover-only or destructive states as explicit `variants`. |
547
+ | `clockTime` | Your styling keys off a **specific** date, not just "now". |
548
+ | `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
325
549
 
326
550
  ## Optional: content layer (advisory)
327
551
 
@@ -392,22 +616,22 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
392
616
 
393
617
  **Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
394
618
 
395
- | Option | Default | Purpose |
396
- | ------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
397
- | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants?, popups? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
398
- | `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. |
399
- | `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. |
400
- | `popups` | `false` | Optional automatic popup capture. Set `true` or `{ max, triggers, overlays, timeoutMs }` to click visible safe triggers and save each opened overlay state as `<surface>-popup-XX`. |
401
- | `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. |
402
- | `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. |
403
- | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
404
- | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
405
- | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
406
- | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
407
- | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
408
- | `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. |
409
- | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
410
- | `baseDir` | `__stylemaps__` | Output root directory. |
619
+ | Option | Default | Purpose |
620
+ | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
621
+ | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants?, popups? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
622
+ | `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. |
623
+ | `variants` | _none_ | Optional non-live deterministic states under a surface. The base surface still captures; each variant becomes `<surface>-<variant>` so base/head compare matching states. |
624
+ | `popups` | `false` | Optional automatic popup capture. Set `true` or `{ max, triggers, overlays, timeoutMs }` to click visible safe triggers and save each opened overlay state as `<surface>-popup-XX`; maps include `overlays` proof metadata for captured semantic roots. |
625
+ | `expected` | _none_ | Your route/view/state/component universe. Emits a coverage-guard test (runs without a capture dir) that fails when a required key has no surface and isn't excluded. |
626
+ | `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. |
627
+ | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
628
+ | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
629
+ | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
630
+ | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
631
+ | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
632
+ | `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. |
633
+ | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
634
+ | `baseDir` | `__stylemaps__` | Output root directory. |
411
635
 
412
636
  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.
413
637
 
@@ -430,6 +654,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
430
654
  - `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
431
655
  - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
432
656
  - `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
657
+ - `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
433
658
 
434
659
  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.
435
660
 
@@ -176,9 +176,27 @@ const SURFACES: Surface[] = [
176
176
  // No widths → StyleProof detects your @media breakpoints from the loaded CSS and
177
177
  // sweeps one viewport per band. Pass an explicit array (e.g. 1280, 768, 390) to pin them (or to
178
178
  // cover a JS-only matchMedia breakpoint that has no CSS @media rule).
179
+ // Non-live UI states belong here as variants, so the base branch's
180
+ // dialog-open state compares to the head branch's dialog-open state.
181
+ // variants: [
182
+ // {
183
+ // key: 'dialog-open',
184
+ // go: async (page) => {
185
+ // await page.getByRole('button', { name: /open settings/i }).click();
186
+ // await page.getByRole('dialog').waitFor();
187
+ // },
188
+ // },
189
+ // {
190
+ // key: 'popover-open',
191
+ // go: async (page) => {
192
+ // await page.getByRole('button', { name: /more/i }).click();
193
+ // await page.locator('[popover], [role="menu"]').first().waitFor();
194
+ // },
195
+ // },
196
+ // ],
179
197
  },
180
- // Add one surface per distinct page state: open menus, dialogs, selected
181
- // tabs, form errors anything whose styling you want certified.
198
+ // Add more surfaces for distinct routes/views; add menus, dialogs, popovers,
199
+ // selected tabs, and form errors as variants of the route/view that owns them.
182
200
  ];
183
201
 
184
202
  defineStyleMapCapture({
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Harvest one-step UI state variants from a running app.
4
+ *
5
+ * styleproof-variants --base-url http://localhost:3000 --route / --route /settings
6
+ */
7
+ import fs from 'node:fs';
8
+ import { chromium } from '@playwright/test';
9
+ import { harvestStyleVariants } from '../dist/variant-crawler.js';
10
+ import { defaultLinkKey } from '../dist/crawl.js';
11
+ import { isHelpArg, showHelpAndExit, unknownFlagMessage } from '../dist/cli-errors.js';
12
+
13
+ const HELP = `styleproof-variants — discover one-step UI state variants
14
+
15
+ usage: styleproof-variants --base-url <url> --route <path-or-key=path> [options]
16
+
17
+ options:
18
+ --base-url <url> running app origin, e.g. http://localhost:3000
19
+ --route <route> route path, absolute URL, or key=path. Repeatable.
20
+ --out <file> manifest output (default: styleproof.variants.generated.json)
21
+ --max-actions <n> max attempted actions per route (default: 40)
22
+ --width <px> viewport width (default: 1280)
23
+ --height <px> viewport height (default: 800)
24
+ --strict exit 1 if live-state fixtures or skipped candidates remain
25
+ -h, --help show this help
26
+ `;
27
+
28
+ const argv = process.argv.slice(2);
29
+ let baseUrl = '';
30
+ let out = 'styleproof.variants.generated.json';
31
+ let maxActions = 40;
32
+ let width = 1280;
33
+ let height = 800;
34
+ let strict = false;
35
+ const routeArgs = [];
36
+
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (isHelpArg(a)) showHelpAndExit(HELP);
40
+ else if (a === '--base-url') baseUrl = argv[++i];
41
+ else if (a.startsWith('--base-url=')) baseUrl = a.slice(11);
42
+ else if (a === '--route') routeArgs.push(argv[++i]);
43
+ else if (a.startsWith('--route=')) routeArgs.push(a.slice(8));
44
+ else if (a === '--out') out = argv[++i];
45
+ else if (a.startsWith('--out=')) out = a.slice(6);
46
+ else if (a === '--max-actions') maxActions = Number(argv[++i]);
47
+ else if (a.startsWith('--max-actions=')) maxActions = Number(a.slice(14));
48
+ else if (a === '--width') width = Number(argv[++i]);
49
+ else if (a.startsWith('--width=')) width = Number(a.slice(8));
50
+ else if (a === '--height') height = Number(argv[++i]);
51
+ else if (a.startsWith('--height=')) height = Number(a.slice(9));
52
+ else if (a === '--strict') strict = true;
53
+ else if (a.startsWith('--')) {
54
+ console.error(unknownFlagMessage('styleproof-variants', a));
55
+ process.exit(2);
56
+ } else {
57
+ routeArgs.push(a);
58
+ }
59
+ }
60
+
61
+ if (!baseUrl) {
62
+ console.error('styleproof-variants: --base-url is required');
63
+ process.exit(2);
64
+ }
65
+ if (!routeArgs.length) {
66
+ console.error('styleproof-variants: at least one --route is required');
67
+ process.exit(2);
68
+ }
69
+ if (![maxActions, width, height].every(Number.isFinite)) {
70
+ console.error('styleproof-variants: --max-actions, --width, and --height must be numbers');
71
+ process.exit(2);
72
+ }
73
+
74
+ function parseRoute(input) {
75
+ const eq = input.indexOf('=');
76
+ if (eq > 0) return { key: input.slice(0, eq), url: input.slice(eq + 1) };
77
+ return { key: defaultLinkKey(new URL(input, baseUrl)), url: input };
78
+ }
79
+
80
+ const browser = await chromium.launch();
81
+ try {
82
+ const page = await browser.newPage({ viewport: { width, height } });
83
+ const harvest = await harvestStyleVariants(page, {
84
+ baseUrl,
85
+ routes: routeArgs.map(parseRoute),
86
+ maxActionsPerRoute: maxActions,
87
+ });
88
+ fs.writeFileSync(out, JSON.stringify(harvest, null, 2) + '\n');
89
+ const variants = harvest.routes.reduce((sum, route) => sum + route.variants.length, 0);
90
+ const liveStates = harvest.routes.reduce((sum, route) => sum + route.liveStates.length, 0);
91
+ const skipped = harvest.routes.reduce((sum, route) => sum + route.skipped.length, 0);
92
+ console.log(`styleproof-variants: wrote ${out}`);
93
+ console.log(`${variants} variant(s), ${liveStates} live-state candidate(s), ${skipped} skipped candidate(s)`);
94
+ if (strict && (liveStates || skipped)) process.exit(1);
95
+ } finally {
96
+ await browser.close();
97
+ }
@@ -0,0 +1,38 @@
1
+ export type ActionContextInput = {
2
+ eventName: string;
3
+ payload: {
4
+ pull_request?: {
5
+ number?: number;
6
+ head?: {
7
+ sha?: string;
8
+ };
9
+ };
10
+ workflow_run?: {
11
+ head_sha?: string;
12
+ pull_requests?: {
13
+ number?: number;
14
+ }[];
15
+ };
16
+ };
17
+ repo: Record<string, string>;
18
+ github: {
19
+ rest: {
20
+ repos: {
21
+ listPullRequestsAssociatedWithCommit: (args: Record<string, string>) => Promise<{
22
+ data: {
23
+ state?: string;
24
+ number?: number;
25
+ head?: {
26
+ sha?: string;
27
+ };
28
+ }[];
29
+ }>;
30
+ };
31
+ };
32
+ };
33
+ };
34
+ export type ActionContextResult = {
35
+ prNumber: string;
36
+ headSha: string;
37
+ };
38
+ export declare function resolveActionContext({ eventName, payload, repo, github, }: ActionContextInput): Promise<ActionContextResult>;
@@ -0,0 +1,17 @@
1
+ export async function resolveActionContext({ eventName, payload, repo, github, }) {
2
+ let prNumber = payload.pull_request?.number;
3
+ let headSha = payload.pull_request?.head?.sha;
4
+ if (eventName === 'workflow_run') {
5
+ const run = payload.workflow_run;
6
+ headSha = run?.head_sha;
7
+ prNumber = run?.pull_requests?.[0]?.number;
8
+ if (headSha && !prNumber) {
9
+ const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
10
+ ...repo,
11
+ commit_sha: headSha,
12
+ });
13
+ prNumber = data.find((pr) => pr.state === 'open' && pr.head?.sha === headSha)?.number;
14
+ }
15
+ }
16
+ return prNumber && headSha ? { prNumber: String(prNumber), headSha } : { prNumber: '', headSha: '' };
17
+ }
package/dist/capture.d.ts CHANGED
@@ -69,6 +69,16 @@ export type LiveRegionCandidate = {
69
69
  ariaLive?: string;
70
70
  ariaBusy?: string;
71
71
  };
72
+ export type CapturedOverlay = {
73
+ path: string;
74
+ tag: string;
75
+ cls: string;
76
+ reason: string;
77
+ role?: string;
78
+ ariaModal?: string;
79
+ ariaLive?: string;
80
+ text?: string;
81
+ };
72
82
  export type StyleMap = {
73
83
  /** Optional runner-supplied context; ignored by the certification diff. */
74
84
  metadata?: CaptureMetadata;
@@ -98,6 +108,14 @@ export type StyleMap = {
98
108
  * regions that actually keep changing are auto-excluded via `volatile`.
99
109
  */
100
110
  liveCandidates?: LiveRegionCandidate[];
111
+ /**
112
+ * Visible semantic overlay roots that were present in the captured DOM and
113
+ * whose paths are present in `elements`. This is diagnostic/proof metadata:
114
+ * dialogs, menus, listboxes, modal roots and toast roots are still certified by
115
+ * the normal computed-style map, while this list lets tests prove those states
116
+ * were actually reached and captured.
117
+ */
118
+ overlays?: CapturedOverlay[];
101
119
  /**
102
120
  * Colour-valued `:root` custom properties (design/theme tokens), normalised to
103
121
  * the same `rgb(...)` form the longhands resolve to — `{ "--red-200": "rgb(254,