styleproof 1.4.0 → 1.5.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 +31 -0
- package/README.md +2 -0
- package/dist/capture.d.ts +40 -4
- package/dist/capture.js +79 -6
- package/dist/diff.d.ts +3 -1
- package/dist/diff.js +30 -12
- package/dist/report.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.0]
|
|
11
|
+
|
|
12
|
+
Live regions are handled automatically, so a dashboard with streaming data,
|
|
13
|
+
tickers, or late-loading content no longer produces a false "everything
|
|
14
|
+
changed" report.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Auto-settle before capture (`stabilize`, default on).** The capture now
|
|
19
|
+
polls the (motion-frozen) page until its computed-style map has been unchanged
|
|
20
|
+
for a quiet window, so content that paints _after_ `go()` resolves — an async
|
|
21
|
+
fetch, an SSE/WebSocket stream backfilling a grid — is captured loaded, not
|
|
22
|
+
mid-load. This is what fixes the most common false positive: base and head
|
|
23
|
+
racing an async load and diffing empty-state-vs-populated. Requiring a
|
|
24
|
+
_sustained_ no-change window (not a single quiet sample) is what lets it wait
|
|
25
|
+
through the gap before late content appears.
|
|
26
|
+
- **Automatic live-region detection.** Anything still changing on its own when
|
|
27
|
+
the settle budget runs out is, by definition, nondeterministic (it mutates
|
|
28
|
+
with no code change). Those element paths are recorded in `StyleMap.volatile`
|
|
29
|
+
and excluded from the diff — unioned across both sides — so a stream or ticker
|
|
30
|
+
never reads as a change, with no manual `ignore`. The report notes how many
|
|
31
|
+
live regions were auto-excluded. Tune or disable via
|
|
32
|
+
`captureStyleMap(page, { stabilize })` (`false`, or `{ interval, quietFor, timeout }`).
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `diffStyleMapDirs` now returns a `volatile` count alongside `surfaces`/`counts`.
|
|
37
|
+
- Text-only churn (a clock, "2m ago") still never matters — the diff has always
|
|
38
|
+
compared computed style, not text; this release adds the structural/layout
|
|
39
|
+
determinism to match.
|
|
40
|
+
|
|
10
41
|
## [1.4.0]
|
|
11
42
|
|
|
12
43
|
New surfaces (present on only one side, no baseline to diff) are shown for
|
package/README.md
CHANGED
|
@@ -123,6 +123,8 @@ jobs:
|
|
|
123
123
|
|
|
124
124
|
> Capture both sides in the **same environment** (same machine, same env vars): if env vars change _what renders_, base and head will diff on DOM no PR touched.
|
|
125
125
|
|
|
126
|
+
**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 } }`.
|
|
127
|
+
|
|
126
128
|
## Reference
|
|
127
129
|
|
|
128
130
|
**Action `BenSheridanEdwards/styleproof@v1`** — key inputs:
|
package/dist/capture.d.ts
CHANGED
|
@@ -45,13 +45,45 @@ export type StyleMap = {
|
|
|
45
45
|
* certified, instead of silently reading as "identical".
|
|
46
46
|
*/
|
|
47
47
|
statesSkipped?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Element paths detected as LIVE at capture time — they kept changing on
|
|
50
|
+
* their own (after motion was frozen) until the settle budget ran out, so
|
|
51
|
+
* they're nondeterministic. Excluded from `elements`/`states`/`defaults`
|
|
52
|
+
* here, and skipped by the diff (which unions both sides' volatile sets) so a
|
|
53
|
+
* stream/ticker never reads as a change. Empty/absent on a fully-settled page.
|
|
54
|
+
*/
|
|
55
|
+
volatile?: string[];
|
|
48
56
|
};
|
|
49
57
|
export type CaptureOptions = {
|
|
50
58
|
/**
|
|
51
59
|
* Selectors for nondeterministic regions (live data, embeds, ads). The
|
|
52
|
-
* matching elements and their descendants are skipped entirely.
|
|
60
|
+
* matching elements and their descendants are skipped entirely. Usually
|
|
61
|
+
* unnecessary now that `stabilize` auto-detects live regions; use it to skip
|
|
62
|
+
* a region you know is volatile without paying the settle wait for it.
|
|
53
63
|
*/
|
|
54
64
|
ignore?: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Settle the page before capturing, and auto-exclude live regions (default
|
|
67
|
+
* on). StyleProof polls the (motion-frozen) page until its computed-style map
|
|
68
|
+
* stops changing — so async content that paints AFTER `go()` resolves (a
|
|
69
|
+
* fetch, an SSE/WebSocket stream) is captured in its loaded state, not
|
|
70
|
+
* mid-load. Any region still changing when the budget runs out is a live
|
|
71
|
+
* region by definition (it mutates with no code change); its paths are
|
|
72
|
+
* recorded in `StyleMap.volatile` and excluded from the diff, so a stream or
|
|
73
|
+
* ticker never reads as a change — no manual `ignore` needed. Text-only churn
|
|
74
|
+
* (a clock, "2m ago") never matters: the diff compares computed style, not
|
|
75
|
+
* text. Pass `false` to capture the exact frame `go()` left, or `{ interval,
|
|
76
|
+
* quietFor, timeout }` (ms) to tune the poll cadence, the no-change window
|
|
77
|
+
* that counts as settled, and the budget. Note: content that first paints
|
|
78
|
+
* after a quiet gap longer than `quietFor` can't be waited for without a
|
|
79
|
+
* signal — settle that in `go()`; anything still moving at `timeout` is
|
|
80
|
+
* treated as a live region.
|
|
81
|
+
*/
|
|
82
|
+
stabilize?: boolean | {
|
|
83
|
+
interval?: number;
|
|
84
|
+
quietFor?: number;
|
|
85
|
+
timeout?: number;
|
|
86
|
+
};
|
|
55
87
|
/**
|
|
56
88
|
* Capture forced :hover/:focus/:active state deltas (default true). This is
|
|
57
89
|
* the expensive layer — O(interactive elements × 3 states) with a subtree
|
|
@@ -66,10 +98,14 @@ export type CaptureOptions = {
|
|
|
66
98
|
*/
|
|
67
99
|
maxInteractive?: number;
|
|
68
100
|
};
|
|
101
|
+
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
102
|
+
* the capture (excluding live regions) and the diff (skipping them). */
|
|
103
|
+
export declare function isUnder(path: string, roots: string[]): boolean;
|
|
69
104
|
/**
|
|
70
|
-
* Capture the page's complete style map. Drive the page to the state you
|
|
71
|
-
*
|
|
72
|
-
*
|
|
105
|
+
* Capture the page's complete style map. Drive the page to the state you want
|
|
106
|
+
* first (navigate, open menus); by default the capture then auto-settles the
|
|
107
|
+
* page and excludes live regions (see `stabilize`), so a fetch/stream that
|
|
108
|
+
* paints after `go()` resolves is captured loaded, not mid-load.
|
|
73
109
|
*/
|
|
74
110
|
export declare function captureStyleMap(page: Page, options?: CaptureOptions): Promise<StyleMap>;
|
|
75
111
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
package/dist/capture.js
CHANGED
|
@@ -5,6 +5,11 @@ 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
|
+
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
9
|
+
* the capture (excluding live regions) and the diff (skipping them). */
|
|
10
|
+
export function isUnder(path, roots) {
|
|
11
|
+
return roots.some((r) => path === r || path.startsWith(r + ' > '));
|
|
12
|
+
}
|
|
8
13
|
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
9
14
|
function capturePage({ ignore, motionOnly }) {
|
|
10
15
|
const MOTION = /^(transition|animation)/;
|
|
@@ -206,14 +211,16 @@ function pathsForSelector({ selector, skipSel }) {
|
|
|
206
211
|
}
|
|
207
212
|
// Forced pseudo-class states on interactive elements, via CDP so no real
|
|
208
213
|
// mouse or focus is involved and parent-state descendant rules still apply.
|
|
209
|
-
async function captureForcedStates(page, ignore, maxInteractive) {
|
|
214
|
+
async function captureForcedStates(page, ignore, maxInteractive, skipPaths = []) {
|
|
210
215
|
const client = await page.context().newCDPSession(page);
|
|
211
216
|
await client.send('DOM.enable');
|
|
212
217
|
await client.send('CSS.enable');
|
|
213
218
|
const { root } = await client.send('DOM.getDocument');
|
|
214
219
|
const { nodeIds } = await client.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: INTERACTIVE });
|
|
215
220
|
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
216
|
-
|
|
221
|
+
// Null out forced-state work for live (volatile) paths too, so they're not
|
|
222
|
+
// probed and can't reintroduce the churn the settle pass just excluded.
|
|
223
|
+
const paths = (await page.evaluate(pathsForSelector, { selector: INTERACTIVE, skipSel })).map((p) => p && skipPaths.length && isUnder(p, skipPaths) ? null : p);
|
|
217
224
|
// The CDP DOM snapshot and the live querySelectorAll are two separate,
|
|
218
225
|
// non-atomic reads. They can legitimately disagree — display:contents,
|
|
219
226
|
// nodes detached or injected between the two calls (next-route-announcer,
|
|
@@ -254,19 +261,84 @@ async function captureForcedStates(page, ignore, maxInteractive) {
|
|
|
254
261
|
await client.detach();
|
|
255
262
|
return { states, skipped: false };
|
|
256
263
|
}
|
|
264
|
+
/** Element paths that differ between two captures (added, removed, or restyled). */
|
|
265
|
+
function changedElementPaths(a, b) {
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const p of new Set([...Object.keys(a), ...Object.keys(b)]))
|
|
268
|
+
if (JSON.stringify(a[p]) !== JSON.stringify(b[p]))
|
|
269
|
+
out.push(p);
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
257
272
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
273
|
+
* Poll the page until its computed-style map has been UNCHANGED for `quietFor`
|
|
274
|
+
* ms (it settled — async content finished painting) or the budget runs out.
|
|
275
|
+
* Requiring a sustained quiet window, not a single quiet sample, is what lets it
|
|
276
|
+
* wait THROUGH the gap before late content paints and through a streaming
|
|
277
|
+
* backfill instead of settling on the first lull. Returns the paths still
|
|
278
|
+
* changing at timeout: genuine LIVE regions, to be excluded from the capture.
|
|
279
|
+
* Reuses `capturePage` (motion already frozen by the caller), so only
|
|
280
|
+
* content/layout churn — not an animation frame — keeps it from settling.
|
|
281
|
+
*/
|
|
282
|
+
async function stabilizePage(page, ignore, interval, quietFor, timeout) {
|
|
283
|
+
const snap = async () => (await page.evaluate(capturePage, { ignore, motionOnly: false })).elements;
|
|
284
|
+
const start = Date.now();
|
|
285
|
+
let prev = await snap();
|
|
286
|
+
let lastChangeAt = start;
|
|
287
|
+
let recent = [];
|
|
288
|
+
while (Date.now() - start < timeout) {
|
|
289
|
+
await page.waitForTimeout(interval);
|
|
290
|
+
const cur = await snap();
|
|
291
|
+
const changed = changedElementPaths(prev, cur);
|
|
292
|
+
prev = cur;
|
|
293
|
+
if (changed.length) {
|
|
294
|
+
lastChangeAt = Date.now();
|
|
295
|
+
recent = changed;
|
|
296
|
+
}
|
|
297
|
+
else if (Date.now() - lastChangeAt >= quietFor) {
|
|
298
|
+
return []; // unchanged for the full quiet window → settled
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return recent; // never went quiet for quietFor within budget → still-moving paths are live
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Capture the page's complete style map. Drive the page to the state you want
|
|
305
|
+
* first (navigate, open menus); by default the capture then auto-settles the
|
|
306
|
+
* page and excludes live regions (see `stabilize`), so a fetch/stream that
|
|
307
|
+
* paints after `go()` resolves is captured loaded, not mid-load.
|
|
261
308
|
*/
|
|
262
309
|
export async function captureStyleMap(page, options = {}) {
|
|
263
310
|
const ignore = options.ignore ?? [];
|
|
264
311
|
const captureStates = options.captureStates ?? true;
|
|
265
312
|
const maxInteractive = options.maxInteractive ?? 800;
|
|
313
|
+
const stabilize = options.stabilize ?? true;
|
|
266
314
|
// Motion longhands first (FREEZE_CSS would null them), then everything else.
|
|
267
315
|
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true });
|
|
268
316
|
await page.addStyleTag({ content: FREEZE_CSS });
|
|
317
|
+
// Settle: wait for async content to finish painting so base and head capture
|
|
318
|
+
// the same loaded state, and collect any region still changing on its own
|
|
319
|
+
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
320
|
+
// real content/layout churn lands here.
|
|
321
|
+
let volatile = [];
|
|
322
|
+
if (stabilize !== false) {
|
|
323
|
+
const opt = typeof stabilize === 'object' ? stabilize : {};
|
|
324
|
+
const interval = opt.interval || 150;
|
|
325
|
+
const quietFor = opt.quietFor || 600;
|
|
326
|
+
const timeout = opt.timeout || 5000;
|
|
327
|
+
volatile = await stabilizePage(page, ignore, interval, quietFor, timeout);
|
|
328
|
+
if (volatile.length) {
|
|
329
|
+
// eslint-disable-next-line no-console
|
|
330
|
+
console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
|
|
331
|
+
'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
|
|
332
|
+
'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
269
335
|
const base = await page.evaluate(capturePage, { ignore, motionOnly: false });
|
|
336
|
+
// Drop live regions (and their subtrees) detected by the settle pass — done
|
|
337
|
+
// here, in Node, so the serialized capturePage stays a pure snapshot.
|
|
338
|
+
if (volatile.length)
|
|
339
|
+
for (const p of Object.keys(base.elements))
|
|
340
|
+
if (isUnder(p, volatile))
|
|
341
|
+
delete base.elements[p];
|
|
270
342
|
if (base.shadowHosts || base.sameOriginFrames) {
|
|
271
343
|
// eslint-disable-next-line no-console
|
|
272
344
|
console.warn(`styleproof: ${base.shadowHosts} shadow host(s) and ${base.sameOriginFrames} same-origin iframe(s) were ` +
|
|
@@ -286,7 +358,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
286
358
|
let states = {};
|
|
287
359
|
let statesSkipped = false;
|
|
288
360
|
if (captureStates) {
|
|
289
|
-
const forced = await captureForcedStates(page, ignore, maxInteractive);
|
|
361
|
+
const forced = await captureForcedStates(page, ignore, maxInteractive, volatile);
|
|
290
362
|
states = forced.states;
|
|
291
363
|
statesSkipped = forced.skipped;
|
|
292
364
|
}
|
|
@@ -295,6 +367,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
295
367
|
elements: base.elements,
|
|
296
368
|
states,
|
|
297
369
|
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
370
|
+
...(volatile.length ? { volatile } : {}),
|
|
298
371
|
};
|
|
299
372
|
}
|
|
300
373
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
package/dist/diff.d.ts
CHANGED
|
@@ -42,10 +42,12 @@ export type DiffCounts = {
|
|
|
42
42
|
};
|
|
43
43
|
/** Diff two style maps of the same surface. */
|
|
44
44
|
export declare function diffStyleMaps(a: StyleMap, b: StyleMap): Finding[];
|
|
45
|
-
/** Diff every same-named capture between two directories.
|
|
45
|
+
/** Diff every same-named capture between two directories. `volatile` is the
|
|
46
|
+
* count of live regions auto-excluded across all surfaces (union per surface). */
|
|
46
47
|
export declare function diffStyleMapDirs(dirA: string, dirB: string): {
|
|
47
48
|
surfaces: SurfaceDiff[];
|
|
48
49
|
counts: DiffCounts;
|
|
50
|
+
volatile: number;
|
|
49
51
|
};
|
|
50
52
|
/** Human label: structural path plus a truncated class hint. */
|
|
51
53
|
export declare function findingLabel(path: string, cls: string): string;
|
package/dist/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { loadStyleMap } from './capture.js';
|
|
3
|
+
import { loadStyleMap, isUnder } from './capture.js';
|
|
4
4
|
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
5
5
|
const changed = [];
|
|
6
6
|
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
@@ -16,7 +16,14 @@ function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
|
16
16
|
/** Diff two style maps of the same surface. */
|
|
17
17
|
export function diffStyleMaps(a, b) {
|
|
18
18
|
const findings = [];
|
|
19
|
+
// Live regions either capture flagged as nondeterministic (a stream, ticker,
|
|
20
|
+
// late-loading content): never diff them — their values move with no code
|
|
21
|
+
// change. Union both sides so a region volatile on only one capture is still
|
|
22
|
+
// skipped, in every layer (element, pseudo, forced-state).
|
|
23
|
+
const volatile = [...new Set([...(a.volatile ?? []), ...(b.volatile ?? [])])];
|
|
19
24
|
for (const p of [...new Set([...Object.keys(a.elements), ...Object.keys(b.elements)])].sort()) {
|
|
25
|
+
if (volatile.length && isUnder(p, volatile))
|
|
26
|
+
continue;
|
|
20
27
|
const ea = a.elements[p];
|
|
21
28
|
const eb = b.elements[p];
|
|
22
29
|
if (!ea || !eb) {
|
|
@@ -62,6 +69,8 @@ export function diffStyleMaps(a, b) {
|
|
|
62
69
|
});
|
|
63
70
|
}
|
|
64
71
|
for (const p of new Set([...Object.keys(a.states ?? {}), ...Object.keys(b.states ?? {})])) {
|
|
72
|
+
if (volatile.length && isUnder(p, volatile))
|
|
73
|
+
continue;
|
|
65
74
|
const sa = a.states?.[p] ?? {};
|
|
66
75
|
const sb = b.states?.[p] ?? {};
|
|
67
76
|
const cls = (a.elements[p] ?? b.elements[p])?.cls ?? '';
|
|
@@ -77,13 +86,25 @@ export function diffStyleMaps(a, b) {
|
|
|
77
86
|
}
|
|
78
87
|
return findings;
|
|
79
88
|
}
|
|
89
|
+
/** Add a surface's findings to the running totals (one DOM/style/state tally). */
|
|
90
|
+
function tallyCounts(findings, counts) {
|
|
91
|
+
for (const f of findings) {
|
|
92
|
+
if (f.kind === 'dom')
|
|
93
|
+
counts.dom++;
|
|
94
|
+
else if (f.kind === 'style')
|
|
95
|
+
counts.style += f.props.length;
|
|
96
|
+
else
|
|
97
|
+
counts.state += f.props.length;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
80
100
|
function indexDir(dir) {
|
|
81
101
|
return Object.fromEntries(fs
|
|
82
102
|
.readdirSync(dir)
|
|
83
103
|
.filter((f) => /\.json(\.gz)?$/.test(f))
|
|
84
104
|
.map((f) => [f.replace(/\.json(\.gz)?$/, ''), path.join(dir, f)]));
|
|
85
105
|
}
|
|
86
|
-
/** Diff every same-named capture between two directories.
|
|
106
|
+
/** Diff every same-named capture between two directories. `volatile` is the
|
|
107
|
+
* count of live regions auto-excluded across all surfaces (union per surface). */
|
|
87
108
|
export function diffStyleMapDirs(dirA, dirB) {
|
|
88
109
|
const indexA = indexDir(dirA);
|
|
89
110
|
const indexB = indexDir(dirB);
|
|
@@ -92,6 +113,7 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
92
113
|
throw new Error(`no .json(.gz) captures found in ${dirA} or ${dirB}`);
|
|
93
114
|
const surfaces = [];
|
|
94
115
|
const counts = { dom: 0, style: 0, state: 0 };
|
|
116
|
+
let volatile = 0;
|
|
95
117
|
for (const surface of names) {
|
|
96
118
|
if (!indexA[surface] || !indexB[surface]) {
|
|
97
119
|
// A surface present on only one side has no baseline to diff against — it's
|
|
@@ -101,19 +123,15 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
101
123
|
surfaces.push({ surface, missing: indexA[surface] ? 'after' : 'before', findings: [] });
|
|
102
124
|
continue;
|
|
103
125
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
counts.style += f.props.length;
|
|
110
|
-
else
|
|
111
|
-
counts.state += f.props.length;
|
|
112
|
-
}
|
|
126
|
+
const mapA = loadStyleMap(indexA[surface]);
|
|
127
|
+
const mapB = loadStyleMap(indexB[surface]);
|
|
128
|
+
volatile += new Set([...(mapA.volatile ?? []), ...(mapB.volatile ?? [])]).size;
|
|
129
|
+
const findings = diffStyleMaps(mapA, mapB);
|
|
130
|
+
tallyCounts(findings, counts);
|
|
113
131
|
if (findings.length)
|
|
114
132
|
surfaces.push({ surface, findings });
|
|
115
133
|
}
|
|
116
|
-
return { surfaces, counts };
|
|
134
|
+
return { surfaces, counts, volatile };
|
|
117
135
|
}
|
|
118
136
|
/** Human label: structural path plus a truncated class hint. */
|
|
119
137
|
export function findingLabel(path, cls) {
|
package/dist/report.js
CHANGED
|
@@ -524,7 +524,7 @@ const stripDerived = (f) => f.kind === 'dom' ? f : { ...f, props: f.props.filter
|
|
|
524
524
|
export function generateStyleMapReport(opts) {
|
|
525
525
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, foldDetailsAt = 0, } = opts;
|
|
526
526
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
527
|
-
const { surfaces } = diffStyleMapDirs(beforeDir, afterDir);
|
|
527
|
+
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
528
528
|
fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
|
|
529
529
|
// Focus each surface on styling intent: drop reflow-casualty elements (only
|
|
530
530
|
// size/position-derived changes) and strip those props from the rest, unless
|
|
@@ -590,6 +590,9 @@ export function generateStyleMapReport(opts) {
|
|
|
590
590
|
`New surfaces don't block the check.`);
|
|
591
591
|
}
|
|
592
592
|
}
|
|
593
|
+
if (volatileCount > 0) {
|
|
594
|
+
md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check._`);
|
|
595
|
+
}
|
|
593
596
|
let totalFindings = 0;
|
|
594
597
|
let cropSeq = 0;
|
|
595
598
|
for (const cg of changeGroups) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|