styleproof 1.4.0 → 1.6.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 +66 -0
- package/README.md +5 -3
- package/dist/capture.d.ts +40 -4
- package/dist/capture.js +79 -6
- package/dist/describe.d.ts +28 -0
- package/dist/describe.js +258 -0
- package/dist/diff.d.ts +3 -1
- package/dist/diff.js +30 -12
- package/dist/report.d.ts +7 -6
- package/dist/report.js +125 -66
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.0]
|
|
11
|
+
|
|
12
|
+
The report tells you what to look for, in plain English, and stops being a
|
|
13
|
+
spot-the-difference puzzle.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Plain-English change bullets.** Each crop now leads with a few bullets that
|
|
18
|
+
name the change the way a person would — `**columns: 2 → 3**`, `becomes a
|
|
19
|
+
centered flex layout`, `corners squared off (50% → 8px)`, `recoloured light
|
|
20
|
+
yellow → cyan` — instead of a raw list of computed-style deltas. A deterministic
|
|
21
|
+
rule set over the summarised properties (no LLM, no network); the exact
|
|
22
|
+
before→after tables still live in the fold for when you want them. New
|
|
23
|
+
`describeChange` / `colorName` helpers in `src/describe.ts`.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Before/after crops line up exactly.** Both sides are now cropped from the
|
|
28
|
+
SAME page rectangle (the union of where the change sits on each side), so the
|
|
29
|
+
backgrounds align and the bullet tells you where to look — no more comparing
|
|
30
|
+
two differently-framed screenshots.
|
|
31
|
+
- **Forced-state noise is gone.** The `:hover`/`:focus`/`:active` layer was
|
|
32
|
+
drowning real changes in echoes — on one PR, 3135 "state-delta differences"
|
|
33
|
+
across 8 elements. Now:
|
|
34
|
+
- a state delta the **base style already changed** is suppressed (a `:hover
|
|
35
|
+
color` that just follows a recoloured base is an echo, not a dropped variant);
|
|
36
|
+
- layout/grid-track props are stripped from state deltas (a forced relayout
|
|
37
|
+
isn't interaction feedback);
|
|
38
|
+
- a change between two "no value" markers (`— → (gone)`) is dropped — it never
|
|
39
|
+
meant anything;
|
|
40
|
+
- the `outline` shorthand no longer renders `(state no longer changes it)`
|
|
41
|
+
three times in a row.
|
|
42
|
+
- `summarizeProps` drops no-op and non-value↔non-value rows, so counts and tables
|
|
43
|
+
reflect only real, reviewable changes.
|
|
44
|
+
|
|
45
|
+
## [1.5.0]
|
|
46
|
+
|
|
47
|
+
Live regions are handled automatically, so a dashboard with streaming data,
|
|
48
|
+
tickers, or late-loading content no longer produces a false "everything
|
|
49
|
+
changed" report.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **Auto-settle before capture (`stabilize`, default on).** The capture now
|
|
54
|
+
polls the (motion-frozen) page until its computed-style map has been unchanged
|
|
55
|
+
for a quiet window, so content that paints _after_ `go()` resolves — an async
|
|
56
|
+
fetch, an SSE/WebSocket stream backfilling a grid — is captured loaded, not
|
|
57
|
+
mid-load. This is what fixes the most common false positive: base and head
|
|
58
|
+
racing an async load and diffing empty-state-vs-populated. Requiring a
|
|
59
|
+
_sustained_ no-change window (not a single quiet sample) is what lets it wait
|
|
60
|
+
through the gap before late content appears.
|
|
61
|
+
- **Automatic live-region detection.** Anything still changing on its own when
|
|
62
|
+
the settle budget runs out is, by definition, nondeterministic (it mutates
|
|
63
|
+
with no code change). Those element paths are recorded in `StyleMap.volatile`
|
|
64
|
+
and excluded from the diff — unioned across both sides — so a stream or ticker
|
|
65
|
+
never reads as a change, with no manual `ignore`. The report notes how many
|
|
66
|
+
live regions were auto-excluded. Tune or disable via
|
|
67
|
+
`captureStyleMap(page, { stabilize })` (`false`, or `{ interval, quietFor, timeout }`).
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- `diffStyleMapDirs` now returns a `volatile` count alongside `surfaces`/`counts`.
|
|
72
|
+
- Text-only churn (a clock, "2m ago") still never matters — the diff has always
|
|
73
|
+
compared computed style, not text; this release adds the structural/layout
|
|
74
|
+
determinism to match.
|
|
75
|
+
|
|
10
76
|
## [1.4.0]
|
|
11
77
|
|
|
12
78
|
New surfaces (present on only one side, no baseline to diff) are shown for
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:fo
|
|
|
14
14
|
|
|
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
|
-
- A summary line, then **one section per distinct change**, with a side-by-side before/after cropped screenshot and the property changes folded under a toggle.
|
|
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
18
|
- An **approval checkbox per change**, driving a `StyleProof` commit status: red until every change is 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_.
|
|
@@ -25,13 +25,13 @@ One change — the hero CTA recoloured cyan → amber — posts as a single sect
|
|
|
25
25
|
|
|
26
26
|

|
|
27
27
|
|
|
28
|
-
As it renders in the PR comment (
|
|
28
|
+
As it renders in the PR comment (a plain-English bullet first, then the exact table inside the toggle):
|
|
29
29
|
|
|
30
30
|
```text
|
|
31
31
|
### `a.btn-solid` · 1 element restyled
|
|
32
32
|
_landing @ 1280_
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
- **`a.btn-solid`** — background cyan → amber
|
|
35
35
|
|
|
36
36
|
▾ Show the property change
|
|
37
37
|
| Property | Before | After |
|
|
@@ -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`. */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PropChange } from './diff.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic, offline plain-English summariser for a crop's changes. Turns a
|
|
4
|
+
* wall of computed-style deltas into a few bullets that tell a reviewer WHAT to
|
|
5
|
+
* look for ("Grid: 2 → 3 columns", "Accent recoloured cyan → red") instead of
|
|
6
|
+
* leaving them to spot the difference. No LLM — just curated rules over the
|
|
7
|
+
* already-summarised property changes, so the same input always yields the same
|
|
8
|
+
* words and it runs with no network.
|
|
9
|
+
*/
|
|
10
|
+
/** One changed element, with its property deltas already run through summarizeProps. */
|
|
11
|
+
export type ElementChange = {
|
|
12
|
+
label: string;
|
|
13
|
+
added?: boolean;
|
|
14
|
+
removed?: boolean;
|
|
15
|
+
retagged?: boolean;
|
|
16
|
+
/** Base/computed-style deltas (not interactive-state ones). */
|
|
17
|
+
props: PropChange[];
|
|
18
|
+
/** Interactive states this element gained/changed/dropped, by name. */
|
|
19
|
+
states?: string[];
|
|
20
|
+
};
|
|
21
|
+
/** A short colour word for an rgb/rgba value: "cyan", "dark blue", "transparent". */
|
|
22
|
+
export declare function colorName(v: string): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Plain-English bullets for a crop's changes — DOM verbs, then labelled restyle
|
|
25
|
+
* phrases, then a flag for interaction-state changes a static screenshot can't
|
|
26
|
+
* show. Capped so the summary stays a glance (the exact tables live in the fold).
|
|
27
|
+
*/
|
|
28
|
+
export declare function describeChange(els: ElementChange[], maxBullets?: number): string[];
|
package/dist/describe.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// --- colour naming: nearest of a small, legible palette -----------------------
|
|
2
|
+
const PALETTE = [
|
|
3
|
+
['black', [0, 0, 0]],
|
|
4
|
+
['white', [255, 255, 255]],
|
|
5
|
+
['gray', [128, 128, 128]],
|
|
6
|
+
['red', [229, 57, 53]],
|
|
7
|
+
['orange', [245, 124, 0]],
|
|
8
|
+
['amber', [255, 179, 0]],
|
|
9
|
+
['yellow', [253, 216, 53]],
|
|
10
|
+
['lime', [124, 179, 66]],
|
|
11
|
+
['green', [56, 142, 60]],
|
|
12
|
+
['teal', [0, 150, 136]],
|
|
13
|
+
['cyan', [38, 198, 218]],
|
|
14
|
+
['blue', [33, 110, 233]],
|
|
15
|
+
['indigo', [57, 73, 171]],
|
|
16
|
+
['purple', [142, 68, 173]],
|
|
17
|
+
['magenta', [216, 27, 170]],
|
|
18
|
+
['pink', [236, 64, 122]],
|
|
19
|
+
];
|
|
20
|
+
function parseColor(v) {
|
|
21
|
+
const m = v.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)/i);
|
|
22
|
+
if (!m)
|
|
23
|
+
return null;
|
|
24
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
|
|
25
|
+
}
|
|
26
|
+
/** Nearest palette word to an rgb point, by squared distance. */
|
|
27
|
+
function nearest(r, g, b) {
|
|
28
|
+
let best = PALETTE[0][0];
|
|
29
|
+
let bestD = Infinity;
|
|
30
|
+
for (const [name, [pr, pg, pb]] of PALETTE) {
|
|
31
|
+
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
|
|
32
|
+
if (d < bestD)
|
|
33
|
+
[bestD, best] = [d, name];
|
|
34
|
+
}
|
|
35
|
+
return best;
|
|
36
|
+
}
|
|
37
|
+
/** A short colour word for an rgb/rgba value: "cyan", "dark blue", "transparent". */
|
|
38
|
+
export function colorName(v) {
|
|
39
|
+
if (v === 'transparent')
|
|
40
|
+
return 'transparent';
|
|
41
|
+
const c = parseColor(v);
|
|
42
|
+
if (!c)
|
|
43
|
+
return null;
|
|
44
|
+
const [r, g, b, a] = c;
|
|
45
|
+
if (a === 0)
|
|
46
|
+
return 'transparent';
|
|
47
|
+
const best = nearest(r, g, b);
|
|
48
|
+
// Light/dark qualifier for the chromatic colours, where it reads naturally.
|
|
49
|
+
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
50
|
+
const chromatic = best !== 'black' && best !== 'white' && best !== 'gray';
|
|
51
|
+
const qual = chromatic ? (lum > 200 ? 'light ' : lum < 70 ? 'dark ' : '') : '';
|
|
52
|
+
return `${qual}${best}`;
|
|
53
|
+
}
|
|
54
|
+
const shift = (before, after) => {
|
|
55
|
+
const cb = colorName(before);
|
|
56
|
+
const ca = colorName(after);
|
|
57
|
+
return cb && ca ? `${cb} → ${ca}` : `${before} → ${after}`;
|
|
58
|
+
};
|
|
59
|
+
// --- track counting for grid columns/rows ------------------------------------
|
|
60
|
+
/** Count grid tracks in a `grid-template-*` value, honouring the `Npx ×K` form. */
|
|
61
|
+
function trackCount(v) {
|
|
62
|
+
if (v === 'none' || !v)
|
|
63
|
+
return 0;
|
|
64
|
+
const rep = v.match(/×(\d+)/); // summarizeProps collapses "8px 8px 8px" → "8px ×3"
|
|
65
|
+
if (rep)
|
|
66
|
+
return Number(rep[1]);
|
|
67
|
+
return v.split(/\s+/).filter((t) => t && t !== '0px' && t !== '0').length;
|
|
68
|
+
}
|
|
69
|
+
const round = (v) => /50%|9999|999px/.test(v);
|
|
70
|
+
const flexPhrase = (m, before) => {
|
|
71
|
+
const col = m.get('flex-direction')?.after?.startsWith('column') ? 'vertical ' : '';
|
|
72
|
+
const centered = m.get('justify-content')?.after === 'center' || m.get('align-items')?.after === 'center';
|
|
73
|
+
return `becomes a${centered ? ' centered' : ''} ${col}flex layout (was ${before})`;
|
|
74
|
+
};
|
|
75
|
+
const layoutRule = (m, mark) => {
|
|
76
|
+
const d = m.get('display');
|
|
77
|
+
if (!d)
|
|
78
|
+
return [];
|
|
79
|
+
mark('display', 'justify-content', 'align-items', 'flex-direction');
|
|
80
|
+
if (d.after === 'none')
|
|
81
|
+
return ['**hidden**'];
|
|
82
|
+
if (d.before === 'none')
|
|
83
|
+
return ['**shown**'];
|
|
84
|
+
if (/flex/.test(d.after))
|
|
85
|
+
return [flexPhrase(m, d.before)];
|
|
86
|
+
if (/grid/.test(d.after))
|
|
87
|
+
return [`becomes a grid (was ${d.before})`];
|
|
88
|
+
return [`display ${d.before} → ${d.after}`];
|
|
89
|
+
};
|
|
90
|
+
const gridRule = (m, mark) => {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const [prop, word] of [
|
|
93
|
+
['grid-template-columns', 'columns'],
|
|
94
|
+
['grid-template-rows', 'rows'],
|
|
95
|
+
]) {
|
|
96
|
+
const g = m.get(prop);
|
|
97
|
+
if (!g)
|
|
98
|
+
continue;
|
|
99
|
+
mark(prop);
|
|
100
|
+
const [b, a] = [trackCount(g.before), trackCount(g.after)];
|
|
101
|
+
if (b !== a && (b > 0 || a > 0))
|
|
102
|
+
out.push(`**${word}: ${b} → ${a}**`);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
};
|
|
106
|
+
const borderWidthRule = (m, mark) => {
|
|
107
|
+
const bw = m.get('border-width');
|
|
108
|
+
if (!bw)
|
|
109
|
+
return [];
|
|
110
|
+
mark('border-width', 'border-style', 'border-color');
|
|
111
|
+
const wasZero = /^0/.test(bw.before);
|
|
112
|
+
const isZero = /^0/.test(bw.after);
|
|
113
|
+
if (wasZero && !isZero)
|
|
114
|
+
return [`gains a ${bw.after} border`];
|
|
115
|
+
if (!wasZero && isZero)
|
|
116
|
+
return ['loses its border'];
|
|
117
|
+
return [`border ${bw.before} → ${bw.after}`];
|
|
118
|
+
};
|
|
119
|
+
const borderRadiusRule = (m, mark) => {
|
|
120
|
+
const r = m.get('border-radius');
|
|
121
|
+
if (!r)
|
|
122
|
+
return [];
|
|
123
|
+
mark('border-radius');
|
|
124
|
+
if (round(r.before) && !round(r.after))
|
|
125
|
+
return [`corners squared off (${r.before} → ${r.after})`];
|
|
126
|
+
if (!round(r.before) && round(r.after))
|
|
127
|
+
return ['corners fully rounded'];
|
|
128
|
+
return [`corner radius ${r.before} → ${r.after}`];
|
|
129
|
+
};
|
|
130
|
+
const colorRule = (m, mark) => {
|
|
131
|
+
const fields = [
|
|
132
|
+
['color', 'text'],
|
|
133
|
+
['background-color', 'background'],
|
|
134
|
+
['border-color', 'border colour'],
|
|
135
|
+
];
|
|
136
|
+
const present = fields.map(([p, w]) => [m.get(p), w]).filter(([c]) => c);
|
|
137
|
+
if (!present.length)
|
|
138
|
+
return [];
|
|
139
|
+
const [first] = present;
|
|
140
|
+
const same = present.length > 1 && present.every(([c]) => c.before === first[0].before && c.after === first[0].after);
|
|
141
|
+
fields.forEach(([p]) => m.has(p) && mark(p));
|
|
142
|
+
if (same)
|
|
143
|
+
return [`recoloured ${shift(first[0].before, first[0].after)}`];
|
|
144
|
+
return present.map(([c, w]) => `${w} ${shift(c.before, c.after)}`);
|
|
145
|
+
};
|
|
146
|
+
const fillRule = (m, mark) => {
|
|
147
|
+
const bgi = m.get('background-image');
|
|
148
|
+
if (!bgi)
|
|
149
|
+
return [];
|
|
150
|
+
mark('background-image');
|
|
151
|
+
const kind = (v) => /gradient/.test(v) ? 'a gradient' : /url\(/.test(v) ? 'an image' : v === 'none' ? 'no fill' : v;
|
|
152
|
+
return [`fill → ${kind(bgi.after)} (was ${kind(bgi.before)})`];
|
|
153
|
+
};
|
|
154
|
+
const effectsRule = (m, mark) => {
|
|
155
|
+
const out = [];
|
|
156
|
+
const sh = m.get('box-shadow');
|
|
157
|
+
if (sh) {
|
|
158
|
+
mark('box-shadow');
|
|
159
|
+
out.push(sh.before === 'none' ? 'gains a shadow' : sh.after === 'none' ? 'loses its shadow' : 'shadow changes');
|
|
160
|
+
}
|
|
161
|
+
const op = m.get('opacity');
|
|
162
|
+
if (op) {
|
|
163
|
+
mark('opacity');
|
|
164
|
+
out.push(`opacity ${op.before} → ${op.after}`);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
const weight = (v) => Number(v) || (v === 'bold' ? 700 : v === 'normal' ? 400 : NaN);
|
|
169
|
+
const typographyRule = (m, mark) => {
|
|
170
|
+
const out = [];
|
|
171
|
+
const fs = m.get('font-size');
|
|
172
|
+
if (fs) {
|
|
173
|
+
mark('font-size');
|
|
174
|
+
out.push(`text size ${fs.before} → ${fs.after}`);
|
|
175
|
+
}
|
|
176
|
+
const fw = m.get('font-weight');
|
|
177
|
+
if (fw) {
|
|
178
|
+
mark('font-weight');
|
|
179
|
+
const d = weight(fw.after) - weight(fw.before);
|
|
180
|
+
out.push(Number.isNaN(d) ? `weight ${fw.before} → ${fw.after}` : d > 0 ? 'bolder text' : 'lighter text');
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
};
|
|
184
|
+
const spacingRule = (m, mark) => {
|
|
185
|
+
const spacing = ['padding', 'margin', 'gap'].filter((p) => m.has(p));
|
|
186
|
+
spacing.forEach((p) => mark(p));
|
|
187
|
+
return spacing.length ? [`${spacing.join(' & ')} adjusted`] : [];
|
|
188
|
+
};
|
|
189
|
+
const RULES = [
|
|
190
|
+
layoutRule,
|
|
191
|
+
gridRule,
|
|
192
|
+
borderWidthRule,
|
|
193
|
+
borderRadiusRule,
|
|
194
|
+
colorRule,
|
|
195
|
+
fillRule,
|
|
196
|
+
effectsRule,
|
|
197
|
+
typographyRule,
|
|
198
|
+
spacingRule,
|
|
199
|
+
];
|
|
200
|
+
/** Build the English phrases for ONE element's property deltas. */
|
|
201
|
+
function phrasesFor(props) {
|
|
202
|
+
const m = new Map(props.map((p) => [p.prop, p]));
|
|
203
|
+
const used = new Set();
|
|
204
|
+
const mark = (...ps) => ps.forEach((p) => used.add(p));
|
|
205
|
+
const out = RULES.flatMap((rule) => rule(m, mark));
|
|
206
|
+
// Anything no rule named: a quiet tail so nothing is silently lost.
|
|
207
|
+
const rest = [...m.keys()].filter((p) => !used.has(p));
|
|
208
|
+
if (rest.length)
|
|
209
|
+
out.push(`+${rest.length} more (${rest.slice(0, 3).join(', ')}${rest.length > 3 ? '…' : ''})`);
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/** Tally added/removed/retagged elements into "3 added" style lines. */
|
|
213
|
+
function domVerbLines(els) {
|
|
214
|
+
const verbs = {};
|
|
215
|
+
for (const el of els) {
|
|
216
|
+
const v = el.added ? 'added' : el.removed ? 'removed' : el.retagged ? 'retagged' : null;
|
|
217
|
+
if (v)
|
|
218
|
+
verbs[v] = (verbs[v] ?? 0) + 1;
|
|
219
|
+
}
|
|
220
|
+
return Object.entries(verbs).map(([verb, n]) => `**${n}** element${n === 1 ? '' : 's'} ${verb}`);
|
|
221
|
+
}
|
|
222
|
+
/** Restyle phrases per element, deduped: a phrase on ONE element is labelled with
|
|
223
|
+
* it; the same phrase across many collapses to `×N` (naming one of fourteen would
|
|
224
|
+
* mislead). */
|
|
225
|
+
function restyleLines(els) {
|
|
226
|
+
const byLine = new Map();
|
|
227
|
+
const order = [];
|
|
228
|
+
for (const el of els) {
|
|
229
|
+
if (el.added || el.removed)
|
|
230
|
+
continue; // values, not deltas — heading covers them
|
|
231
|
+
const line = phrasesFor(el.props).join(', ');
|
|
232
|
+
if (!line)
|
|
233
|
+
continue;
|
|
234
|
+
if (!byLine.has(line)) {
|
|
235
|
+
byLine.set(line, { count: 0, label: el.label });
|
|
236
|
+
order.push(line);
|
|
237
|
+
}
|
|
238
|
+
byLine.get(line).count++;
|
|
239
|
+
}
|
|
240
|
+
return order.map((line) => {
|
|
241
|
+
const { count, label } = byLine.get(line);
|
|
242
|
+
return count > 1 ? `${line} _(×${count})_` : `**\`${label}\`** — ${line}`;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Plain-English bullets for a crop's changes — DOM verbs, then labelled restyle
|
|
247
|
+
* phrases, then a flag for interaction-state changes a static screenshot can't
|
|
248
|
+
* show. Capped so the summary stays a glance (the exact tables live in the fold).
|
|
249
|
+
*/
|
|
250
|
+
export function describeChange(els, maxBullets = 6) {
|
|
251
|
+
const lines = [...domVerbLines(els), ...restyleLines(els)];
|
|
252
|
+
const states = [...new Set(els.flatMap((e) => e.states ?? []))];
|
|
253
|
+
if (states.length)
|
|
254
|
+
lines.push(`interaction states changed: ${states.map((s) => `\`:${s}\``).join(', ')}`);
|
|
255
|
+
if (lines.length <= maxBullets)
|
|
256
|
+
return lines;
|
|
257
|
+
return [...lines.slice(0, maxBullets - 1), `…and ${lines.length - (maxBullets - 1)} more change(s)`];
|
|
258
|
+
}
|
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.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type PropChange } from './diff.js';
|
|
2
|
+
export { describeChange, colorName } from './describe.js';
|
|
2
3
|
/**
|
|
3
4
|
* Visual diff report: for every surface with findings, crop the before/after
|
|
4
5
|
* full-page screenshots around the changed elements and write a markdown
|
|
@@ -6,9 +7,9 @@ import { type PropChange } from './diff.js';
|
|
|
6
7
|
*
|
|
7
8
|
* Cropping zooms out to the OUTERMOST changed element: changed paths that are
|
|
8
9
|
* descendants of other changed paths are folded into their ancestor, nearby
|
|
9
|
-
* regions are merged, and both sides are cropped at
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* regions are merged, and both sides are cropped at the SAME page rectangle (the
|
|
11
|
+
* union of where the change sits on each side) so the pair lines up exactly —
|
|
12
|
+
* the reviewer compares like-for-like instead of playing spot-the-difference.
|
|
12
13
|
*/
|
|
13
14
|
export type ReportOptions = {
|
|
14
15
|
beforeDir: string;
|
|
@@ -27,9 +28,9 @@ export type ReportOptions = {
|
|
|
27
28
|
maxCrops?: number;
|
|
28
29
|
/**
|
|
29
30
|
* Row count at which a crop's property tables fold under a `<details>` toggle
|
|
30
|
-
* (default 0 = always fold; the
|
|
31
|
-
* to e.g. 5 to keep small changes inline and fold only verbose
|
|
32
|
-
* `Infinity` to never fold.
|
|
31
|
+
* (default 0 = always fold; the plain-English bullets and screenshot stay
|
|
32
|
+
* visible). Set to e.g. 5 to keep small changes inline and fold only verbose
|
|
33
|
+
* ones, or `Infinity` to never fold.
|
|
33
34
|
*/
|
|
34
35
|
foldDetailsAt?: number;
|
|
35
36
|
/**
|
package/dist/report.js
CHANGED
|
@@ -3,6 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
4
|
import { loadStyleMap } from './capture.js';
|
|
5
5
|
import { diffStyleMapDirs } from './diff.js';
|
|
6
|
+
import { describeChange } from './describe.js';
|
|
7
|
+
// Re-export the plain-English summariser so consumers (and tests) reach it
|
|
8
|
+
// through the package's report module rather than a deep path.
|
|
9
|
+
export { describeChange, colorName } from './describe.js';
|
|
6
10
|
// Hidden marker appended to a new-surface heading. Invisible in rendered
|
|
7
11
|
// markdown; lets the PR-comment layer attach an OPTIONAL "approve" box to a new
|
|
8
12
|
// surface (vs the required box on a real change), so new surfaces never gate.
|
|
@@ -20,6 +24,16 @@ const visible = (b) => !!b && b.w > 0 && b.h > 0;
|
|
|
20
24
|
function outermost(paths) {
|
|
21
25
|
return paths.filter((p) => !paths.some((q) => q !== p && p.startsWith(q + ' > ')));
|
|
22
26
|
}
|
|
27
|
+
/** Group findings by their element path (one group per changed element). */
|
|
28
|
+
function groupByPath(findings) {
|
|
29
|
+
const byPath = new Map();
|
|
30
|
+
for (const f of findings) {
|
|
31
|
+
const arr = byPath.get(f.path) ?? [];
|
|
32
|
+
arr.push(f);
|
|
33
|
+
byPath.set(f.path, arr);
|
|
34
|
+
}
|
|
35
|
+
return [...byPath.values()];
|
|
36
|
+
}
|
|
23
37
|
function groupRegions(paths, a, b, padBy) {
|
|
24
38
|
const groups = paths.map((p) => {
|
|
25
39
|
const ra = a.elements[p]?.rect;
|
|
@@ -218,6 +232,13 @@ const CURRENTCOLOR_FOLLOWERS = [
|
|
|
218
232
|
'-webkit-text-fill-color',
|
|
219
233
|
'-webkit-text-stroke-color',
|
|
220
234
|
];
|
|
235
|
+
// "No value here" markers: a forced-state delta that doesn't apply, an unset
|
|
236
|
+
// longhand, or a capture artifact where a path didn't line up. A change BETWEEN
|
|
237
|
+
// two of these (e.g. `— → (gone)`) is meaningless and must never read as a diff.
|
|
238
|
+
const NON_VALUE = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)', '(gone)']);
|
|
239
|
+
const isNonValue = (v) => NON_VALUE.has(v);
|
|
240
|
+
/** Combine longhands into a shorthand value; all-non-value sides collapse to one. */
|
|
241
|
+
const combineValues = (vals) => (vals.every(isNonValue) ? '(unset)' : vals.join(' '));
|
|
221
242
|
export function summarizeProps(props) {
|
|
222
243
|
const map = new Map(props.map((p) => [p.prop, { ...p }]));
|
|
223
244
|
for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
|
|
@@ -273,13 +294,16 @@ export function summarizeProps(props) {
|
|
|
273
294
|
map.delete('outline-color');
|
|
274
295
|
map.set('outline', {
|
|
275
296
|
prop: 'outline',
|
|
276
|
-
before:
|
|
277
|
-
after:
|
|
297
|
+
before: combineValues([ow.before, os.before, oc.before]),
|
|
298
|
+
after: combineValues([ow.after, os.after, oc.after]),
|
|
278
299
|
});
|
|
279
300
|
}
|
|
280
|
-
return [...map.values()]
|
|
301
|
+
return ([...map.values()]
|
|
281
302
|
.map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
|
|
282
|
-
|
|
303
|
+
// Drop no-ops: a value that didn't actually change, or a change between two
|
|
304
|
+
// "no value here" markers (`— → (gone)`), which carries no information.
|
|
305
|
+
.filter((p) => p.before !== p.after && !(isNonValue(p.before) && isNonValue(p.after)))
|
|
306
|
+
.sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop)));
|
|
283
307
|
}
|
|
284
308
|
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
285
309
|
export function prettyLabel(p, cls) {
|
|
@@ -351,10 +375,8 @@ function regionHeading(regionPaths, findings) {
|
|
|
351
375
|
const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
|
|
352
376
|
return `${label} · ${groupTitle(findings)}`;
|
|
353
377
|
}
|
|
354
|
-
// A
|
|
355
|
-
|
|
356
|
-
const STATE_PLACEHOLDER = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)']);
|
|
357
|
-
const cell = (v) => (STATE_PLACEHOLDER.has(v) ? '—' : `\`${v}\``);
|
|
378
|
+
// A "no value here" marker renders as an em dash rather than its literal text.
|
|
379
|
+
const cell = (v) => (isNonValue(v) ? '—' : `\`${v}\``);
|
|
358
380
|
function beforeAfterTable(rows) {
|
|
359
381
|
return [
|
|
360
382
|
'| Property | Before | After |',
|
|
@@ -406,15 +428,9 @@ function renderOneElement(group) {
|
|
|
406
428
|
* "before"); an existing element shows before → after.
|
|
407
429
|
*/
|
|
408
430
|
function renderElements(findings, maxElements = 40) {
|
|
409
|
-
const byPath = new Map();
|
|
410
|
-
for (const f of findings) {
|
|
411
|
-
const arr = byPath.get(f.path) ?? [];
|
|
412
|
-
arr.push(f);
|
|
413
|
-
byPath.set(f.path, arr);
|
|
414
|
-
}
|
|
415
431
|
const blocks = [];
|
|
416
432
|
const bySig = new Map();
|
|
417
|
-
for (const group of
|
|
433
|
+
for (const group of groupByPath(findings)) {
|
|
418
434
|
const el = renderOneElement(group);
|
|
419
435
|
if (!el)
|
|
420
436
|
continue;
|
|
@@ -439,25 +455,6 @@ function renderElements(findings, maxElements = 40) {
|
|
|
439
455
|
}
|
|
440
456
|
return out;
|
|
441
457
|
}
|
|
442
|
-
/**
|
|
443
|
-
* A scannable one-liner of what a crop changed, shown ABOVE the folded tables so a
|
|
444
|
-
* reviewer can judge without expanding: its top property deltas with values, the
|
|
445
|
-
* rest as a count, and a flag when the change reaches into hover/focus/active — the
|
|
446
|
-
* one kind of change a static before|after screenshot can't show.
|
|
447
|
-
*/
|
|
448
|
-
function changeEssence(findings) {
|
|
449
|
-
const verbs = [];
|
|
450
|
-
for (const c of ['added', 'removed', 'retagged']) {
|
|
451
|
-
const k = findings.filter((f) => f.kind === 'dom' && f.change === c).length;
|
|
452
|
-
if (k)
|
|
453
|
-
verbs.push(`${k} ${c}`);
|
|
454
|
-
}
|
|
455
|
-
const rows = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props)));
|
|
456
|
-
const top = rows.slice(0, 3).map((r) => `\`${r.prop}\` ${cell(r.before)} → ${cell(r.after)}`);
|
|
457
|
-
const more = rows.length > top.length ? `+${rows.length - top.length} more` : '';
|
|
458
|
-
const line = [...verbs, ...top, more].filter(Boolean).join(' · ') || '_see changes_';
|
|
459
|
-
return findings.some((f) => f.kind === 'state') ? `${line} _· incl. hover/focus/active_` : line;
|
|
460
|
-
}
|
|
461
458
|
/** Plain-text `<summary>` affordance — GitHub renders markdown inside `<summary>`
|
|
462
459
|
* literally, so no backticks or bold here. */
|
|
463
460
|
function foldSummary(findings) {
|
|
@@ -466,31 +463,24 @@ function foldSummary(findings) {
|
|
|
466
463
|
return 'Show details';
|
|
467
464
|
return n === 1 ? 'Show the property change' : `Show all ${n} property changes`;
|
|
468
465
|
}
|
|
469
|
-
/** Render a crop's changes:
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
* tables collapse; ≤ 0 folds
|
|
466
|
+
/** Render a crop's changes: plain-English bullets that tell the reviewer what to
|
|
467
|
+
* look for, then the exact property tables — folded under a toggle once they would
|
|
468
|
+
* be a wall (the screenshot and approval checkbox above always stay visible).
|
|
469
|
+
* Blank lines around the table block are mandatory or GitHub prints the tables as
|
|
470
|
+
* literal text. `foldAt` is the row count at which the tables collapse; ≤ 0 folds
|
|
471
|
+
* always, Infinity never. */
|
|
474
472
|
function renderCropChanges(findings, foldAt) {
|
|
475
473
|
const tables = renderElements(findings);
|
|
476
474
|
if (!tables.length)
|
|
477
475
|
return [];
|
|
478
476
|
const rows = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props))).length;
|
|
479
|
-
// Small enough to read at a glance: the tables speak for themselves
|
|
480
|
-
// line (it would just echo a one- or two-row table).
|
|
477
|
+
// Small enough to read at a glance: the tables speak for themselves.
|
|
481
478
|
if (rows < foldAt)
|
|
482
479
|
return tables;
|
|
483
|
-
// Folded:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
'',
|
|
488
|
-
'<details>',
|
|
489
|
-
`<summary>${foldSummary(findings)}</summary>`,
|
|
490
|
-
...tables,
|
|
491
|
-
'',
|
|
492
|
-
'</details>',
|
|
493
|
-
];
|
|
480
|
+
// Folded: plain-English bullets are the visible stand-in for what the toggle hides.
|
|
481
|
+
const bullets = describeChange(buildElementChanges(findings));
|
|
482
|
+
const summary = bullets.length ? bullets.map((b) => `- ${b}`) : ['_see changes_'];
|
|
483
|
+
return ['', ...summary, '', '<details>', `<summary>${foldSummary(findings)}</summary>`, ...tables, '', '</details>'];
|
|
494
484
|
}
|
|
495
485
|
// Computed values that follow from an element's box size or position rather than
|
|
496
486
|
// its styling. On any reflow they change all the way up the ancestor chain
|
|
@@ -519,20 +509,82 @@ const DERIVED_PROPS = new Set([
|
|
|
519
509
|
'inset-inline-start',
|
|
520
510
|
'inset-inline-end',
|
|
521
511
|
]);
|
|
522
|
-
|
|
523
|
-
|
|
512
|
+
// Props stripped from forced :hover/:focus/:active deltas specifically. Layout
|
|
513
|
+
// and grid-track values that shift when a state forces a relayout are capture
|
|
514
|
+
// noise, not interaction feedback — a state finding is meant to catch a changed
|
|
515
|
+
// hover/focus/active *style* (colour, outline, shadow), not a reflow.
|
|
516
|
+
const STATE_STRIP = new Set([
|
|
517
|
+
...DERIVED_PROPS,
|
|
518
|
+
'grid-template-columns',
|
|
519
|
+
'grid-template-rows',
|
|
520
|
+
'grid-template-areas',
|
|
521
|
+
'grid-auto-columns',
|
|
522
|
+
'grid-auto-rows',
|
|
523
|
+
'grid-auto-flow',
|
|
524
|
+
]);
|
|
525
|
+
/**
|
|
526
|
+
* Strip the noise the visual report shouldn't carry, cross-referencing each
|
|
527
|
+
* element's layers so the forced-state layer stops echoing the base:
|
|
528
|
+
* - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
|
|
529
|
+
* - forced states: drop derived + grid-track props, drop a delta the BASE
|
|
530
|
+
* already changed (a `:hover color` that just follows a recoloured base is an
|
|
531
|
+
* echo, not a dropped variant), and drop non-value↔non-value rows;
|
|
532
|
+
* - any finding left with no props is removed entirely.
|
|
533
|
+
*/
|
|
534
|
+
function cleanFindings(findings) {
|
|
535
|
+
const out = [];
|
|
536
|
+
for (const group of groupByPath(findings)) {
|
|
537
|
+
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
538
|
+
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
539
|
+
for (const f of group) {
|
|
540
|
+
if (f.kind === 'dom') {
|
|
541
|
+
out.push(f);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const props = f.kind === 'style'
|
|
545
|
+
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
546
|
+
: f.props.filter((p) => !STATE_STRIP.has(p.prop) && !baseChanged.has(p.prop) && !(isNonValue(p.before) && isNonValue(p.after)));
|
|
547
|
+
if (props.length)
|
|
548
|
+
out.push({ ...f, props });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
/** Per-element view for the plain-English summariser: the base deltas (summarised)
|
|
554
|
+
* plus which interactive states genuinely changed. */
|
|
555
|
+
function buildElementChanges(findings) {
|
|
556
|
+
const els = [];
|
|
557
|
+
for (const group of groupByPath(findings)) {
|
|
558
|
+
const dom = group.find((f) => f.kind === 'dom');
|
|
559
|
+
const styleProps = group
|
|
560
|
+
.filter((f) => f.kind === 'style')
|
|
561
|
+
.flatMap((f) => f.props);
|
|
562
|
+
els.push({
|
|
563
|
+
label: prettyLabel(group[0].path, group[0].cls),
|
|
564
|
+
added: dom?.change === 'added',
|
|
565
|
+
removed: dom?.change === 'removed',
|
|
566
|
+
retagged: dom?.change === 'retagged',
|
|
567
|
+
props: summarizeProps(styleProps),
|
|
568
|
+
states: [
|
|
569
|
+
...new Set(group.filter((f) => f.kind === 'state').map((f) => f.state)),
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return els;
|
|
574
|
+
}
|
|
524
575
|
export function generateStyleMapReport(opts) {
|
|
525
576
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, foldDetailsAt = 0, } = opts;
|
|
526
577
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
527
|
-
const { surfaces } = diffStyleMapDirs(beforeDir, afterDir);
|
|
578
|
+
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
528
579
|
fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
|
|
529
|
-
// Focus each surface on styling intent: drop reflow-casualty
|
|
530
|
-
//
|
|
531
|
-
// includeLayoutNoise is set. Surfaces left with no real
|
|
580
|
+
// Focus each surface on styling intent: drop reflow-casualty props, suppress
|
|
581
|
+
// forced-state echoes of base changes, and remove non-value noise (see
|
|
582
|
+
// cleanFindings), unless includeLayoutNoise is set. Surfaces left with no real
|
|
583
|
+
// change are dropped.
|
|
532
584
|
const prepared = surfaces
|
|
533
585
|
.map((sd) => ({
|
|
534
586
|
sd,
|
|
535
|
-
findings: sd.missing || includeNoise ? sd.findings : sd.findings
|
|
587
|
+
findings: sd.missing || includeNoise ? sd.findings : cleanFindings(sd.findings),
|
|
536
588
|
}))
|
|
537
589
|
.filter((p) => p.sd.missing || p.findings.length > 0);
|
|
538
590
|
// Group surfaces that changed in the SAME way (the rects differ per width; the
|
|
@@ -590,6 +642,9 @@ export function generateStyleMapReport(opts) {
|
|
|
590
642
|
`New surfaces don't block the check.`);
|
|
591
643
|
}
|
|
592
644
|
}
|
|
645
|
+
if (volatileCount > 0) {
|
|
646
|
+
md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check._`);
|
|
647
|
+
}
|
|
593
648
|
let totalFindings = 0;
|
|
594
649
|
let cropSeq = 0;
|
|
595
650
|
for (const cg of changeGroups) {
|
|
@@ -631,14 +686,18 @@ export function generateStyleMapReport(opts) {
|
|
|
631
686
|
const region = visible(g.after) ? g.after : g.before;
|
|
632
687
|
let images = {};
|
|
633
688
|
if (region && pngA && pngB) {
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
689
|
+
// Crop the SAME page rectangle from both sides — the union of where the
|
|
690
|
+
// change sits on each side — so the pair lines up exactly and the reviewer
|
|
691
|
+
// compares like-for-like instead of playing spot-the-difference. (Centring
|
|
692
|
+
// each side on its own moved box would shift the background between them.)
|
|
693
|
+
const cropBox = visible(g.before) && visible(g.after) ? union(g.before, g.after) : region;
|
|
694
|
+
const w = Math.max(minWidth, cropBox.w);
|
|
695
|
+
const h = Math.min(maxHeight, Math.max(minHeight, cropBox.h));
|
|
637
696
|
// Path-safe, report-unique stem: `hero@1280` → `hero-1280-3` so relative
|
|
638
697
|
// image links resolve cleanly and two crops never collide on one filename.
|
|
639
698
|
const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
|
|
640
|
-
const before = cropPng(pngA,
|
|
641
|
-
const after = cropPng(pngB,
|
|
699
|
+
const before = cropPng(pngA, cropBox, w, h);
|
|
700
|
+
const after = cropPng(pngB, cropBox, w, h);
|
|
642
701
|
const composite = compositePair(before, after);
|
|
643
702
|
writePng(path.join(outDir, `${stem}-before.png`), before);
|
|
644
703
|
writePng(path.join(outDir, `${stem}-after.png`), after);
|
|
@@ -653,8 +712,8 @@ export function generateStyleMapReport(opts) {
|
|
|
653
712
|
else {
|
|
654
713
|
md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
|
|
655
714
|
}
|
|
656
|
-
// What this crop changed:
|
|
657
|
-
//
|
|
715
|
+
// What this crop changed: plain-English bullets, then the property tables —
|
|
716
|
+
// folded under a toggle once they'd be a wall (foldDetailsAt).
|
|
658
717
|
md.push(...renderCropChanges(regionFindings, foldDetailsAt));
|
|
659
718
|
surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
|
|
660
719
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|