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 +52 -1
- package/README.md +263 -38
- package/bin/styleproof-init.mjs +20 -2
- package/bin/styleproof-variants.mjs +97 -0
- package/dist/action-context.d.ts +38 -0
- package/dist/action-context.js +17 -0
- package/dist/capture.d.ts +18 -0
- package/dist/capture.js +71 -0
- package/dist/components.d.ts +41 -0
- package/dist/components.js +101 -0
- package/dist/coverage.d.ts +7 -6
- package/dist/coverage.js +7 -6
- package/dist/diff.js +22 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +33 -10
- package/dist/variant-crawler.d.ts +60 -0
- package/dist/variant-crawler.js +275 -0
- package/package.json +5 -3
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.
|
|
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
|
-
**
|
|
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
|
[](https://www.npmjs.com/package/styleproof)
|
|
6
9
|
[](https://github.com/BenSheridanEdwards/StyleProof/actions)
|
|
7
10
|
[](https://github.com/BenSheridanEdwards/StyleProof/blob/main/LICENSE)
|
|
8
11
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
34
|
+
## Why
|
|
16
35
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -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
|
|
181
|
-
// tabs, form errors
|
|
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,
|