styleproof 1.5.0 → 1.7.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 -5
- package/dist/capture.d.ts +8 -0
- package/dist/capture.js +30 -0
- package/dist/describe.d.ts +38 -0
- package/dist/describe.js +365 -0
- package/dist/report.d.ts +7 -6
- package/dist/report.js +142 -71
- 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.7.0]
|
|
11
|
+
|
|
12
|
+
Colour changes are named by their theme token, shown as hex with a live swatch,
|
|
13
|
+
and the bullets stay a glance.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Theme-token linking.** A colour change now names the design token behind it —
|
|
18
|
+
``background `red-100` (`#fee2e2`) → `red-200` (`#fecaca`)`` — not just the raw
|
|
19
|
+
value. Computed styles lose the `var(--token)` reference, so the capture now
|
|
20
|
+
records the colour-valued `:root` custom properties (`StyleMap.tokens`,
|
|
21
|
+
normalised to `rgb`) and the report matches a changed value back to its token by
|
|
22
|
+
value, preferring the scale step (`red-200`) over an alias.
|
|
23
|
+
- **Hex everywhere, with swatches.** Every colour renders as `#hex` (translucent
|
|
24
|
+
ones stay `rgba`), in both the bullets and the property tables, so GitHub draws
|
|
25
|
+
its live colour swatch next to each value in the PR comment.
|
|
26
|
+
- **Click any crop to enlarge.** Each before/after composite is wrapped in a link
|
|
27
|
+
to the full-resolution image, so a click opens it full size to zoom.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **Tighter bullets.** Each element is capped to its few most-visible changes
|
|
32
|
+
(then `+N more`); low-signal props (`font-family`, `letter-spacing`, …) are kept
|
|
33
|
+
out of the count; near-identical same-label elements fold to one `×N` line with
|
|
34
|
+
their shared changes (`details vary` when they don't match).
|
|
35
|
+
- **No more `white → white`.** When two colours round to the same word, the bullet
|
|
36
|
+
shows just the hex so a subtle change doesn't read as a no-op.
|
|
37
|
+
- **Headline drops zero counts** (`0 state-delta difference(s)` was noise), and a
|
|
38
|
+
grid that becomes flex no longer prints a confusing `columns: 3 → 0` — the layout
|
|
39
|
+
rule names it.
|
|
40
|
+
|
|
41
|
+
## [1.6.0]
|
|
42
|
+
|
|
43
|
+
The report tells you what to look for, in plain English, and stops being a
|
|
44
|
+
spot-the-difference puzzle.
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **Plain-English change bullets.** Each crop now leads with a few bullets that
|
|
49
|
+
name the change the way a person would — `**columns: 2 → 3**`, `becomes a
|
|
50
|
+
centered flex layout`, `corners squared off (50% → 8px)`, `recoloured light
|
|
51
|
+
yellow → cyan` — instead of a raw list of computed-style deltas. A deterministic
|
|
52
|
+
rule set over the summarised properties (no LLM, no network); the exact
|
|
53
|
+
before→after tables still live in the fold for when you want them. New
|
|
54
|
+
`describeChange` / `colorName` helpers in `src/describe.ts`.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **Before/after crops line up exactly.** Both sides are now cropped from the
|
|
59
|
+
SAME page rectangle (the union of where the change sits on each side), so the
|
|
60
|
+
backgrounds align and the bullet tells you where to look — no more comparing
|
|
61
|
+
two differently-framed screenshots.
|
|
62
|
+
- **Forced-state noise is gone.** The `:hover`/`:focus`/`:active` layer was
|
|
63
|
+
drowning real changes in echoes — on one PR, 3135 "state-delta differences"
|
|
64
|
+
across 8 elements. Now:
|
|
65
|
+
- a state delta the **base style already changed** is suppressed (a `:hover
|
|
66
|
+
color` that just follows a recoloured base is an echo, not a dropped variant);
|
|
67
|
+
- layout/grid-track props are stripped from state deltas (a forced relayout
|
|
68
|
+
isn't interaction feedback);
|
|
69
|
+
- a change between two "no value" markers (`— → (gone)`) is dropped — it never
|
|
70
|
+
meant anything;
|
|
71
|
+
- the `outline` shorthand no longer renders `(state no longer changes it)`
|
|
72
|
+
three times in a row.
|
|
73
|
+
- `summarizeProps` drops no-op and non-value↔non-value rows, so counts and tables
|
|
74
|
+
reflect only real, reviewable changes.
|
|
75
|
+
|
|
10
76
|
## [1.5.0]
|
|
11
77
|
|
|
12
78
|
Live regions are handled automatically, so a dashboard with streaming data,
|
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,17 +25,17 @@ 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 — naming the theme token and showing the hex with a live colour swatch — 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 `brand-cyan` (`#5fcadb`) → `brand-amber` (`#f59e0b`)
|
|
35
35
|
|
|
36
36
|
▾ Show the property change
|
|
37
|
-
| Property | Before
|
|
38
|
-
| background-color |
|
|
37
|
+
| Property | Before | After |
|
|
38
|
+
| background-color | #5fcadb | #f59e0b |
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
## Works with any styling system
|
package/dist/capture.d.ts
CHANGED
|
@@ -53,6 +53,14 @@ export type StyleMap = {
|
|
|
53
53
|
* stream/ticker never reads as a change. Empty/absent on a fully-settled page.
|
|
54
54
|
*/
|
|
55
55
|
volatile?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Colour-valued `:root` custom properties (design/theme tokens), normalised to
|
|
58
|
+
* the same `rgb(...)` form the longhands resolve to — `{ "--red-200": "rgb(254,
|
|
59
|
+
* 202, 202)" }`. Lets the report name the token behind a colour change
|
|
60
|
+
* (`red-100 → red-200`) instead of only the raw value. Captured once per
|
|
61
|
+
* surface from the document root; per-subtree overrides aren't tracked.
|
|
62
|
+
*/
|
|
63
|
+
tokens?: Record<string, string>;
|
|
56
64
|
};
|
|
57
65
|
export type CaptureOptions = {
|
|
58
66
|
/**
|
package/dist/capture.js
CHANGED
|
@@ -300,6 +300,34 @@ async function stabilizePage(page, ignore, interval, quietFor, timeout) {
|
|
|
300
300
|
}
|
|
301
301
|
return recent; // never went quiet for quietFor within budget → still-moving paths are live
|
|
302
302
|
}
|
|
303
|
+
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
304
|
+
// Colour-valued `:root` custom properties (theme tokens), each normalised to the
|
|
305
|
+
// browser's `rgb(...)` form (via a probe element) so they match the resolved
|
|
306
|
+
// longhand values the diff compares — letting the report name `red-200` behind a
|
|
307
|
+
// colour. Non-colour tokens (spacing, etc.) are skipped.
|
|
308
|
+
function capturePageTokens() {
|
|
309
|
+
const root = document.documentElement;
|
|
310
|
+
const cs = getComputedStyle(root);
|
|
311
|
+
const probe = document.createElement('span');
|
|
312
|
+
probe.style.cssText = 'position:absolute;left:-9999px';
|
|
313
|
+
document.body.appendChild(probe);
|
|
314
|
+
const tokens = {};
|
|
315
|
+
for (let i = 0; i < cs.length; i++) {
|
|
316
|
+
const name = cs.item(i);
|
|
317
|
+
if (!name.startsWith('--'))
|
|
318
|
+
continue;
|
|
319
|
+
const raw = cs.getPropertyValue(name).trim();
|
|
320
|
+
if (!raw)
|
|
321
|
+
continue;
|
|
322
|
+
probe.style.color = '';
|
|
323
|
+
probe.style.color = raw; // invalid (non-colour) values leave it empty
|
|
324
|
+
if (!probe.style.color)
|
|
325
|
+
continue;
|
|
326
|
+
tokens[name] = getComputedStyle(probe).color; // canonical rgb(a)(...)
|
|
327
|
+
}
|
|
328
|
+
probe.remove();
|
|
329
|
+
return tokens;
|
|
330
|
+
}
|
|
303
331
|
/**
|
|
304
332
|
* Capture the page's complete style map. Drive the page to the state you want
|
|
305
333
|
* first (navigate, open menus); by default the capture then auto-settles the
|
|
@@ -362,12 +390,14 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
362
390
|
states = forced.states;
|
|
363
391
|
statesSkipped = forced.skipped;
|
|
364
392
|
}
|
|
393
|
+
const tokens = await page.evaluate(capturePageTokens);
|
|
365
394
|
return {
|
|
366
395
|
defaults: base.defaults,
|
|
367
396
|
elements: base.elements,
|
|
368
397
|
states,
|
|
369
398
|
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
370
399
|
...(volatile.length ? { volatile } : {}),
|
|
400
|
+
...(Object.keys(tokens).length ? { tokens } : {}),
|
|
371
401
|
};
|
|
372
402
|
}
|
|
373
403
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
/** rgb/rgba → `#rrggbb` (opaque) or the rgba string (translucent); non-colours pass through. */
|
|
24
|
+
export declare function toHex(v: string): string;
|
|
25
|
+
/** Reverse-index a token map (`--red-200` → `rgb(...)`) to value → token name,
|
|
26
|
+
* preferring a scale step (`red-200`) over an alias, then the shorter name. */
|
|
27
|
+
export declare function tokenIndex(tokens?: Record<string, string>): Map<string, string>;
|
|
28
|
+
/** Token reverse-indexes for each side, so colour rules can name `red-200`. */
|
|
29
|
+
export type DescribeCtx = {
|
|
30
|
+
tokensBefore?: Map<string, string>;
|
|
31
|
+
tokensAfter?: Map<string, string>;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Plain-English bullets for a crop's changes — DOM verbs, then labelled restyle
|
|
35
|
+
* phrases, then a flag for interaction-state changes a static screenshot can't
|
|
36
|
+
* show. Capped so the summary stays a glance (the exact tables live in the fold).
|
|
37
|
+
*/
|
|
38
|
+
export declare function describeChange(els: ElementChange[], ctx?: DescribeCtx, maxBullets?: number): string[];
|
package/dist/describe.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
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
|
+
/** rgb/rgba → `#rrggbb` (opaque) or the rgba string (translucent); non-colours pass through. */
|
|
55
|
+
export function toHex(v) {
|
|
56
|
+
const c = parseColor(v);
|
|
57
|
+
if (!c)
|
|
58
|
+
return v;
|
|
59
|
+
const [r, g, b, a] = c;
|
|
60
|
+
if (a < 1)
|
|
61
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
62
|
+
const h = (n) => n.toString(16).padStart(2, '0');
|
|
63
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
64
|
+
}
|
|
65
|
+
/** Canonical key for matching a colour value against the token index. */
|
|
66
|
+
function colorKey(v) {
|
|
67
|
+
const c = parseColor(v);
|
|
68
|
+
return c ? c.join(',') : null;
|
|
69
|
+
}
|
|
70
|
+
/** Reverse-index a token map (`--red-200` → `rgb(...)`) to value → token name,
|
|
71
|
+
* preferring a scale step (`red-200`) over an alias, then the shorter name. */
|
|
72
|
+
export function tokenIndex(tokens) {
|
|
73
|
+
const idx = new Map();
|
|
74
|
+
if (!tokens)
|
|
75
|
+
return idx;
|
|
76
|
+
const isScale = (n) => /-\d+$/.test(n);
|
|
77
|
+
for (const [rawName, value] of Object.entries(tokens)) {
|
|
78
|
+
const key = colorKey(value);
|
|
79
|
+
if (!key)
|
|
80
|
+
continue;
|
|
81
|
+
const name = rawName.replace(/^--/, '');
|
|
82
|
+
const cur = idx.get(key);
|
|
83
|
+
if (!cur) {
|
|
84
|
+
idx.set(key, name);
|
|
85
|
+
}
|
|
86
|
+
else if ((isScale(name) && !isScale(cur)) || (isScale(name) === isScale(cur) && name.length < cur.length)) {
|
|
87
|
+
idx.set(key, name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return idx;
|
|
91
|
+
}
|
|
92
|
+
/** Resolve one colour value to its token (if any), colour word, and `#hex`. */
|
|
93
|
+
function describeColor(value, idx) {
|
|
94
|
+
if (value === 'transparent')
|
|
95
|
+
return { token: null, word: 'transparent', full: 'transparent', hex: 'transparent' };
|
|
96
|
+
const key = colorKey(value);
|
|
97
|
+
if (!key)
|
|
98
|
+
return { token: null, word: null, full: value, hex: value }; // not a colour
|
|
99
|
+
const token = idx?.get(key) ?? null;
|
|
100
|
+
const hex = toHex(value);
|
|
101
|
+
const word = colorName(value);
|
|
102
|
+
const full = token ? `\`${token}\` (\`${hex}\`)` : `${word} (\`${hex}\`)`;
|
|
103
|
+
return { token, word, full, hex };
|
|
104
|
+
}
|
|
105
|
+
// One side's rendering: hex-only when it's a word-level no-op, else token/word + hex.
|
|
106
|
+
const sidePart = (c, noop) => (noop && c.hex !== 'transparent' ? `\`${c.hex}\`` : c.full);
|
|
107
|
+
/** `red-100 (#fee2e2) → red-200 (#fecaca)`. When neither side has a token and the
|
|
108
|
+
* colour WORD is unchanged (two near-whites), show just the hex so a subtle change
|
|
109
|
+
* doesn't read as a no-op. */
|
|
110
|
+
const shift = (before, after, idxB, idxA) => {
|
|
111
|
+
const b = describeColor(before, idxB);
|
|
112
|
+
const a = describeColor(after, idxA);
|
|
113
|
+
if (b.word === null && a.word === null)
|
|
114
|
+
return `${before} → ${after}`; // neither is a colour
|
|
115
|
+
const noop = !b.token && !a.token && b.word !== null && b.word === a.word;
|
|
116
|
+
return `${sidePart(b, noop)} → ${sidePart(a, noop)}`;
|
|
117
|
+
};
|
|
118
|
+
// --- track counting for grid columns/rows ------------------------------------
|
|
119
|
+
/** Count grid tracks in a `grid-template-*` value, honouring the `Npx ×K` form. */
|
|
120
|
+
function trackCount(v) {
|
|
121
|
+
if (v === 'none' || !v)
|
|
122
|
+
return 0;
|
|
123
|
+
const rep = v.match(/×(\d+)/); // summarizeProps collapses "8px 8px 8px" → "8px ×3"
|
|
124
|
+
if (rep)
|
|
125
|
+
return Number(rep[1]);
|
|
126
|
+
return v.split(/\s+/).filter((t) => t && t !== '0px' && t !== '0').length;
|
|
127
|
+
}
|
|
128
|
+
const round = (v) => /50%|9999|999px/.test(v);
|
|
129
|
+
const flexPhrase = (m, before) => {
|
|
130
|
+
const col = m.get('flex-direction')?.after?.startsWith('column') ? 'vertical ' : '';
|
|
131
|
+
const centered = m.get('justify-content')?.after === 'center' || m.get('align-items')?.after === 'center';
|
|
132
|
+
return `becomes a${centered ? ' centered' : ''} ${col}flex layout (was ${before})`;
|
|
133
|
+
};
|
|
134
|
+
const layoutRule = (m, mark) => {
|
|
135
|
+
const d = m.get('display');
|
|
136
|
+
if (!d)
|
|
137
|
+
return [];
|
|
138
|
+
mark('display', 'justify-content', 'align-items', 'flex-direction');
|
|
139
|
+
if (d.after === 'none')
|
|
140
|
+
return ['**hidden**'];
|
|
141
|
+
if (d.before === 'none')
|
|
142
|
+
return ['**shown**'];
|
|
143
|
+
if (/flex/.test(d.after))
|
|
144
|
+
return [flexPhrase(m, d.before)];
|
|
145
|
+
if (/grid/.test(d.after))
|
|
146
|
+
return [`becomes a grid (was ${d.before})`];
|
|
147
|
+
return [`display ${d.before} → ${d.after}`];
|
|
148
|
+
};
|
|
149
|
+
const gridRule = (m, mark) => {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const [prop, word] of [
|
|
152
|
+
['grid-template-columns', 'columns'],
|
|
153
|
+
['grid-template-rows', 'rows'],
|
|
154
|
+
]) {
|
|
155
|
+
const g = m.get(prop);
|
|
156
|
+
if (!g)
|
|
157
|
+
continue;
|
|
158
|
+
mark(prop);
|
|
159
|
+
const [b, a] = [trackCount(g.before), trackCount(g.after)];
|
|
160
|
+
// A 0-track side that coincides with a display change is the grid becoming
|
|
161
|
+
// (or leaving) a grid — the layout rule already names that, so don't add a
|
|
162
|
+
// confusing "columns: 3 → 0". Only emit a genuine track-count change.
|
|
163
|
+
if (b !== a && (b > 0 || a > 0) && !(m.has('display') && (b === 0 || a === 0))) {
|
|
164
|
+
out.push(`**${word}: ${b} → ${a}**`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
};
|
|
169
|
+
const noBorder = (v) => /^0/.test(v) || v === '(unset)';
|
|
170
|
+
const borderWidthRule = (m, mark) => {
|
|
171
|
+
const bw = m.get('border-width');
|
|
172
|
+
if (!bw)
|
|
173
|
+
return [];
|
|
174
|
+
mark('border-width', 'border-style', 'border-color');
|
|
175
|
+
const wasZero = noBorder(bw.before);
|
|
176
|
+
const isZero = noBorder(bw.after);
|
|
177
|
+
if (wasZero && !isZero)
|
|
178
|
+
return [`gains a ${bw.after} border`];
|
|
179
|
+
if (!wasZero && isZero)
|
|
180
|
+
return ['loses its border'];
|
|
181
|
+
return [`border ${bw.before} → ${bw.after}`];
|
|
182
|
+
};
|
|
183
|
+
const borderRadiusRule = (m, mark) => {
|
|
184
|
+
const r = m.get('border-radius');
|
|
185
|
+
if (!r)
|
|
186
|
+
return [];
|
|
187
|
+
mark('border-radius');
|
|
188
|
+
if (round(r.before) && !round(r.after))
|
|
189
|
+
return [`corners squared off (${r.before} → ${r.after})`];
|
|
190
|
+
if (!round(r.before) && round(r.after))
|
|
191
|
+
return ['corners fully rounded'];
|
|
192
|
+
return [`corner radius ${r.before} → ${r.after}`];
|
|
193
|
+
};
|
|
194
|
+
const colorRule = (m, mark, ctx) => {
|
|
195
|
+
const fields = [
|
|
196
|
+
['color', 'text'],
|
|
197
|
+
['background-color', 'background'],
|
|
198
|
+
['border-color', 'border colour'],
|
|
199
|
+
];
|
|
200
|
+
const sh = (c) => shift(c.before, c.after, ctx.tokensBefore, ctx.tokensAfter);
|
|
201
|
+
const present = fields.map(([p, w]) => [m.get(p), w]).filter(([c]) => c);
|
|
202
|
+
if (!present.length)
|
|
203
|
+
return [];
|
|
204
|
+
const [first] = present;
|
|
205
|
+
const same = present.length > 1 && present.every(([c]) => c.before === first[0].before && c.after === first[0].after);
|
|
206
|
+
fields.forEach(([p]) => m.has(p) && mark(p));
|
|
207
|
+
if (same)
|
|
208
|
+
return [`recoloured ${sh(first[0])}`];
|
|
209
|
+
return present.map(([c, w]) => `${w} ${sh(c)}`);
|
|
210
|
+
};
|
|
211
|
+
const fillRule = (m, mark) => {
|
|
212
|
+
const bgi = m.get('background-image');
|
|
213
|
+
if (!bgi)
|
|
214
|
+
return [];
|
|
215
|
+
mark('background-image');
|
|
216
|
+
const kind = (v) => /gradient/.test(v) ? 'a gradient' : /url\(/.test(v) ? 'an image' : v === 'none' ? 'no fill' : v;
|
|
217
|
+
return [`fill → ${kind(bgi.after)} (was ${kind(bgi.before)})`];
|
|
218
|
+
};
|
|
219
|
+
const effectsRule = (m, mark) => {
|
|
220
|
+
const out = [];
|
|
221
|
+
const sh = m.get('box-shadow');
|
|
222
|
+
if (sh) {
|
|
223
|
+
mark('box-shadow');
|
|
224
|
+
out.push(sh.before === 'none' ? 'gains a shadow' : sh.after === 'none' ? 'loses its shadow' : 'shadow changes');
|
|
225
|
+
}
|
|
226
|
+
const op = m.get('opacity');
|
|
227
|
+
if (op) {
|
|
228
|
+
mark('opacity');
|
|
229
|
+
out.push(`opacity ${op.before} → ${op.after}`);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
};
|
|
233
|
+
const weight = (v) => Number(v) || (v === 'bold' ? 700 : v === 'normal' ? 400 : NaN);
|
|
234
|
+
const typographyRule = (m, mark) => {
|
|
235
|
+
const out = [];
|
|
236
|
+
const fs = m.get('font-size');
|
|
237
|
+
if (fs) {
|
|
238
|
+
mark('font-size');
|
|
239
|
+
out.push(`text size ${fs.before} → ${fs.after}`);
|
|
240
|
+
}
|
|
241
|
+
const fw = m.get('font-weight');
|
|
242
|
+
if (fw) {
|
|
243
|
+
mark('font-weight');
|
|
244
|
+
const d = weight(fw.after) - weight(fw.before);
|
|
245
|
+
out.push(Number.isNaN(d) ? `weight ${fw.before} → ${fw.after}` : d > 0 ? 'bolder text' : 'lighter text');
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
};
|
|
249
|
+
const spacingRule = (m, mark) => {
|
|
250
|
+
const spacing = ['padding', 'margin', 'gap'].filter((p) => m.has(p));
|
|
251
|
+
spacing.forEach((p) => mark(p));
|
|
252
|
+
return spacing.length ? [`${spacing.join(' & ')} adjusted`] : [];
|
|
253
|
+
};
|
|
254
|
+
const RULES = [
|
|
255
|
+
layoutRule,
|
|
256
|
+
gridRule,
|
|
257
|
+
borderWidthRule,
|
|
258
|
+
borderRadiusRule,
|
|
259
|
+
colorRule,
|
|
260
|
+
fillRule,
|
|
261
|
+
effectsRule,
|
|
262
|
+
typographyRule,
|
|
263
|
+
spacingRule,
|
|
264
|
+
];
|
|
265
|
+
// Props that rarely matter to a visual reviewer — excluded from the "+N more"
|
|
266
|
+
// tail (they're still in the table) so a bullet stays signal, not noise.
|
|
267
|
+
const LOW_SIGNAL = new Set([
|
|
268
|
+
'font-family',
|
|
269
|
+
'letter-spacing',
|
|
270
|
+
'word-spacing',
|
|
271
|
+
'flex-grow',
|
|
272
|
+
'flex-shrink',
|
|
273
|
+
'flex-basis',
|
|
274
|
+
'object-fit',
|
|
275
|
+
'white-space',
|
|
276
|
+
'text-rendering',
|
|
277
|
+
'-webkit-font-smoothing',
|
|
278
|
+
'-webkit-box-orient',
|
|
279
|
+
'text-overflow',
|
|
280
|
+
'word-break',
|
|
281
|
+
'overflow-wrap',
|
|
282
|
+
]);
|
|
283
|
+
/** Build the English phrases for ONE element's deltas, capped so a bullet stays a
|
|
284
|
+
* glance: the top `cap` rule phrases (rules are priority-ordered), then a single
|
|
285
|
+
* "+N more" counting the rest (low-signal props excluded — they're in the table). */
|
|
286
|
+
function phrasesFor(props, ctx, cap = 4) {
|
|
287
|
+
const m = new Map(props.map((p) => [p.prop, p]));
|
|
288
|
+
const used = new Set();
|
|
289
|
+
const mark = (...ps) => ps.forEach((p) => used.add(p));
|
|
290
|
+
const phrases = RULES.flatMap((rule) => rule(m, mark, ctx));
|
|
291
|
+
const rest = [...m.keys()].filter((p) => !used.has(p) && !LOW_SIGNAL.has(p)).length;
|
|
292
|
+
const shown = phrases.slice(0, cap);
|
|
293
|
+
const overflow = phrases.length - shown.length + rest;
|
|
294
|
+
if (overflow > 0)
|
|
295
|
+
shown.push(`+${overflow} more`);
|
|
296
|
+
return shown;
|
|
297
|
+
}
|
|
298
|
+
/** Tally added/removed/retagged elements into "3 added" style lines. */
|
|
299
|
+
function domVerbLines(els) {
|
|
300
|
+
const verbs = {};
|
|
301
|
+
for (const el of els) {
|
|
302
|
+
const v = el.added ? 'added' : el.removed ? 'removed' : el.retagged ? 'retagged' : null;
|
|
303
|
+
if (v)
|
|
304
|
+
verbs[v] = (verbs[v] ?? 0) + 1;
|
|
305
|
+
}
|
|
306
|
+
return Object.entries(verbs).map(([verb, n]) => `**${n}** element${n === 1 ? '' : 's'} ${verb}`);
|
|
307
|
+
}
|
|
308
|
+
const sig = (phrases) => phrases.join(', ');
|
|
309
|
+
/** One line for a same-label group: the shared phrases (or the single line),
|
|
310
|
+
* flagged `(details vary)` when members aren't identical. */
|
|
311
|
+
function foldedLine(group, ctx) {
|
|
312
|
+
const lists = group.map((el) => phrasesFor(el.props, ctx));
|
|
313
|
+
if (lists.every((l) => sig(l) === sig(lists[0])))
|
|
314
|
+
return sig(lists[0]);
|
|
315
|
+
const common = lists[0].filter((p) => lists.every((l) => l.includes(p)));
|
|
316
|
+
return common.length ? `${common.join(', ')} _(details vary)_` : 'restyled _(details vary)_';
|
|
317
|
+
}
|
|
318
|
+
/** Restyle phrases, folded by element label (so two near-identical `span.led`s
|
|
319
|
+
* become one `×2` line with their shared changes), then deduped across labels (so
|
|
320
|
+
* the same line on different labels collapses to `… (×N)`). */
|
|
321
|
+
function restyleLines(els, ctx) {
|
|
322
|
+
const byLabel = new Map();
|
|
323
|
+
for (const el of els) {
|
|
324
|
+
if (el.added || el.removed)
|
|
325
|
+
continue; // values, not deltas — heading covers them
|
|
326
|
+
if (!phrasesFor(el.props, ctx).length)
|
|
327
|
+
continue;
|
|
328
|
+
const arr = byLabel.get(el.label) ?? [];
|
|
329
|
+
arr.push(el);
|
|
330
|
+
byLabel.set(el.label, arr);
|
|
331
|
+
}
|
|
332
|
+
const byLine = new Map();
|
|
333
|
+
const order = [];
|
|
334
|
+
for (const [label, group] of byLabel) {
|
|
335
|
+
const line = foldedLine(group, ctx);
|
|
336
|
+
if (!byLine.has(line)) {
|
|
337
|
+
byLine.set(line, { count: 0, labels: new Set() });
|
|
338
|
+
order.push(line);
|
|
339
|
+
}
|
|
340
|
+
const e = byLine.get(line);
|
|
341
|
+
e.count += group.length;
|
|
342
|
+
e.labels.add(label);
|
|
343
|
+
}
|
|
344
|
+
return order.map((line) => {
|
|
345
|
+
const { count, labels } = byLine.get(line);
|
|
346
|
+
if (labels.size > 1)
|
|
347
|
+
return `${line} _(×${count})_`;
|
|
348
|
+
const label = [...labels][0];
|
|
349
|
+
return `**\`${label}\`**${count > 1 ? ` ×${count}` : ''} — ${line}`;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Plain-English bullets for a crop's changes — DOM verbs, then labelled restyle
|
|
354
|
+
* phrases, then a flag for interaction-state changes a static screenshot can't
|
|
355
|
+
* show. Capped so the summary stays a glance (the exact tables live in the fold).
|
|
356
|
+
*/
|
|
357
|
+
export function describeChange(els, ctx = {}, maxBullets = 6) {
|
|
358
|
+
const lines = [...domVerbLines(els), ...restyleLines(els, ctx)];
|
|
359
|
+
const states = [...new Set(els.flatMap((e) => e.states ?? []))];
|
|
360
|
+
if (states.length)
|
|
361
|
+
lines.push(`interaction states changed: ${states.map((s) => `\`:${s}\``).join(', ')}`);
|
|
362
|
+
if (lines.length <= maxBullets)
|
|
363
|
+
return lines;
|
|
364
|
+
return [...lines.slice(0, maxBullets - 1), `…and ${lines.length - (maxBullets - 1)} more change(s)`];
|
|
365
|
+
}
|
package/dist/report.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type PropChange } from './diff.js';
|
|
2
|
+
export { describeChange, colorName, tokenIndex, toHex } 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, tokenIndex, toHex } 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, tokenIndex, toHex } 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,27 @@ 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
|
+
/** Headline counts with the zeros dropped — `0 state-delta difference(s)` is noise. */
|
|
28
|
+
function changeCountLabel(shown) {
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (shown.dom)
|
|
31
|
+
parts.push(`${shown.dom} DOM change(s)`);
|
|
32
|
+
if (shown.style)
|
|
33
|
+
parts.push(`${shown.style} computed-style difference(s)`);
|
|
34
|
+
if (shown.state)
|
|
35
|
+
parts.push(`${shown.state} state-delta difference(s)`);
|
|
36
|
+
return parts.join(' · ');
|
|
37
|
+
}
|
|
38
|
+
/** Group findings by their element path (one group per changed element). */
|
|
39
|
+
function groupByPath(findings) {
|
|
40
|
+
const byPath = new Map();
|
|
41
|
+
for (const f of findings) {
|
|
42
|
+
const arr = byPath.get(f.path) ?? [];
|
|
43
|
+
arr.push(f);
|
|
44
|
+
byPath.set(f.path, arr);
|
|
45
|
+
}
|
|
46
|
+
return [...byPath.values()];
|
|
47
|
+
}
|
|
23
48
|
function groupRegions(paths, a, b, padBy) {
|
|
24
49
|
const groups = paths.map((p) => {
|
|
25
50
|
const ra = a.elements[p]?.rect;
|
|
@@ -218,6 +243,13 @@ const CURRENTCOLOR_FOLLOWERS = [
|
|
|
218
243
|
'-webkit-text-fill-color',
|
|
219
244
|
'-webkit-text-stroke-color',
|
|
220
245
|
];
|
|
246
|
+
// "No value here" markers: a forced-state delta that doesn't apply, an unset
|
|
247
|
+
// longhand, or a capture artifact where a path didn't line up. A change BETWEEN
|
|
248
|
+
// two of these (e.g. `— → (gone)`) is meaningless and must never read as a diff.
|
|
249
|
+
const NON_VALUE = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)', '(gone)']);
|
|
250
|
+
const isNonValue = (v) => NON_VALUE.has(v);
|
|
251
|
+
/** Combine longhands into a shorthand value; all-non-value sides collapse to one. */
|
|
252
|
+
const combineValues = (vals) => (vals.every(isNonValue) ? '(unset)' : vals.join(' '));
|
|
221
253
|
export function summarizeProps(props) {
|
|
222
254
|
const map = new Map(props.map((p) => [p.prop, { ...p }]));
|
|
223
255
|
for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
|
|
@@ -273,13 +305,16 @@ export function summarizeProps(props) {
|
|
|
273
305
|
map.delete('outline-color');
|
|
274
306
|
map.set('outline', {
|
|
275
307
|
prop: 'outline',
|
|
276
|
-
before:
|
|
277
|
-
after:
|
|
308
|
+
before: combineValues([ow.before, os.before, oc.before]),
|
|
309
|
+
after: combineValues([ow.after, os.after, oc.after]),
|
|
278
310
|
});
|
|
279
311
|
}
|
|
280
|
-
return [...map.values()]
|
|
312
|
+
return ([...map.values()]
|
|
281
313
|
.map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
|
|
282
|
-
|
|
314
|
+
// Drop no-ops: a value that didn't actually change, or a change between two
|
|
315
|
+
// "no value here" markers (`— → (gone)`), which carries no information.
|
|
316
|
+
.filter((p) => p.before !== p.after && !(isNonValue(p.before) && isNonValue(p.after)))
|
|
317
|
+
.sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop)));
|
|
283
318
|
}
|
|
284
319
|
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
285
320
|
export function prettyLabel(p, cls) {
|
|
@@ -351,10 +386,9 @@ function regionHeading(regionPaths, findings) {
|
|
|
351
386
|
const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
|
|
352
387
|
return `${label} · ${groupTitle(findings)}`;
|
|
353
388
|
}
|
|
354
|
-
// A
|
|
355
|
-
//
|
|
356
|
-
const
|
|
357
|
-
const cell = (v) => (STATE_PLACEHOLDER.has(v) ? '—' : `\`${v}\``);
|
|
389
|
+
// A "no value here" marker renders as an em dash; colours render as `#hex` so the
|
|
390
|
+
// table cell shows GitHub's live swatch.
|
|
391
|
+
const cell = (v) => (isNonValue(v) ? '—' : `\`${toHex(v)}\``);
|
|
358
392
|
function beforeAfterTable(rows) {
|
|
359
393
|
return [
|
|
360
394
|
'| Property | Before | After |',
|
|
@@ -406,15 +440,9 @@ function renderOneElement(group) {
|
|
|
406
440
|
* "before"); an existing element shows before → after.
|
|
407
441
|
*/
|
|
408
442
|
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
443
|
const blocks = [];
|
|
416
444
|
const bySig = new Map();
|
|
417
|
-
for (const group of
|
|
445
|
+
for (const group of groupByPath(findings)) {
|
|
418
446
|
const el = renderOneElement(group);
|
|
419
447
|
if (!el)
|
|
420
448
|
continue;
|
|
@@ -439,25 +467,6 @@ function renderElements(findings, maxElements = 40) {
|
|
|
439
467
|
}
|
|
440
468
|
return out;
|
|
441
469
|
}
|
|
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
470
|
/** Plain-text `<summary>` affordance — GitHub renders markdown inside `<summary>`
|
|
462
471
|
* literally, so no backticks or bold here. */
|
|
463
472
|
function foldSummary(findings) {
|
|
@@ -466,31 +475,24 @@ function foldSummary(findings) {
|
|
|
466
475
|
return 'Show details';
|
|
467
476
|
return n === 1 ? 'Show the property change' : `Show all ${n} property changes`;
|
|
468
477
|
}
|
|
469
|
-
/** Render a crop's changes:
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
* tables collapse; ≤ 0 folds
|
|
474
|
-
|
|
478
|
+
/** Render a crop's changes: plain-English bullets that tell the reviewer what to
|
|
479
|
+
* look for, then the exact property tables — folded under a toggle once they would
|
|
480
|
+
* be a wall (the screenshot and approval checkbox above always stay visible).
|
|
481
|
+
* Blank lines around the table block are mandatory or GitHub prints the tables as
|
|
482
|
+
* literal text. `foldAt` is the row count at which the tables collapse; ≤ 0 folds
|
|
483
|
+
* always, Infinity never. */
|
|
484
|
+
function renderCropChanges(findings, foldAt, ctx) {
|
|
475
485
|
const tables = renderElements(findings);
|
|
476
486
|
if (!tables.length)
|
|
477
487
|
return [];
|
|
478
488
|
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).
|
|
489
|
+
// Small enough to read at a glance: the tables speak for themselves.
|
|
481
490
|
if (rows < foldAt)
|
|
482
491
|
return tables;
|
|
483
|
-
// Folded:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
'',
|
|
488
|
-
'<details>',
|
|
489
|
-
`<summary>${foldSummary(findings)}</summary>`,
|
|
490
|
-
...tables,
|
|
491
|
-
'',
|
|
492
|
-
'</details>',
|
|
493
|
-
];
|
|
492
|
+
// Folded: plain-English bullets are the visible stand-in for what the toggle hides.
|
|
493
|
+
const bullets = describeChange(buildElementChanges(findings), ctx);
|
|
494
|
+
const summary = bullets.length ? bullets.map((b) => `- ${b}`) : ['_see changes_'];
|
|
495
|
+
return ['', ...summary, '', '<details>', `<summary>${foldSummary(findings)}</summary>`, ...tables, '', '</details>'];
|
|
494
496
|
}
|
|
495
497
|
// Computed values that follow from an element's box size or position rather than
|
|
496
498
|
// its styling. On any reflow they change all the way up the ancestor chain
|
|
@@ -519,20 +521,82 @@ const DERIVED_PROPS = new Set([
|
|
|
519
521
|
'inset-inline-start',
|
|
520
522
|
'inset-inline-end',
|
|
521
523
|
]);
|
|
522
|
-
|
|
523
|
-
|
|
524
|
+
// Props stripped from forced :hover/:focus/:active deltas specifically. Layout
|
|
525
|
+
// and grid-track values that shift when a state forces a relayout are capture
|
|
526
|
+
// noise, not interaction feedback — a state finding is meant to catch a changed
|
|
527
|
+
// hover/focus/active *style* (colour, outline, shadow), not a reflow.
|
|
528
|
+
const STATE_STRIP = new Set([
|
|
529
|
+
...DERIVED_PROPS,
|
|
530
|
+
'grid-template-columns',
|
|
531
|
+
'grid-template-rows',
|
|
532
|
+
'grid-template-areas',
|
|
533
|
+
'grid-auto-columns',
|
|
534
|
+
'grid-auto-rows',
|
|
535
|
+
'grid-auto-flow',
|
|
536
|
+
]);
|
|
537
|
+
/**
|
|
538
|
+
* Strip the noise the visual report shouldn't carry, cross-referencing each
|
|
539
|
+
* element's layers so the forced-state layer stops echoing the base:
|
|
540
|
+
* - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
|
|
541
|
+
* - forced states: drop derived + grid-track props, drop a delta the BASE
|
|
542
|
+
* already changed (a `:hover color` that just follows a recoloured base is an
|
|
543
|
+
* echo, not a dropped variant), and drop non-value↔non-value rows;
|
|
544
|
+
* - any finding left with no props is removed entirely.
|
|
545
|
+
*/
|
|
546
|
+
function cleanFindings(findings) {
|
|
547
|
+
const out = [];
|
|
548
|
+
for (const group of groupByPath(findings)) {
|
|
549
|
+
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
550
|
+
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
551
|
+
for (const f of group) {
|
|
552
|
+
if (f.kind === 'dom') {
|
|
553
|
+
out.push(f);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const props = f.kind === 'style'
|
|
557
|
+
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
558
|
+
: f.props.filter((p) => !STATE_STRIP.has(p.prop) && !baseChanged.has(p.prop) && !(isNonValue(p.before) && isNonValue(p.after)));
|
|
559
|
+
if (props.length)
|
|
560
|
+
out.push({ ...f, props });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
/** Per-element view for the plain-English summariser: the base deltas (summarised)
|
|
566
|
+
* plus which interactive states genuinely changed. */
|
|
567
|
+
function buildElementChanges(findings) {
|
|
568
|
+
const els = [];
|
|
569
|
+
for (const group of groupByPath(findings)) {
|
|
570
|
+
const dom = group.find((f) => f.kind === 'dom');
|
|
571
|
+
const styleProps = group
|
|
572
|
+
.filter((f) => f.kind === 'style')
|
|
573
|
+
.flatMap((f) => f.props);
|
|
574
|
+
els.push({
|
|
575
|
+
label: prettyLabel(group[0].path, group[0].cls),
|
|
576
|
+
added: dom?.change === 'added',
|
|
577
|
+
removed: dom?.change === 'removed',
|
|
578
|
+
retagged: dom?.change === 'retagged',
|
|
579
|
+
props: summarizeProps(styleProps),
|
|
580
|
+
states: [
|
|
581
|
+
...new Set(group.filter((f) => f.kind === 'state').map((f) => f.state)),
|
|
582
|
+
],
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return els;
|
|
586
|
+
}
|
|
524
587
|
export function generateStyleMapReport(opts) {
|
|
525
588
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, foldDetailsAt = 0, } = opts;
|
|
526
589
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
527
590
|
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
528
591
|
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
|
|
592
|
+
// Focus each surface on styling intent: drop reflow-casualty props, suppress
|
|
593
|
+
// forced-state echoes of base changes, and remove non-value noise (see
|
|
594
|
+
// cleanFindings), unless includeLayoutNoise is set. Surfaces left with no real
|
|
595
|
+
// change are dropped.
|
|
532
596
|
const prepared = surfaces
|
|
533
597
|
.map((sd) => ({
|
|
534
598
|
sd,
|
|
535
|
-
findings: sd.missing || includeNoise ? sd.findings : sd.findings
|
|
599
|
+
findings: sd.missing || includeNoise ? sd.findings : cleanFindings(sd.findings),
|
|
536
600
|
}))
|
|
537
601
|
.filter((p) => p.sd.missing || p.findings.length > 0);
|
|
538
602
|
// Group surfaces that changed in the SAME way (the rects differ per width; the
|
|
@@ -580,8 +644,7 @@ export function generateStyleMapReport(opts) {
|
|
|
580
644
|
}
|
|
581
645
|
else {
|
|
582
646
|
if (changeGroups.length > 0) {
|
|
583
|
-
md.push(`**${shown
|
|
584
|
-
`across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
|
|
647
|
+
md.push(`**${changeCountLabel(shown)}** across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
|
|
585
648
|
}
|
|
586
649
|
if (missing.length > 0) {
|
|
587
650
|
if (changeGroups.length > 0)
|
|
@@ -600,6 +663,8 @@ export function generateStyleMapReport(opts) {
|
|
|
600
663
|
totalFindings += surfaceFindings.length;
|
|
601
664
|
const mapA = loadStyleMap(findCapture(beforeDir, sd.surface));
|
|
602
665
|
const mapB = loadStyleMap(findCapture(afterDir, sd.surface));
|
|
666
|
+
// Theme-token reverse-indexes so colour changes can name `red-200` per side.
|
|
667
|
+
const describeCtx = { tokensBefore: tokenIndex(mapA.tokens), tokensAfter: tokenIndex(mapB.tokens) };
|
|
603
668
|
const pngA = readPng(path.join(beforeDir, `${sd.surface}.png`));
|
|
604
669
|
const pngB = readPng(path.join(afterDir, `${sd.surface}.png`));
|
|
605
670
|
const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
|
|
@@ -634,21 +699,27 @@ export function generateStyleMapReport(opts) {
|
|
|
634
699
|
const region = visible(g.after) ? g.after : g.before;
|
|
635
700
|
let images = {};
|
|
636
701
|
if (region && pngA && pngB) {
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
702
|
+
// Crop the SAME page rectangle from both sides — the union of where the
|
|
703
|
+
// change sits on each side — so the pair lines up exactly and the reviewer
|
|
704
|
+
// compares like-for-like instead of playing spot-the-difference. (Centring
|
|
705
|
+
// each side on its own moved box would shift the background between them.)
|
|
706
|
+
const cropBox = visible(g.before) && visible(g.after) ? union(g.before, g.after) : region;
|
|
707
|
+
const w = Math.max(minWidth, cropBox.w);
|
|
708
|
+
const h = Math.min(maxHeight, Math.max(minHeight, cropBox.h));
|
|
640
709
|
// Path-safe, report-unique stem: `hero@1280` → `hero-1280-3` so relative
|
|
641
710
|
// image links resolve cleanly and two crops never collide on one filename.
|
|
642
711
|
const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
|
|
643
|
-
const before = cropPng(pngA,
|
|
644
|
-
const after = cropPng(pngB,
|
|
712
|
+
const before = cropPng(pngA, cropBox, w, h);
|
|
713
|
+
const after = cropPng(pngB, cropBox, w, h);
|
|
645
714
|
const composite = compositePair(before, after);
|
|
646
715
|
writePng(path.join(outDir, `${stem}-before.png`), before);
|
|
647
716
|
writePng(path.join(outDir, `${stem}-after.png`), after);
|
|
648
717
|
writePng(path.join(outDir, `${stem}-composite.png`), composite);
|
|
649
718
|
images = { before: `${stem}-before.png`, after: `${stem}-after.png`, composite: `${stem}-composite.png` };
|
|
650
|
-
// One side-by-side image per crop
|
|
651
|
-
|
|
719
|
+
// One side-by-side image per crop, left as a PLAIN image (not wrapped in a
|
|
720
|
+
// link) so GitHub's built-in lightbox opens it in a zoomable popup on click —
|
|
721
|
+
// a link wrapper would navigate to the file instead.
|
|
722
|
+
md.push('', `})`, '', `<sub>◀ before · after ▶ — ${sd.surface} · click to zoom</sub>`);
|
|
652
723
|
}
|
|
653
724
|
else if (!region) {
|
|
654
725
|
md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
|
|
@@ -656,9 +727,9 @@ export function generateStyleMapReport(opts) {
|
|
|
656
727
|
else {
|
|
657
728
|
md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
|
|
658
729
|
}
|
|
659
|
-
// What this crop changed:
|
|
660
|
-
//
|
|
661
|
-
md.push(...renderCropChanges(regionFindings, foldDetailsAt));
|
|
730
|
+
// What this crop changed: plain-English bullets, then the property tables —
|
|
731
|
+
// folded under a toggle once they'd be a wall (foldDetailsAt).
|
|
732
|
+
md.push(...renderCropChanges(regionFindings, foldDetailsAt, describeCtx));
|
|
662
733
|
surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
|
|
663
734
|
}
|
|
664
735
|
surfaceJson.findings = surfaceFindings;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|