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 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
- * want first (navigate, open menus, settle fonts/animations) the capture
72
- * reads whatever is in front of it.
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
- const paths = await page.evaluate(pathsForSelector, { selector: INTERACTIVE, skipSel });
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
- * Capture the page's complete style map. Drive the page to the state you
259
- * want first (navigate, open menus, settle fonts/animations) the capture
260
- * reads whatever is in front of it.
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 findings = diffStyleMaps(loadStyleMap(indexA[surface]), loadStyleMap(indexB[surface]));
105
- for (const f of findings) {
106
- if (f.kind === 'dom')
107
- counts.dom++;
108
- else if (f.kind === 'style')
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.4.0",
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",