styleproof 1.9.3 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -16
- package/README.md +54 -18
- package/bin/styleproof-report.mjs +11 -0
- package/dist/capture.d.ts +22 -0
- package/dist/capture.js +85 -47
- package/dist/diff.d.ts +21 -0
- package/dist/diff.js +61 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/report.d.ts +12 -0
- package/dist/report.js +101 -2
- package/dist/runner.d.ts +23 -1
- package/dist/runner.js +36 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,14 +7,67 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.10.0] - 2026-06-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`approve-all` input.** In review-gate mode, render a single **Approve all
|
|
15
|
+
changes** checkbox at the top of the report instead of one box per change, so a
|
|
16
|
+
reviewer signs off every change with one tick. Off by default (per-change boxes
|
|
17
|
+
are unchanged); the `styleproof-approve` workflow accepts either shape.
|
|
18
|
+
- **Fork and Dependabot support.** The Action now resolves the PR number and head
|
|
19
|
+
SHA in an event-aware way, so it can be driven from a `workflow_run` as well as
|
|
20
|
+
from `pull_request`. New example workflows `example/styleproof-capture.yml`
|
|
21
|
+
(read-only `pull_request` capture + artifact upload) and
|
|
22
|
+
`example/styleproof-report.yml` (`workflow_run` report under a write token, never
|
|
23
|
+
running the PR's code) let fork and Dependabot PRs gate without handing a write
|
|
24
|
+
token to untrusted code — the secure alternative to `pull_request_target`. PR
|
|
25
|
+
identity is taken only from the trusted `workflow_run` event (`head_sha`, the
|
|
26
|
+
event's `pull_requests`, and a commit→PR lookup against that same head SHA for
|
|
27
|
+
forks) — never from the capture artifact — so an untrusted PR cannot redirect the
|
|
28
|
+
privileged comment or status at a victim PR or commit.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **Real focus no longer makes `:focus` capture nondeterministic.** Capture froze
|
|
33
|
+
motion (`FREEZE_CSS`) and the clock but never neutralised real DOM focus, so
|
|
34
|
+
whatever element happened to hold focus at capture time (autofocus, late
|
|
35
|
+
hydration, a stray prior action) contaminated the capture across runs: it baked
|
|
36
|
+
a focus ring into the resting `elements` map, and it cancelled the forced-state
|
|
37
|
+
`:focus` delta — forcing `:focus` on an already-focused element changes nothing,
|
|
38
|
+
so the ring showed up as a delta on some runs but not others, surfacing as a
|
|
39
|
+
self-check `outline-color: … → (state does not change it)` failure on a no-op
|
|
40
|
+
PR. `captureStyleMap` now blurs the active element before any read, mirroring
|
|
41
|
+
the motion freeze; `:hover`/`:focus`/`:active` are still certified
|
|
42
|
+
deterministically via CDP `forcePseudoState`.
|
|
43
|
+
- **SSE streams no longer read as phantom diffs under replay.** A long-lived
|
|
44
|
+
stream can't round-trip through a HAR, so `routeFromHAR`'s url glob (default
|
|
45
|
+
`**/api/**`) would intercept an app's `EventSource` endpoint and, on the replay
|
|
46
|
+
run, abort it — dropping the app to its no-stream fallback (a different but
|
|
47
|
+
_stable_ render that settle/volatile can't catch). That surfaced as a
|
|
48
|
+
computed-style change on every diff even when no CSS changed. Capture now lets
|
|
49
|
+
`Accept: text/event-stream` requests bypass record/replay and reach the live
|
|
50
|
+
server on both runs, so both sides see the same streamed state. Stream-pushed
|
|
51
|
+
data must be deterministic at capture time (fixtures/frozen clock), as the
|
|
52
|
+
README already requires of live regions.
|
|
53
|
+
- The README CI recipe pointed `baseline-dir` / `fresh-dir` at the bare `base` /
|
|
54
|
+
`head` labels, but captures land under `baseDir` (`__stylemaps__/base`,
|
|
55
|
+
`__stylemaps__/head`), so `styleproof-diff` failed with `no capture at base`.
|
|
56
|
+
The recipe now uses the full `__stylemaps__/<label>` paths.
|
|
57
|
+
|
|
58
|
+
## [1.9.4]
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Repository URLs now use the canonical `BenSheridanEdwards/StyleProof` casing
|
|
63
|
+
everywhere — README badges, Action references, and doc links, plus the CHANGELOG
|
|
64
|
+
compare links, CONTRIBUTING, SECURITY, `action.yml`, and the release workflow.
|
|
65
|
+
(npm package-name URLs stay lowercase, as npm requires.)
|
|
66
|
+
|
|
10
67
|
## [1.9.3]
|
|
11
68
|
|
|
12
69
|
### Changed
|
|
13
70
|
|
|
14
|
-
- Maintainer contact moved off a personal email address: security issues now go
|
|
15
|
-
through GitHub's private vulnerability reporting and conduct reports through the
|
|
16
|
-
maintainer's GitHub profile, and the `package.json` `author` no longer carries an
|
|
17
|
-
email. The published package and its metadata are now contact-clean.
|
|
18
71
|
- Canonical `StyleProof`-cased GitHub URLs in `package.json` (`homepage`,
|
|
19
72
|
`repository`, `bugs`).
|
|
20
73
|
|
|
@@ -503,15 +556,15 @@ number)`), so each viewport band can capture at its own height. Default remains
|
|
|
503
556
|
- `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
|
|
504
557
|
property, and state that drifted (exit 1).
|
|
505
558
|
|
|
506
|
-
[Unreleased]: https://github.com/BenSheridanEdwards/
|
|
507
|
-
[1.2.0]: https://github.com/BenSheridanEdwards/
|
|
508
|
-
[1.1.0]: https://github.com/BenSheridanEdwards/
|
|
509
|
-
[1.0.0]: https://github.com/BenSheridanEdwards/
|
|
510
|
-
[0.7.0]: https://github.com/BenSheridanEdwards/
|
|
511
|
-
[0.6.0]: https://github.com/BenSheridanEdwards/
|
|
512
|
-
[0.5.0]: https://github.com/BenSheridanEdwards/
|
|
513
|
-
[0.4.0]: https://github.com/BenSheridanEdwards/
|
|
514
|
-
[0.3.0]: https://github.com/BenSheridanEdwards/
|
|
515
|
-
[0.2.1]: https://github.com/BenSheridanEdwards/
|
|
516
|
-
[0.2.0]: https://github.com/BenSheridanEdwards/
|
|
517
|
-
[0.1.0]: https://github.com/BenSheridanEdwards/
|
|
559
|
+
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.2.0...HEAD
|
|
560
|
+
[1.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.1.0...v1.2.0
|
|
561
|
+
[1.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.0.0...v1.1.0
|
|
562
|
+
[1.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.7.0...v1.0.0
|
|
563
|
+
[0.7.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.6.0...v0.7.0
|
|
564
|
+
[0.6.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.5.0...v0.6.0
|
|
565
|
+
[0.5.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.4.0...v0.5.0
|
|
566
|
+
[0.4.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.3.0...v0.4.0
|
|
567
|
+
[0.3.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.2.1...v0.3.0
|
|
568
|
+
[0.2.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.2.0...v0.2.1
|
|
569
|
+
[0.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.1.0...v0.2.0
|
|
570
|
+
[0.1.0]: https://github.com/BenSheridanEdwards/StyleProof/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
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.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/styleproof)
|
|
6
|
-
[](https://github.com/BenSheridanEdwards/
|
|
6
|
+
[](https://github.com/BenSheridanEdwards/StyleProof/actions)
|
|
7
|
+
[](https://github.com/BenSheridanEdwards/StyleProof/blob/main/LICENSE)
|
|
8
8
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
@@ -15,7 +15,7 @@ Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:fo
|
|
|
15
15
|
On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base branch, diffs them, and posts a Markdown comment:
|
|
16
16
|
|
|
17
17
|
- A summary line, then **one section per distinct change**, with a side-by-side before/after cropped screenshot (both sides cropped from the same rectangle, so they line up exactly) and **plain-English bullets that tell you what to look for** (`columns: 2 → 3`, `recoloured cyan → amber`) above the exact property changes, folded under a toggle.
|
|
18
|
-
- An **approval checkbox per change
|
|
18
|
+
- An **approval checkbox per change** — or, with `approve-all: true`, a single **Approve all changes** box at the top that signs off everything in one tick — driving a `StyleProof` commit status: red until signed off, green when there are none.
|
|
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 with its screenshot under a `🆕 new surface` heading and an _optional_ approval box, but it never holds the status red. It becomes part of the baseline once merged.
|
|
20
20
|
- No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
|
|
21
21
|
|
|
@@ -111,17 +111,32 @@ jobs:
|
|
|
111
111
|
- run: STYLEMAP_DIR=head STYLEPROOF_REPLAY_FROM=__stylemaps__/base npx playwright test e2e/styleproof.spec.ts
|
|
112
112
|
|
|
113
113
|
# report + gate
|
|
114
|
-
- uses: BenSheridanEdwards/
|
|
114
|
+
- uses: BenSheridanEdwards/StyleProof@v1
|
|
115
115
|
with:
|
|
116
|
-
baseline-dir: base
|
|
117
|
-
fresh-dir: head
|
|
116
|
+
baseline-dir: __stylemaps__/base # captures land under baseDir (default __stylemaps__)
|
|
117
|
+
fresh-dir: __stylemaps__/head
|
|
118
118
|
require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
**3. Copy [`example/styleproof-approve.yml`](https://github.com/BenSheridanEdwards/
|
|
121
|
+
**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.
|
|
122
122
|
|
|
123
123
|
**4. Require the `StyleProof` status** in branch protection. Now an unsigned visual change can't merge.
|
|
124
124
|
|
|
125
|
+
## Forks and Dependabot
|
|
126
|
+
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
Fix it by splitting capture from reporting, the way the approve workflow is already split out:
|
|
130
|
+
|
|
131
|
+
- **[`example/styleproof-capture.yml`](example/styleproof-capture.yml)** runs `on: pull_request` with a **read-only** token and no secrets — safe to run untrusted PR code. It only builds, captures the style maps, and uploads them as an artifact.
|
|
132
|
+
- **[`example/styleproof-report.yml`](example/styleproof-report.yml)** runs `on: workflow_run` (after capture finishes) from your **default branch** with a write token. It downloads the artifact and does the diff, comment, and status — but **never checks out or runs the PR's code**, only the trusted style-map data.
|
|
133
|
+
|
|
134
|
+
That last point is why this works where `pull_request_target` does not: StyleProof builds and serves the PR's head, so running it under `pull_request_target` would hand a write token (and your secrets) to untrusted code — the exact supply-chain risk StyleProof exists to help you catch. The `workflow_run` split keeps the privileged half away from PR code entirely.
|
|
135
|
+
|
|
136
|
+
**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.
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
125
140
|
**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:
|
|
126
141
|
|
|
127
142
|
- **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`.
|
|
@@ -133,19 +148,40 @@ jobs:
|
|
|
133
148
|
|
|
134
149
|
**Live pages just work.** Before each capture, StyleProof settles the page — it waits until the computed-style map stops changing, so async content (a fetch, an SSE/WebSocket stream backfilling a grid) is captured loaded, not mid-load. 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. Disable or tune with `captureStyleMap(page, { stabilize: false })` / `{ stabilize: { quietFor, timeout } }`.
|
|
135
150
|
|
|
151
|
+
## Optional: content layer (advisory)
|
|
152
|
+
|
|
153
|
+
StyleProof is **computed-styles first**, and stays that way: a CSS-only refactor that also rewrites text is still certified identical, and live text (a clock, "2m ago") never reads as a change. But a pure-style diff is blind to copy, and copy isn't always cosmetic: **new or longer text can overflow or clip its box, silently breaking the layout.** A visual-confidence tool that can't see that isn't quite complete. So the content layer exists as an explicit **opt-in**, off by default, and **advisory** — it never feeds the certification or the gate.
|
|
154
|
+
|
|
155
|
+
Turn it on in two places:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// styleproof.spec.ts — record each element's own text alongside its computed style
|
|
159
|
+
defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, captureText: true });
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# render the advisory content section (each change with a before/after crop)
|
|
164
|
+
styleproof-report before after --out report --include-content
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The report then carries a separate **📝 Content changes (advisory)** section: every element whose own text changed, with the before/after strings and a side-by-side crop, so a silent copy edit (and any overflow it causes) is visible in review. It does **not** affect `changed`, the `StyleProof` status, or the diff exit code, by design. With capture left at its default (`captureText` off), there's no text in the maps and the section is always empty, so existing setups are completely unaffected.
|
|
168
|
+
|
|
169
|
+
Notes: only an element's _own_ text is recorded (so a parent and child never double-report the same string); text churn in a live region is auto-excluded by the same settle pass that guards styles; and the certification CLI (`styleproof-diff`) is deliberately left content-blind.
|
|
170
|
+
|
|
136
171
|
## Reference
|
|
137
172
|
|
|
138
|
-
**Action `BenSheridanEdwards/
|
|
173
|
+
**Action `BenSheridanEdwards/StyleProof@v1`** — key inputs:
|
|
139
174
|
|
|
140
|
-
| Input | Default | Purpose
|
|
141
|
-
| ------------------ | ------------ |
|
|
142
|
-
| `baseline-dir` | _required_ | Base-branch captures.
|
|
143
|
-
| `fresh-dir` | _required_ | PR-head captures to compare.
|
|
144
|
-
| `require-approval` | `false` | Review-gate mode: set the `StyleProof` status instead of failing.
|
|
145
|
-
| `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true.
|
|
146
|
-
| `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection.
|
|
175
|
+
| Input | Default | Purpose |
|
|
176
|
+
| ------------------ | ------------ | --------------------------------------------------------------------------------------- |
|
|
177
|
+
| `baseline-dir` | _required_ | Base-branch captures. |
|
|
178
|
+
| `fresh-dir` | _required_ | PR-head captures to compare. |
|
|
179
|
+
| `require-approval` | `false` | Review-gate mode: set the `StyleProof` status instead of failing. |
|
|
180
|
+
| `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
|
|
181
|
+
| `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
|
|
182
|
+
| `approve-all` | `false` | Review-gate UI: one **Approve all changes** box at the top instead of per-change boxes. |
|
|
147
183
|
|
|
148
|
-
Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `inline-images`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/
|
|
184
|
+
Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `inline-images`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
|
|
149
185
|
|
|
150
186
|
**Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
|
|
151
187
|
|
|
@@ -176,9 +212,9 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
176
212
|
|
|
177
213
|
- `styleproof-init` — scaffold the capture spec (and a starter `playwright.config.ts` if none exists).
|
|
178
214
|
- `styleproof-diff <beforeDir> <afterDir>` — the certify gate; exits `1` on any difference.
|
|
179
|
-
- `styleproof-report <beforeDir> <afterDir> --out <dir>` — render the diff to a Markdown report with before/after crops.
|
|
215
|
+
- `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).
|
|
180
216
|
|
|
181
|
-
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/
|
|
217
|
+
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.
|
|
182
218
|
|
|
183
219
|
## License
|
|
184
220
|
|
|
@@ -28,6 +28,10 @@ options:
|
|
|
28
28
|
--include-layout-noise keep size/position-derived longhands (height, width,
|
|
29
29
|
transform-origin, top…) that a reflow changes up the
|
|
30
30
|
whole ancestor chain (off by default)
|
|
31
|
+
--include-content render the opt-in content layer: an advisory section
|
|
32
|
+
of elements whose text changed, each with a
|
|
33
|
+
before/after crop. Needs captures taken with
|
|
34
|
+
captureText:true; never affects the check (off by default)
|
|
31
35
|
-h, --help show this help
|
|
32
36
|
|
|
33
37
|
exit: 0 no changes, 1 report generated, 2 usage error.
|
|
@@ -42,6 +46,7 @@ let foldDetailsAt;
|
|
|
42
46
|
let minWidth;
|
|
43
47
|
let minHeight;
|
|
44
48
|
let includeLayoutNoise = false;
|
|
49
|
+
let includeContent = false;
|
|
45
50
|
for (let i = 0; i < argv.length; i++) {
|
|
46
51
|
const a = argv[i];
|
|
47
52
|
if (a === '-h' || a === '--help') {
|
|
@@ -63,6 +68,8 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
63
68
|
else if (a.startsWith('--min-height=')) minHeight = Number(a.slice(13));
|
|
64
69
|
else if (a === '--include-layout-noise') includeLayoutNoise = true;
|
|
65
70
|
else if (a.startsWith('--include-layout-noise=')) includeLayoutNoise = a.slice(23) !== 'false';
|
|
71
|
+
else if (a === '--include-content') includeContent = true;
|
|
72
|
+
else if (a.startsWith('--include-content=')) includeContent = a.slice(18) !== 'false';
|
|
66
73
|
else if (a.startsWith('--')) {
|
|
67
74
|
console.error(`unknown flag: ${a}`);
|
|
68
75
|
process.exit(2);
|
|
@@ -102,6 +109,7 @@ try {
|
|
|
102
109
|
minWidth,
|
|
103
110
|
minHeight,
|
|
104
111
|
includeLayoutNoise,
|
|
112
|
+
includeContent,
|
|
105
113
|
});
|
|
106
114
|
} catch (e) {
|
|
107
115
|
console.error(e.message);
|
|
@@ -117,4 +125,7 @@ console.log(
|
|
|
117
125
|
: `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
|
|
118
126
|
);
|
|
119
127
|
console.log(`report: ${result.reportMdPath}`);
|
|
128
|
+
if (includeContent && result.contentChanges > 0) {
|
|
129
|
+
console.log(`📝 ${result.contentChanges} advisory content change(s) — does not affect the exit code`);
|
|
130
|
+
}
|
|
120
131
|
process.exit(result.changedSurfaces === 0 && result.newSurfaces === 0 ? 0 : 1);
|
package/dist/capture.d.ts
CHANGED
|
@@ -33,6 +33,15 @@ export type ElementEntry = {
|
|
|
33
33
|
rect?: Rect;
|
|
34
34
|
style: Props;
|
|
35
35
|
pseudo?: Record<string, Props>;
|
|
36
|
+
/**
|
|
37
|
+
* The element's OWN rendered text (direct text-node children only, whitespace
|
|
38
|
+
* collapsed) — present only when capture ran with `captureText: true` (the
|
|
39
|
+
* opt-in content layer, off by default). Own-text, not subtree text, so each
|
|
40
|
+
* change is attributed to the single element that owns it. Diffed by
|
|
41
|
+
* {@link diffContentMaps}, never by the certification diff — content is
|
|
42
|
+
* advisory, not a computed-style outcome. See README "Optional: content layer".
|
|
43
|
+
*/
|
|
44
|
+
text?: string;
|
|
36
45
|
};
|
|
37
46
|
export type StyleMap = {
|
|
38
47
|
defaults: Record<string, Props>;
|
|
@@ -107,6 +116,19 @@ export type CaptureOptions = {
|
|
|
107
116
|
* minutes; raise it deliberately if you need full coverage of a huge page.
|
|
108
117
|
*/
|
|
109
118
|
maxInteractive?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Opt-in content layer (default OFF). When true, each element's own rendered
|
|
121
|
+
* text is recorded on `ElementEntry.text` so {@link diffContentMaps} can
|
|
122
|
+
* surface copy changes a pure-style diff is blind to — most importantly the
|
|
123
|
+
* silent ones where new or longer text overflows or clips its box. This is
|
|
124
|
+
* ADVISORY: it never feeds the certification diff or its blocking counts, and
|
|
125
|
+
* StyleProof stays computed-styles-first. Off by default keeps the core
|
|
126
|
+
* promise (a CSS-only refactor that rewrites text is still certified
|
|
127
|
+
* identical) intact for anyone who doesn't opt in. Text churn (clocks,
|
|
128
|
+
* "2m ago") is auto-excluded by the same live-region settle pass that already
|
|
129
|
+
* guards styles, since text now participates in change detection.
|
|
130
|
+
*/
|
|
131
|
+
captureText?: boolean;
|
|
110
132
|
};
|
|
111
133
|
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
112
134
|
* the capture (excluding live regions) and the diff (skipping them). */
|
package/dist/capture.js
CHANGED
|
@@ -31,7 +31,10 @@ export function isUnder(path, roots) {
|
|
|
31
31
|
return roots.some((r) => path === r || path.startsWith(r + ' > '));
|
|
32
32
|
}
|
|
33
33
|
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
34
|
-
|
|
34
|
+
// Pre-existing, grandfathered in the health baseline; the content layer adds
|
|
35
|
+
// one small captureText block, not new structure.
|
|
36
|
+
// fallow-ignore-next-line complexity
|
|
37
|
+
function capturePage({ ignore, motionOnly, captureText }) {
|
|
35
38
|
const MOTION = /^(transition|animation)/;
|
|
36
39
|
const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
|
|
37
40
|
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
@@ -131,6 +134,19 @@ function capturePage({ ignore, motionOnly }) {
|
|
|
131
134
|
Math.round(r.width),
|
|
132
135
|
Math.round(r.height),
|
|
133
136
|
];
|
|
137
|
+
if (captureText) {
|
|
138
|
+
// Own text only (direct text-node children, whitespace collapsed), so a
|
|
139
|
+
// parent and child never both report the same string — each change is
|
|
140
|
+
// attributed to the element that actually owns the text.
|
|
141
|
+
let t = '';
|
|
142
|
+
for (const node of Array.prototype.slice.call(el.childNodes)) {
|
|
143
|
+
if (node.nodeType === 3 /* TEXT_NODE */)
|
|
144
|
+
t += node.textContent ?? '';
|
|
145
|
+
}
|
|
146
|
+
t = t.replace(/\s+/g, ' ').trim();
|
|
147
|
+
if (t)
|
|
148
|
+
entry.text = t;
|
|
149
|
+
}
|
|
134
150
|
}
|
|
135
151
|
for (const ps of PSEUDOS) {
|
|
136
152
|
if (ps === '::marker' && getComputedStyle(el).display !== 'list-item')
|
|
@@ -209,8 +225,14 @@ const STATE_SETS = {
|
|
|
209
225
|
focus: ['focus', 'focus-visible'],
|
|
210
226
|
active: ['active'],
|
|
211
227
|
};
|
|
212
|
-
|
|
213
|
-
|
|
228
|
+
const STATE_ID_ATTR = 'data-styleproof-state-id';
|
|
229
|
+
/**
|
|
230
|
+
* Mark interactive elements once, then address that same element from both CDP
|
|
231
|
+
* and page.evaluate. Positional CDP nodeId ↔ querySelectorAll index alignment is
|
|
232
|
+
* flaky on hydrated apps: two non-atomic DOM snapshots can have the same count
|
|
233
|
+
* but different ordering, which produces phantom forced-state diffs.
|
|
234
|
+
*/
|
|
235
|
+
function markInteractiveElements({ selector, skipSel, attr }) {
|
|
214
236
|
const pathOf = (el) => {
|
|
215
237
|
if (el === document.documentElement)
|
|
216
238
|
return 'html';
|
|
@@ -227,7 +249,18 @@ function pathsForSelector({ selector, skipSel }) {
|
|
|
227
249
|
}
|
|
228
250
|
return 'body > ' + parts.join(' > ');
|
|
229
251
|
};
|
|
230
|
-
|
|
252
|
+
let i = 0;
|
|
253
|
+
return [...document.querySelectorAll(selector)].flatMap((el) => {
|
|
254
|
+
if (skipSel && el.matches(skipSel))
|
|
255
|
+
return [];
|
|
256
|
+
const id = `sp-${i++}`;
|
|
257
|
+
el.setAttribute(attr, id);
|
|
258
|
+
return [{ id, path: pathOf(el) }];
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function clearInteractiveMarks(attr) {
|
|
262
|
+
for (const el of document.querySelectorAll(`[${attr}]`))
|
|
263
|
+
el.removeAttribute(attr);
|
|
231
264
|
}
|
|
232
265
|
// Forced pseudo-class states on interactive elements, via CDP so no real
|
|
233
266
|
// mouse or focus is involved and parent-state descendant rules still apply.
|
|
@@ -236,49 +269,39 @@ async function captureForcedStates(page, ignore, maxInteractive, skipPaths = [])
|
|
|
236
269
|
await client.send('DOM.enable');
|
|
237
270
|
await client.send('CSS.enable');
|
|
238
271
|
const { root } = await client.send('DOM.getDocument');
|
|
239
|
-
const { nodeIds } = await client.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: INTERACTIVE });
|
|
240
272
|
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// The CDP DOM snapshot and the live querySelectorAll are two separate,
|
|
245
|
-
// non-atomic reads. They can legitimately disagree — display:contents,
|
|
246
|
-
// nodes detached or injected between the two calls (next-route-announcer,
|
|
247
|
-
// Playwright internals, late hydration adding [tabindex]). A mismatch is NOT
|
|
248
|
-
// a fatal error: positional nodeId↔path alignment is only valid when the
|
|
249
|
-
// counts agree, so on skew we warn and skip the forced-state layer for this
|
|
250
|
-
// surface rather than aborting the whole capture (base + pseudo layers,
|
|
251
|
-
// the load-bearing certification, still succeed).
|
|
252
|
-
if (paths.length !== nodeIds.length) {
|
|
253
|
-
// eslint-disable-next-line no-console
|
|
254
|
-
console.warn(`styleproof: interactive-element count skew (CDP saw ${nodeIds.length}, page saw ${paths.length}); ` +
|
|
255
|
-
'skipping forced :hover/:focus/:active capture for this surface. This is usually a benign, ' +
|
|
256
|
-
'transient DOM difference (display:contents, injected/detached nodes). Re-run if it persists.');
|
|
257
|
-
await client.detach();
|
|
258
|
-
return { states: {}, skipped: true };
|
|
259
|
-
}
|
|
260
|
-
const limit = Math.min(nodeIds.length, maxInteractive);
|
|
261
|
-
if (nodeIds.length > maxInteractive) {
|
|
273
|
+
const marked = (await page.evaluate(markInteractiveElements, { selector: INTERACTIVE, skipSel, attr: STATE_ID_ATTR })).filter((m) => !(skipPaths.length && isUnder(m.path, skipPaths)));
|
|
274
|
+
const limit = Math.min(marked.length, maxInteractive);
|
|
275
|
+
if (marked.length > maxInteractive) {
|
|
262
276
|
// eslint-disable-next-line no-console
|
|
263
|
-
console.warn(`styleproof: ${
|
|
277
|
+
console.warn(`styleproof: ${marked.length} interactive elements exceeds maxInteractive=${maxInteractive}; ` +
|
|
264
278
|
`forced-state capture truncated to the first ${maxInteractive}. Raise maxInteractive to cover them all.`);
|
|
265
279
|
}
|
|
266
280
|
const states = {};
|
|
267
|
-
|
|
268
|
-
const p
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
(
|
|
281
|
+
try {
|
|
282
|
+
for (const { id, path: p } of marked.slice(0, limit)) {
|
|
283
|
+
const selector = `[${STATE_ID_ATTR}="${id}"]`;
|
|
284
|
+
const { nodeId } = await client.send('DOM.querySelector', { nodeId: root.nodeId, selector });
|
|
285
|
+
if (!nodeId) {
|
|
286
|
+
// eslint-disable-next-line no-console
|
|
287
|
+
console.warn(`styleproof: interactive element ${id} detached before forced-state capture; skipping it.`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const baseSnap = await page.evaluate(snapSubtree, { selector, index: 0 });
|
|
291
|
+
for (const [stateName, forcedPseudoClasses] of Object.entries(STATE_SETS)) {
|
|
292
|
+
await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses });
|
|
293
|
+
const forcedSnap = await page.evaluate(snapSubtree, { selector, index: 0 });
|
|
294
|
+
await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] });
|
|
295
|
+
const delta = deltaBetween(baseSnap, forcedSnap);
|
|
296
|
+
if (Object.keys(delta).length)
|
|
297
|
+
(states[p] ??= {})[stateName] = delta;
|
|
298
|
+
}
|
|
279
299
|
}
|
|
280
300
|
}
|
|
281
|
-
|
|
301
|
+
finally {
|
|
302
|
+
await page.evaluate(clearInteractiveMarks, STATE_ID_ATTR).catch(() => undefined);
|
|
303
|
+
await client.detach();
|
|
304
|
+
}
|
|
282
305
|
return { states, skipped: false };
|
|
283
306
|
}
|
|
284
307
|
/** Element paths that differ between two captures (added, removed, or restyled). */
|
|
@@ -299,8 +322,11 @@ function changedElementPaths(a, b) {
|
|
|
299
322
|
* Reuses `capturePage` (motion already frozen by the caller), so only
|
|
300
323
|
* content/layout churn — not an animation frame — keeps it from settling.
|
|
301
324
|
*/
|
|
302
|
-
async function stabilizePage(page, ignore, interval, quietFor, timeout) {
|
|
303
|
-
|
|
325
|
+
async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText) {
|
|
326
|
+
// captureText is threaded in so text churn participates in settle detection:
|
|
327
|
+
// a clock/ticker whose text changes (with no style change) keeps the map from
|
|
328
|
+
// settling and is excluded as a live region, exactly as style churn already is.
|
|
329
|
+
const snap = async () => (await page.evaluate(capturePage, { ignore, motionOnly: false, captureText })).elements;
|
|
304
330
|
const start = Date.now();
|
|
305
331
|
let prev = await snap();
|
|
306
332
|
let lastChangeAt = start;
|
|
@@ -349,11 +375,11 @@ function capturePageTokens() {
|
|
|
349
375
|
return tokens;
|
|
350
376
|
}
|
|
351
377
|
/** Settle the page and return the paths of live regions to exclude. */
|
|
352
|
-
async function detectVolatile(page, ignore, stabilize) {
|
|
378
|
+
async function detectVolatile(page, ignore, stabilize, captureText) {
|
|
353
379
|
if (stabilize === false)
|
|
354
380
|
return [];
|
|
355
381
|
const opt = typeof stabilize === 'object' ? stabilize : {};
|
|
356
|
-
const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000);
|
|
382
|
+
const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000, captureText);
|
|
357
383
|
if (volatile.length) {
|
|
358
384
|
// eslint-disable-next-line no-console
|
|
359
385
|
console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
|
|
@@ -406,15 +432,27 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
406
432
|
const captureStates = options.captureStates ?? true;
|
|
407
433
|
const maxInteractive = options.maxInteractive ?? 800;
|
|
408
434
|
const stabilize = options.stabilize ?? true;
|
|
435
|
+
const captureText = options.captureText ?? false;
|
|
436
|
+
// Neutralise real focus the same way FREEZE_CSS neutralises motion: blur
|
|
437
|
+
// whatever element holds focus so every read below is the no-interaction
|
|
438
|
+
// resting state. Real :focus is nondeterministic across runs (autofocus, late
|
|
439
|
+
// hydration, a stray prior action) and contaminates BOTH layers — it bakes a
|
|
440
|
+
// focus ring into the resting map, AND it cancels the forced-state :focus delta
|
|
441
|
+
// (forcing :focus on an already-focused element changes nothing, so the ring
|
|
442
|
+
// shows up as a delta on some runs but not others — a self-check "non-
|
|
443
|
+
// deterministic" failure). Interaction states are certified deterministically
|
|
444
|
+
// via CDP forcePseudoState below, never via whatever happened to be focused.
|
|
445
|
+
await page.evaluate(() => document.activeElement?.blur?.());
|
|
409
446
|
// Motion longhands first (FREEZE_CSS would null them), then everything else.
|
|
410
|
-
|
|
447
|
+
// Motion never carries text — it's a settled end state of declared motion.
|
|
448
|
+
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
|
|
411
449
|
await page.addStyleTag({ content: FREEZE_CSS });
|
|
412
450
|
// Settle: wait for async content to finish painting so base and head capture
|
|
413
451
|
// the same loaded state, and collect any region still changing on its own
|
|
414
452
|
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
415
453
|
// real content/layout churn lands here.
|
|
416
|
-
const volatile = await detectVolatile(page, ignore, stabilize);
|
|
417
|
-
const base = await page.evaluate(capturePage, { ignore, motionOnly: false });
|
|
454
|
+
const volatile = await detectVolatile(page, ignore, stabilize, captureText);
|
|
455
|
+
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText });
|
|
418
456
|
dropVolatile(base.elements, volatile);
|
|
419
457
|
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
|
420
458
|
mergeMotion(base.elements, motion.elements);
|
package/dist/diff.d.ts
CHANGED
|
@@ -40,6 +40,15 @@ export type DiffCounts = {
|
|
|
40
40
|
style: number;
|
|
41
41
|
state: number;
|
|
42
42
|
};
|
|
43
|
+
/** A change to an element's own rendered text (opt-in content layer). Kept out
|
|
44
|
+
* of `Finding`/`DiffCounts` on purpose: content is advisory, never part of the
|
|
45
|
+
* computed-style certification or its blocking counts. */
|
|
46
|
+
export type ContentChange = {
|
|
47
|
+
path: string;
|
|
48
|
+
cls: string;
|
|
49
|
+
before: string;
|
|
50
|
+
after: string;
|
|
51
|
+
};
|
|
43
52
|
/** Diff two style maps of the same surface. */
|
|
44
53
|
export declare function diffStyleMaps(a: StyleMap, b: StyleMap): Finding[];
|
|
45
54
|
/** Diff every same-named capture between two directories. `volatile` is the
|
|
@@ -49,5 +58,17 @@ export declare function diffStyleMapDirs(dirA: string, dirB: string): {
|
|
|
49
58
|
counts: DiffCounts;
|
|
50
59
|
volatile: number;
|
|
51
60
|
};
|
|
61
|
+
export declare function diffContentMaps(a: StyleMap, b: StyleMap): ContentChange[];
|
|
62
|
+
/** Per-surface content diff across two capture dirs (opt-in layer). Mirrors
|
|
63
|
+
* {@link diffStyleMapDirs} but content-only and non-gating; surfaces present on
|
|
64
|
+
* just one side have no baseline and are skipped (the style diff reports those
|
|
65
|
+
* as new surfaces). */
|
|
66
|
+
export declare function diffContentDirs(dirA: string, dirB: string): {
|
|
67
|
+
surfaces: {
|
|
68
|
+
surface: string;
|
|
69
|
+
changes: ContentChange[];
|
|
70
|
+
}[];
|
|
71
|
+
count: number;
|
|
72
|
+
};
|
|
52
73
|
/** Human label: structural path plus a truncated class hint. */
|
|
53
74
|
export declare function findingLabel(path: string, cls: string): string;
|
package/dist/diff.js
CHANGED
|
@@ -13,14 +13,22 @@ function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
|
13
13
|
}
|
|
14
14
|
return changed;
|
|
15
15
|
}
|
|
16
|
+
/** Union of both captures' live-region paths — skipped by every diff layer so a
|
|
17
|
+
* region volatile on either side never reads as a change. */
|
|
18
|
+
function volatilePaths(a, b) {
|
|
19
|
+
return [...new Set([...(a.volatile ?? []), ...(b.volatile ?? [])])];
|
|
20
|
+
}
|
|
16
21
|
/** Diff two style maps of the same surface. */
|
|
22
|
+
// Pre-existing, grandfathered in the health baseline; the content layer only
|
|
23
|
+
// extracted volatilePaths out of this, it is not newly complex.
|
|
24
|
+
// fallow-ignore-next-line complexity
|
|
17
25
|
export function diffStyleMaps(a, b) {
|
|
18
26
|
const findings = [];
|
|
19
27
|
// Live regions either capture flagged as nondeterministic (a stream, ticker,
|
|
20
28
|
// late-loading content): never diff them — their values move with no code
|
|
21
29
|
// change. Union both sides so a region volatile on only one capture is still
|
|
22
30
|
// skipped, in every layer (element, pseudo, forced-state).
|
|
23
|
-
const volatile =
|
|
31
|
+
const volatile = volatilePaths(a, b);
|
|
24
32
|
for (const p of [...new Set([...Object.keys(a.elements), ...Object.keys(b.elements)])].sort()) {
|
|
25
33
|
if (volatile.length && isUnder(p, volatile))
|
|
26
34
|
continue;
|
|
@@ -133,6 +141,58 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
133
141
|
}
|
|
134
142
|
return { surfaces, counts, volatile };
|
|
135
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Diff the OPT-IN content layer: elements whose own rendered text changed.
|
|
146
|
+
* Separate from {@link diffStyleMaps} by design — content never enters the
|
|
147
|
+
* certification or its counts. Yields nothing unless capture ran with
|
|
148
|
+
* `captureText: true` (no `text` on either side → nothing to compare), so it's a
|
|
149
|
+
* no-op for anyone who hasn't opted in. Add/remove of an element is left to the
|
|
150
|
+
* style diff (it surfaces there as a DOM change); this reports text that changed
|
|
151
|
+
* on an element present in BOTH captures. Volatile (live) regions are skipped,
|
|
152
|
+
* same as the style diff.
|
|
153
|
+
*/
|
|
154
|
+
/** Normalise captured text (absent → empty) so undefined and '' compare equal. */
|
|
155
|
+
const ownText = (t) => t ?? '';
|
|
156
|
+
export function diffContentMaps(a, b) {
|
|
157
|
+
const volatile = volatilePaths(a, b);
|
|
158
|
+
// Only paths present in BOTH captures: an add/remove is a DOM change the style
|
|
159
|
+
// diff already owns, so iterating the intersection both skips them and keeps
|
|
160
|
+
// this loop branch-light. Equal text (incl. both absent → '' === '') drops out.
|
|
161
|
+
const common = Object.keys(a.elements)
|
|
162
|
+
.filter((p) => p in b.elements)
|
|
163
|
+
.sort();
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const p of common) {
|
|
166
|
+
if (isUnder(p, volatile))
|
|
167
|
+
continue;
|
|
168
|
+
const before = ownText(a.elements[p].text);
|
|
169
|
+
const after = ownText(b.elements[p].text);
|
|
170
|
+
if (before !== after)
|
|
171
|
+
out.push({ path: p, cls: a.elements[p].cls, before, after });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/** Per-surface content diff across two capture dirs (opt-in layer). Mirrors
|
|
176
|
+
* {@link diffStyleMapDirs} but content-only and non-gating; surfaces present on
|
|
177
|
+
* just one side have no baseline and are skipped (the style diff reports those
|
|
178
|
+
* as new surfaces). */
|
|
179
|
+
export function diffContentDirs(dirA, dirB) {
|
|
180
|
+
const indexA = indexDir(dirA);
|
|
181
|
+
const indexB = indexDir(dirB);
|
|
182
|
+
const both = Object.keys(indexA)
|
|
183
|
+
.filter((s) => s in indexB)
|
|
184
|
+
.sort();
|
|
185
|
+
const surfaces = [];
|
|
186
|
+
let count = 0;
|
|
187
|
+
for (const surface of both) {
|
|
188
|
+
const changes = diffContentMaps(loadStyleMap(indexA[surface]), loadStyleMap(indexB[surface]));
|
|
189
|
+
if (changes.length) {
|
|
190
|
+
surfaces.push({ surface, changes });
|
|
191
|
+
count += changes.length;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { surfaces, count };
|
|
195
|
+
}
|
|
136
196
|
/** Human label: structural path plus a truncated class hint. */
|
|
137
197
|
export function findingLabel(path, cls) {
|
|
138
198
|
if (!cls)
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
|
2
2
|
export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
|
|
3
3
|
export { defineStyleMapCapture } from './runner.js';
|
|
4
4
|
export type { Surface, DefineOptions } from './runner.js';
|
|
5
|
-
export { diffStyleMaps, diffStyleMapDirs, findingLabel } from './diff.js';
|
|
6
|
-
export type { Finding, PropChange, SurfaceDiff, DiffCounts } from './diff.js';
|
|
5
|
+
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
6
|
+
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
7
7
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
8
8
|
export type { ReportOptions, ReportResult } from './report.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
2
2
|
export { defineStyleMapCapture } from './runner.js';
|
|
3
|
-
export { diffStyleMaps, diffStyleMapDirs, findingLabel } from './diff.js';
|
|
3
|
+
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
4
4
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/report.d.ts
CHANGED
|
@@ -40,6 +40,16 @@ export type ReportOptions = {
|
|
|
40
40
|
* (`styleproof-diff`) always keeps them.
|
|
41
41
|
*/
|
|
42
42
|
includeLayoutNoise?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Render the opt-in content layer (default OFF): a separate, ADVISORY section
|
|
45
|
+
* listing elements whose own text changed, each with a before/after crop.
|
|
46
|
+
* Requires captures taken with `captureText: true`; otherwise there's no text
|
|
47
|
+
* to diff and the section is empty. Never affects `changedSurfaces`,
|
|
48
|
+
* `totalFindings`, or the exit code — StyleProof stays computed-styles-first;
|
|
49
|
+
* this only surfaces copy changes (and any silent overflow/clipping they
|
|
50
|
+
* cause) for the reviewer's eye.
|
|
51
|
+
*/
|
|
52
|
+
includeContent?: boolean;
|
|
43
53
|
};
|
|
44
54
|
export type ReportResult = {
|
|
45
55
|
/** Surfaces carrying a reviewable change (excludes new, one-sided surfaces). */
|
|
@@ -47,6 +57,8 @@ export type ReportResult = {
|
|
|
47
57
|
/** New surfaces present on only one side, with no baseline to compare. */
|
|
48
58
|
newSurfaces: number;
|
|
49
59
|
totalFindings: number;
|
|
60
|
+
/** Advisory content-layer changes rendered (0 unless includeContent + captured text). Never gates. */
|
|
61
|
+
contentChanges: number;
|
|
50
62
|
reportMdPath: string;
|
|
51
63
|
reportJsonPath: string;
|
|
52
64
|
};
|
package/dist/report.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
4
|
import { loadStyleMap } from './capture.js';
|
|
5
|
-
import { diffStyleMapDirs } from './diff.js';
|
|
5
|
+
import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
|
|
6
6
|
import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
|
|
7
7
|
// Re-export the plain-English summariser so consumers (and tests) reach it
|
|
8
8
|
// through the package's report module rather than a deep path.
|
|
@@ -620,6 +620,92 @@ function buildElementChanges(findings) {
|
|
|
620
620
|
}
|
|
621
621
|
return els;
|
|
622
622
|
}
|
|
623
|
+
/** One-line, backtick-safe display text, clipped so the report stays scannable. */
|
|
624
|
+
function clipText(s, max = 200) {
|
|
625
|
+
const t = s.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
|
|
626
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t;
|
|
627
|
+
}
|
|
628
|
+
/** An element's padded box on one side, or null when it has no visible rect. */
|
|
629
|
+
function paddedRect(entry, padBy) {
|
|
630
|
+
if (!entry?.rect)
|
|
631
|
+
return null;
|
|
632
|
+
const b = pad(rectToBox(entry.rect), padBy);
|
|
633
|
+
return visible(b) ? b : null;
|
|
634
|
+
}
|
|
635
|
+
/** Crop box for a content change: the union of where the element sits on each
|
|
636
|
+
* side (so the pair lines up), or null if it's not visible anywhere. */
|
|
637
|
+
function contentBox(mapA, mapB, p, padBy) {
|
|
638
|
+
const ba = paddedRect(mapA.elements[p], padBy);
|
|
639
|
+
const bb = paddedRect(mapB.elements[p], padBy);
|
|
640
|
+
if (ba && bb)
|
|
641
|
+
return union(ba, bb);
|
|
642
|
+
return bb ?? ba;
|
|
643
|
+
}
|
|
644
|
+
/** before|after crop lines for one content change, or [] when there's no box or
|
|
645
|
+
* no screenshots. Writes the composite PNG as a side effect. */
|
|
646
|
+
function contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq) {
|
|
647
|
+
const box = contentBox(mapA, mapB, c.path, ctx.padBy);
|
|
648
|
+
if (!box || !pngA || !pngB)
|
|
649
|
+
return [];
|
|
650
|
+
const w = Math.max(ctx.minWidth, box.w);
|
|
651
|
+
const h = Math.min(ctx.maxHeight, Math.max(ctx.minHeight, box.h));
|
|
652
|
+
const composite = compositePair(cropPng(pngA, box, w, h).png, cropPng(pngB, box, w, h).png);
|
|
653
|
+
const stem = `crops/${surface.replace(/[^a-z0-9-]/gi, '-')}-content-${seq}`;
|
|
654
|
+
writePng(path.join(ctx.outDir, `${stem}-composite.png`), composite);
|
|
655
|
+
return [
|
|
656
|
+
'',
|
|
657
|
+
`})`,
|
|
658
|
+
'',
|
|
659
|
+
`<sub>◀ before · after ▶ — ${surface}</sub>`,
|
|
660
|
+
];
|
|
661
|
+
}
|
|
662
|
+
/** One surface's content block: heading, then per change the before/after text
|
|
663
|
+
* and its crop. Returns the markdown plus the advanced crop counter. */
|
|
664
|
+
function renderContentSurface(ctx, surface, changes, seq) {
|
|
665
|
+
const mapA = loadStyleMap(findCapture(ctx.beforeDir, surface));
|
|
666
|
+
const mapB = loadStyleMap(findCapture(ctx.afterDir, surface));
|
|
667
|
+
const pngA = readPng(path.join(ctx.beforeDir, `${surface}.png`));
|
|
668
|
+
const pngB = readPng(path.join(ctx.afterDir, `${surface}.png`));
|
|
669
|
+
const md = ['', `### \`${surface}\` · ${changes.length} content change(s)`];
|
|
670
|
+
for (const c of changes) {
|
|
671
|
+
seq++;
|
|
672
|
+
md.push('', `**\`${prettyLabel(c.path, c.cls)}\`**`, '', `- before: \`${clipText(c.before) || '(empty)'}\``, `- after: \`${clipText(c.after) || '(empty)'}\``, ...contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq));
|
|
673
|
+
}
|
|
674
|
+
return { md, seq };
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The opt-in content layer, rendered as its own ADVISORY section. Reuses the
|
|
678
|
+
* style report's crop/composite machinery so every copy change gets a
|
|
679
|
+
* before/after screenshot — the whole point being to make a silent text change,
|
|
680
|
+
* and any overflow or clipping it triggers, visible in review. Returns the
|
|
681
|
+
* markdown plus a count; the caller keeps both out of the gate (counts/exit live
|
|
682
|
+
* on the computed-style path).
|
|
683
|
+
*/
|
|
684
|
+
function renderContentSection(ctx) {
|
|
685
|
+
const { surfaces, count } = diffContentDirs(ctx.beforeDir, ctx.afterDir);
|
|
686
|
+
if (!count)
|
|
687
|
+
return { md: [], count: 0 };
|
|
688
|
+
const md = [
|
|
689
|
+
'',
|
|
690
|
+
'---',
|
|
691
|
+
'',
|
|
692
|
+
'## 📝 Content changes (advisory)',
|
|
693
|
+
'',
|
|
694
|
+
`_${count} element(s) changed their own text. **Advisory only** — content is not part of the computed-style ` +
|
|
695
|
+
`certification and does not affect the check. Surfaced so a copy change the style diff can't see (and any ` +
|
|
696
|
+
`overflow or clipping it causes) is visible in review._`,
|
|
697
|
+
];
|
|
698
|
+
let seq = 0;
|
|
699
|
+
for (const { surface, changes } of surfaces) {
|
|
700
|
+
const out = renderContentSurface(ctx, surface, changes, seq);
|
|
701
|
+
md.push(...out.md);
|
|
702
|
+
seq = out.seq;
|
|
703
|
+
}
|
|
704
|
+
return { md, count };
|
|
705
|
+
}
|
|
706
|
+
// Pre-existing, grandfathered in the health baseline; the content layer is
|
|
707
|
+
// rendered by extracted helpers, this only gained a call + headline branch.
|
|
708
|
+
// fallow-ignore-next-line complexity
|
|
623
709
|
export function generateStyleMapReport(opts) {
|
|
624
710
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '',
|
|
625
711
|
// Tighter than before (was 24) so the change fills the frame — the annotation
|
|
@@ -629,6 +715,7 @@ export function generateStyleMapReport(opts) {
|
|
|
629
715
|
// own focused frame rather than one wide merged one.
|
|
630
716
|
maxCrops = 8, foldDetailsAt = 0, } = opts;
|
|
631
717
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
718
|
+
const includeContent = opts.includeContent ?? false;
|
|
632
719
|
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
633
720
|
fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
|
|
634
721
|
// Focus each surface on styling intent: drop reflow-casualty props, suppress
|
|
@@ -680,9 +767,16 @@ export function generateStyleMapReport(opts) {
|
|
|
680
767
|
const md = [];
|
|
681
768
|
const json = [];
|
|
682
769
|
const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
|
|
770
|
+
// Opt-in, advisory: computed here so its count can colour the headline, but its
|
|
771
|
+
// markdown is appended at the very end and it NEVER feeds the gate below.
|
|
772
|
+
const contentSection = includeContent
|
|
773
|
+
? renderContentSection({ beforeDir, afterDir, outDir, img, padBy, minWidth, minHeight, maxHeight })
|
|
774
|
+
: { md: [], count: 0 };
|
|
683
775
|
md.push('## 🗺️ StyleProof report', '');
|
|
684
776
|
if (changeGroups.length === 0 && missing.length === 0) {
|
|
685
|
-
md.push(
|
|
777
|
+
md.push(contentSection.count > 0
|
|
778
|
+
? '✓ Computed styles identical: every longhand, pseudo-element, and hover/focus/active state matches. See the advisory content changes below.'
|
|
779
|
+
: '✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
|
|
686
780
|
}
|
|
687
781
|
else {
|
|
688
782
|
if (changeGroups.length > 0) {
|
|
@@ -698,6 +792,9 @@ export function generateStyleMapReport(opts) {
|
|
|
698
792
|
if (volatileCount > 0) {
|
|
699
793
|
md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check._`);
|
|
700
794
|
}
|
|
795
|
+
if (contentSection.count > 0 && (changeGroups.length > 0 || missing.length > 0)) {
|
|
796
|
+
md.push('', `📝 _${contentSection.count} advisory content change(s) below — they don't affect the check._`);
|
|
797
|
+
}
|
|
701
798
|
let totalFindings = 0;
|
|
702
799
|
let cropSeq = 0;
|
|
703
800
|
for (const cg of changeGroups) {
|
|
@@ -808,6 +905,7 @@ export function generateStyleMapReport(opts) {
|
|
|
808
905
|
md.push('', `_No baseline to compare against — this surface is new, so it doesn't block the check. It becomes part of the baseline once this merges._`);
|
|
809
906
|
json.push(surfaceJson);
|
|
810
907
|
}
|
|
908
|
+
md.push(...contentSection.md);
|
|
811
909
|
const reportMdPath = path.join(outDir, 'report.md');
|
|
812
910
|
const reportJsonPath = path.join(outDir, 'report.json');
|
|
813
911
|
fs.writeFileSync(reportMdPath, md.join('\n') + '\n');
|
|
@@ -816,6 +914,7 @@ export function generateStyleMapReport(opts) {
|
|
|
816
914
|
changedSurfaces: prepared.length - missing.length,
|
|
817
915
|
newSurfaces: missing.length,
|
|
818
916
|
totalFindings,
|
|
917
|
+
contentChanges: contentSection.count,
|
|
819
918
|
reportMdPath,
|
|
820
919
|
reportJsonPath,
|
|
821
920
|
};
|
package/dist/runner.d.ts
CHANGED
|
@@ -64,7 +64,29 @@ export type DefineOptions = {
|
|
|
64
64
|
* as a phantom change on an unrelated diff. Default from STYLEPROOF_SELFCHECK=1.
|
|
65
65
|
*/
|
|
66
66
|
selfCheck?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Opt-in content layer (default OFF). Record each element's own rendered text
|
|
69
|
+
* so the report's optional content section can surface copy changes (run
|
|
70
|
+
* `styleproof-report --include-content`). Advisory only — never gates. See
|
|
71
|
+
* `CaptureOptions.captureText` and the README's "Optional: content layer".
|
|
72
|
+
*/
|
|
73
|
+
captureText?: boolean;
|
|
67
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
77
|
+
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
78
|
+
* captures at most a truncated body, and on replay the connection aborts, so the
|
|
79
|
+
* app drops to its no-stream fallback — a DIFFERENT but STABLE state that
|
|
80
|
+
* settle/volatile can't catch (it isn't moving, so it reads as a real change).
|
|
81
|
+
* Passing the stream through on BOTH record and replay keeps both sides in the
|
|
82
|
+
* same streamed state; the data it pushes must be deterministic at capture time
|
|
83
|
+
* (fixtures/frozen clock), same as any live region. Detected by the
|
|
84
|
+
* `Accept: text/event-stream` header EventSource always sends.
|
|
85
|
+
*
|
|
86
|
+
* Registered AFTER routeFromHAR so it matches first (Playwright runs the most
|
|
87
|
+
* recently added route first); non-stream requests `fallback()` to the HAR.
|
|
88
|
+
*/
|
|
89
|
+
export declare function passLiveStreams(page: Page, url: string): Promise<void>;
|
|
68
90
|
/**
|
|
69
91
|
* Generate one Playwright test per surface × width that captures the style
|
|
70
92
|
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
@@ -78,4 +100,4 @@ export type DefineOptions = {
|
|
|
78
100
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
79
101
|
* ```
|
|
80
102
|
*/
|
|
81
|
-
export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, }: DefineOptions): void;
|
|
103
|
+
export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
|
package/dist/runner.js
CHANGED
|
@@ -10,18 +10,42 @@ function driftDesc(f) {
|
|
|
10
10
|
const p = f.props[0];
|
|
11
11
|
return p ? `${f.path} ${p.prop}: ${p.before} → ${p.after}` : f.path;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
15
|
+
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
16
|
+
* captures at most a truncated body, and on replay the connection aborts, so the
|
|
17
|
+
* app drops to its no-stream fallback — a DIFFERENT but STABLE state that
|
|
18
|
+
* settle/volatile can't catch (it isn't moving, so it reads as a real change).
|
|
19
|
+
* Passing the stream through on BOTH record and replay keeps both sides in the
|
|
20
|
+
* same streamed state; the data it pushes must be deterministic at capture time
|
|
21
|
+
* (fixtures/frozen clock), same as any live region. Detected by the
|
|
22
|
+
* `Accept: text/event-stream` header EventSource always sends.
|
|
23
|
+
*
|
|
24
|
+
* Registered AFTER routeFromHAR so it matches first (Playwright runs the most
|
|
25
|
+
* recently added route first); non-stream requests `fallback()` to the HAR.
|
|
26
|
+
*/
|
|
27
|
+
export async function passLiveStreams(page, url) {
|
|
28
|
+
await page.route(url, async (route) => {
|
|
29
|
+
if ((route.request().headers()['accept'] ?? '').includes('text/event-stream'))
|
|
30
|
+
await route.continue();
|
|
31
|
+
else
|
|
32
|
+
await route.fallback();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
13
35
|
/**
|
|
14
36
|
* Pin the surface's inputs: replay the baseline's recorded data (or record ours),
|
|
15
37
|
* scoped to the data URLs so the app's own JS/CSS still load live, then freeze the
|
|
16
38
|
* clock so time-derived styling is stable.
|
|
17
39
|
*/
|
|
18
40
|
async function pinInputs(page, harName, s) {
|
|
41
|
+
let intercepting = false;
|
|
19
42
|
if (s.replayFrom) {
|
|
20
43
|
const har = path.join(s.replayFrom, harName);
|
|
21
44
|
if (fs.existsSync(har)) {
|
|
22
45
|
// notFound:'abort' — a request the baseline never recorded fails
|
|
23
46
|
// deterministically rather than silently hitting the live backend.
|
|
24
47
|
await page.routeFromHAR(har, { url: s.replayUrl, update: false, notFound: 'abort' });
|
|
48
|
+
intercepting = true;
|
|
25
49
|
}
|
|
26
50
|
else {
|
|
27
51
|
// eslint-disable-next-line no-console
|
|
@@ -34,14 +58,20 @@ async function pinInputs(page, harName, s) {
|
|
|
34
58
|
update: true,
|
|
35
59
|
updateContent: 'embed', // single portable file, no sidecar resources
|
|
36
60
|
});
|
|
61
|
+
intercepting = true;
|
|
37
62
|
}
|
|
63
|
+
// Streams can't be HAR'd; let them reach the live server so replay doesn't
|
|
64
|
+
// abort them into the app's no-stream fallback (a phantom diff). Only when a
|
|
65
|
+
// HAR route is active — otherwise everything is already live.
|
|
66
|
+
if (intercepting)
|
|
67
|
+
await passLiveStreams(page, s.replayUrl);
|
|
38
68
|
if (s.freezeClock)
|
|
39
69
|
await page.clock.setFixedTime(new Date(s.clockTime));
|
|
40
70
|
}
|
|
41
71
|
/** Capture the surface again and throw if the computed styles drifted from `first`. */
|
|
42
|
-
async function assertDeterministic(page, surface, first) {
|
|
72
|
+
async function assertDeterministic(page, surface, first, captureText) {
|
|
43
73
|
await surface.go(page);
|
|
44
|
-
const again = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
|
|
74
|
+
const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText });
|
|
45
75
|
const drift = diffStyleMaps(first, again);
|
|
46
76
|
if (drift.length) {
|
|
47
77
|
throw new Error(`styleproof self-check failed: ${surface.key} is non-deterministic — ` +
|
|
@@ -57,9 +87,9 @@ async function captureSurface(page, surface, width, s) {
|
|
|
57
87
|
const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
|
|
58
88
|
await page.setViewportSize({ width, height });
|
|
59
89
|
await surface.go(page);
|
|
60
|
-
const map = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
|
|
90
|
+
const map = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText: s.captureText });
|
|
61
91
|
if (s.selfCheck)
|
|
62
|
-
await assertDeterministic(page, surface, map);
|
|
92
|
+
await assertDeterministic(page, surface, map, s.captureText);
|
|
63
93
|
const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
|
|
64
94
|
saveStyleMap(`${stem}.json.gz`, map);
|
|
65
95
|
if (s.screenshots) {
|
|
@@ -81,7 +111,7 @@ async function captureSurface(page, surface, width, s) {
|
|
|
81
111
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
82
112
|
* ```
|
|
83
113
|
*/
|
|
84
|
-
export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = process.env.STYLEPROOF_SELFCHECK === '1', }) {
|
|
114
|
+
export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = process.env.STYLEPROOF_SELFCHECK === '1', captureText = false, }) {
|
|
85
115
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
86
116
|
const settings = {
|
|
87
117
|
dir: dir,
|
|
@@ -92,6 +122,7 @@ export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__'
|
|
|
92
122
|
freezeClock,
|
|
93
123
|
clockTime,
|
|
94
124
|
selfCheck,
|
|
125
|
+
captureText,
|
|
95
126
|
};
|
|
96
127
|
test.describe('styleproof capture', () => {
|
|
97
128
|
for (const surface of surfaces) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|