styleproof 1.9.0 → 1.9.3
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 +23 -0
- package/README.md +27 -1
- package/dist/capture.d.ts +5 -3
- package/dist/capture.js +72 -37
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.9.3]
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
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
|
+
- Canonical `StyleProof`-cased GitHub URLs in `package.json` (`homepage`,
|
|
19
|
+
`repository`, `bugs`).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- The README demo image now uses an absolute `raw.githubusercontent.com` URL so it
|
|
24
|
+
renders on the npm package page — relative paths don't resolve there, so the image
|
|
25
|
+
showed broken on npmjs.com.
|
|
26
|
+
|
|
27
|
+
## [1.9.1]
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **Framework / non-visual DOM noise no longer registers as a change.** Capture now skips a built-in default set of selectors — `<meta>`, `<title>`, `<link>`, `<script>`, `<style>`, `<base>`, `<noscript>`, `<template>`, and `next-route-announcer` — merged into (not replaced by) the caller's `ignore`. These are elements frameworks stream into the body and reorder (Next.js app-router injects metadata then hoists it) or inject as live regions (Next's a11y route announcer), with no visual box to diff; their churn was surfacing as phantom DOM-added/removed findings on PRs that changed no CSS. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. _Note: this changes the captured element set slightly — re-baseline once after upgrading._
|
|
32
|
+
|
|
10
33
|
## [1.9.0]
|
|
11
34
|
|
|
12
35
|
### Added
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base br
|
|
|
23
23
|
|
|
24
24
|
One change — the hero CTA recoloured cyan → amber — posts as a single section: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
|
|
25
25
|
|
|
26
|
-

|
|
26
|
+

|
|
27
27
|
|
|
28
28
|
As it renders in the PR comment (a plain-English bullet first — naming the theme token and showing the hex with a live colour swatch — then the exact table inside the toggle):
|
|
29
29
|
|
|
@@ -127,6 +127,7 @@ jobs:
|
|
|
127
127
|
- **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`.
|
|
128
128
|
- **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
|
|
129
129
|
- **Self-check** (`STYLEPROOF_SELFCHECK=1`). Captures each surface twice and fails if they differ — a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR.
|
|
130
|
+
- **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
|
|
130
131
|
|
|
131
132
|
> Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
|
|
132
133
|
|
|
@@ -146,6 +147,31 @@ jobs:
|
|
|
146
147
|
|
|
147
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/styleproof/blob/main/action.yml).
|
|
148
149
|
|
|
150
|
+
**Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
|
|
151
|
+
|
|
152
|
+
| Option | Default | Purpose |
|
|
153
|
+
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
154
|
+
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths, ignore?, height? }`. `go(page)` drives to a settled state. |
|
|
155
|
+
| `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
|
|
156
|
+
| `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
|
|
157
|
+
| `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
|
|
158
|
+
| `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
|
|
159
|
+
| `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
|
|
160
|
+
| `selfCheck` | `STYLEPROOF_SELFCHECK=1` | Capture each surface twice and fail on any difference — proves the capture is deterministic. |
|
|
161
|
+
| `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
|
|
162
|
+
| `baseDir` | `__stylemaps__` | Output root directory. |
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
166
|
+
**Capture env vars** (wire CI without editing the spec):
|
|
167
|
+
|
|
168
|
+
| Env | Purpose |
|
|
169
|
+
| ------------------------ | ----------------------------------------------------------------------------- |
|
|
170
|
+
| `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
|
|
171
|
+
| `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from — set this on the **head** capture. |
|
|
172
|
+
| `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
|
|
173
|
+
| `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
|
|
174
|
+
|
|
149
175
|
**CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
|
|
150
176
|
|
|
151
177
|
- `styleproof-init` — scaffold the capture spec (and a starter `playwright.config.ts` if none exists).
|
package/dist/capture.d.ts
CHANGED
|
@@ -65,9 +65,11 @@ export type StyleMap = {
|
|
|
65
65
|
export type CaptureOptions = {
|
|
66
66
|
/**
|
|
67
67
|
* Selectors for nondeterministic regions (live data, embeds, ads). The
|
|
68
|
-
* matching elements and their descendants are skipped entirely.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* matching elements and their descendants are skipped entirely. Added to a
|
|
69
|
+
* built-in default that skips framework/non-visual noise (`<meta>`/`<title>`/
|
|
70
|
+
* `<script>`/`<style>`/… and `next-route-announcer`), so you rarely need it —
|
|
71
|
+
* `stabilize` also auto-detects live regions. Use it to skip a region you know
|
|
72
|
+
* is volatile without paying the settle wait for it.
|
|
71
73
|
*/
|
|
72
74
|
ignore?: string[];
|
|
73
75
|
/**
|
package/dist/capture.js
CHANGED
|
@@ -5,6 +5,26 @@ const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"
|
|
|
5
5
|
// Freeze motion so every captured value is a settled end state, not a frame
|
|
6
6
|
// of an animation or a mid-flight transition after a forced :hover.
|
|
7
7
|
const FREEZE_CSS = '*,*::before,*::after{animation:none!important;transition:none!important}';
|
|
8
|
+
// Always skipped, merged into the caller's `ignore`. Two kinds of noise that
|
|
9
|
+
// are never a visual change worth gating on but churn the DOM between runs:
|
|
10
|
+
// - non-rendered elements frameworks stream into <body> then hoist (Next.js
|
|
11
|
+
// app-router injects <meta>/<title>/<link>); they have no box to style, and
|
|
12
|
+
// their presence/order is nondeterministic. A real stylesheet change still
|
|
13
|
+
// shows up in the affected elements' computed styles, not in the <style> tag.
|
|
14
|
+
// - framework-injected live regions / overlays that mount and reorder on their
|
|
15
|
+
// own (Next.js's a11y route announcer — already a known source of CDP skew).
|
|
16
|
+
const FRAMEWORK_IGNORE = [
|
|
17
|
+
'meta',
|
|
18
|
+
'title',
|
|
19
|
+
'link',
|
|
20
|
+
'script',
|
|
21
|
+
'style',
|
|
22
|
+
'base',
|
|
23
|
+
'noscript',
|
|
24
|
+
'template',
|
|
25
|
+
'next-route-announcer',
|
|
26
|
+
'[id="__next-route-announcer__"]',
|
|
27
|
+
];
|
|
8
28
|
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
9
29
|
* the capture (excluding live regions) and the diff (skipping them). */
|
|
10
30
|
export function isUnder(path, roots) {
|
|
@@ -328,6 +348,51 @@ function capturePageTokens() {
|
|
|
328
348
|
probe.remove();
|
|
329
349
|
return tokens;
|
|
330
350
|
}
|
|
351
|
+
/** Settle the page and return the paths of live regions to exclude. */
|
|
352
|
+
async function detectVolatile(page, ignore, stabilize) {
|
|
353
|
+
if (stabilize === false)
|
|
354
|
+
return [];
|
|
355
|
+
const opt = typeof stabilize === 'object' ? stabilize : {};
|
|
356
|
+
const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000);
|
|
357
|
+
if (volatile.length) {
|
|
358
|
+
// eslint-disable-next-line no-console
|
|
359
|
+
console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
|
|
360
|
+
'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
|
|
361
|
+
'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
|
|
362
|
+
}
|
|
363
|
+
return volatile;
|
|
364
|
+
}
|
|
365
|
+
/** Drop live regions (and their subtrees) from the base capture, in Node so the
|
|
366
|
+
* serialized capturePage stays a pure snapshot. */
|
|
367
|
+
function dropVolatile(elements, volatile) {
|
|
368
|
+
if (!volatile.length)
|
|
369
|
+
return;
|
|
370
|
+
for (const p of Object.keys(elements))
|
|
371
|
+
if (isUnder(p, volatile))
|
|
372
|
+
delete elements[p];
|
|
373
|
+
}
|
|
374
|
+
/** Warn once when shadow roots / iframes were skipped (their styles aren't captured or diffed). */
|
|
375
|
+
function warnUntraversed(shadowHosts, sameOriginFrames) {
|
|
376
|
+
if (!shadowHosts && !sameOriginFrames)
|
|
377
|
+
return;
|
|
378
|
+
// eslint-disable-next-line no-console
|
|
379
|
+
console.warn(`styleproof: ${shadowHosts} shadow host(s) and ${sameOriginFrames} same-origin iframe(s) were ` +
|
|
380
|
+
'NOT traversed — styles inside shadow roots and frames are not captured or diffed. A refactor inside ' +
|
|
381
|
+
'one would be reported as identical. See README "Limitations".');
|
|
382
|
+
}
|
|
383
|
+
/** Fold the pre-freeze motion longhands back onto the settled base capture. */
|
|
384
|
+
function mergeMotion(elements, motion) {
|
|
385
|
+
for (const [p, entry] of Object.entries(elements)) {
|
|
386
|
+
const m = motion[p];
|
|
387
|
+
if (!m)
|
|
388
|
+
continue;
|
|
389
|
+
Object.assign(entry.style, m.style);
|
|
390
|
+
for (const [ps, props] of Object.entries(m.pseudo ?? {})) {
|
|
391
|
+
if (entry.pseudo?.[ps])
|
|
392
|
+
Object.assign(entry.pseudo[ps], props);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
331
396
|
/**
|
|
332
397
|
* Capture the page's complete style map. Drive the page to the state you want
|
|
333
398
|
* first (navigate, open menus); by default the capture then auto-settles the
|
|
@@ -335,7 +400,9 @@ function capturePageTokens() {
|
|
|
335
400
|
* paints after `go()` resolves is captured loaded, not mid-load.
|
|
336
401
|
*/
|
|
337
402
|
export async function captureStyleMap(page, options = {}) {
|
|
338
|
-
|
|
403
|
+
// Framework/non-visual noise is always skipped, so it can't read as a DOM
|
|
404
|
+
// change; the caller's `ignore` adds to it (not replaces it).
|
|
405
|
+
const ignore = [...FRAMEWORK_IGNORE, ...(options.ignore ?? [])];
|
|
339
406
|
const captureStates = options.captureStates ?? true;
|
|
340
407
|
const maxInteractive = options.maxInteractive ?? 800;
|
|
341
408
|
const stabilize = options.stabilize ?? true;
|
|
@@ -346,43 +413,11 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
346
413
|
// the same loaded state, and collect any region still changing on its own
|
|
347
414
|
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
348
415
|
// real content/layout churn lands here.
|
|
349
|
-
|
|
350
|
-
if (stabilize !== false) {
|
|
351
|
-
const opt = typeof stabilize === 'object' ? stabilize : {};
|
|
352
|
-
const interval = opt.interval || 150;
|
|
353
|
-
const quietFor = opt.quietFor || 600;
|
|
354
|
-
const timeout = opt.timeout || 5000;
|
|
355
|
-
volatile = await stabilizePage(page, ignore, interval, quietFor, timeout);
|
|
356
|
-
if (volatile.length) {
|
|
357
|
-
// eslint-disable-next-line no-console
|
|
358
|
-
console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
|
|
359
|
-
'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
|
|
360
|
-
'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
|
|
361
|
-
}
|
|
362
|
-
}
|
|
416
|
+
const volatile = await detectVolatile(page, ignore, stabilize);
|
|
363
417
|
const base = await page.evaluate(capturePage, { ignore, motionOnly: false });
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
for (const p of Object.keys(base.elements))
|
|
368
|
-
if (isUnder(p, volatile))
|
|
369
|
-
delete base.elements[p];
|
|
370
|
-
if (base.shadowHosts || base.sameOriginFrames) {
|
|
371
|
-
// eslint-disable-next-line no-console
|
|
372
|
-
console.warn(`styleproof: ${base.shadowHosts} shadow host(s) and ${base.sameOriginFrames} same-origin iframe(s) were ` +
|
|
373
|
-
'NOT traversed — styles inside shadow roots and frames are not captured or diffed. A refactor inside ' +
|
|
374
|
-
'one would be reported as identical. See README "Limitations".');
|
|
375
|
-
}
|
|
376
|
-
for (const [p, entry] of Object.entries(base.elements)) {
|
|
377
|
-
const m = motion.elements[p];
|
|
378
|
-
if (!m)
|
|
379
|
-
continue;
|
|
380
|
-
Object.assign(entry.style, m.style);
|
|
381
|
-
for (const [ps, props] of Object.entries(m.pseudo ?? {})) {
|
|
382
|
-
if (entry.pseudo?.[ps])
|
|
383
|
-
Object.assign(entry.pseudo[ps], props);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
418
|
+
dropVolatile(base.elements, volatile);
|
|
419
|
+
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
|
420
|
+
mergeMotion(base.elements, motion.elements);
|
|
386
421
|
let states = {};
|
|
387
422
|
let statesSkipped = false;
|
|
388
423
|
if (captureStates) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
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",
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
"ci"
|
|
17
17
|
],
|
|
18
18
|
"license": "MIT",
|
|
19
|
-
"author": "Ben Sheridan-Edwards
|
|
20
|
-
"homepage": "https://github.com/BenSheridanEdwards/
|
|
19
|
+
"author": "Ben Sheridan-Edwards",
|
|
20
|
+
"homepage": "https://github.com/BenSheridanEdwards/StyleProof#readme",
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/BenSheridanEdwards/
|
|
23
|
+
"url": "git+https://github.com/BenSheridanEdwards/StyleProof.git"
|
|
24
24
|
},
|
|
25
25
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/BenSheridanEdwards/
|
|
26
|
+
"url": "https://github.com/BenSheridanEdwards/StyleProof/issues"
|
|
27
27
|
},
|
|
28
28
|
"type": "module",
|
|
29
29
|
"main": "./dist/index.js",
|