styleproof 1.5.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 CHANGED
@@ -7,6 +7,41 @@ 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
+
10
45
  ## [1.5.0]
11
46
 
12
47
  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,13 +25,13 @@ One change — the hero CTA recoloured cyan → amber — posts as a single sect
25
25
 
26
26
  ![A StyleProof report: the CTA button before (cyan) and after (amber), side by side](docs/demo-composite.png)
27
27
 
28
- As it renders in the PR comment (colours become live swatches; the full table sits inside the toggle):
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
- `background-color` `rgb(95, 202, 219)``rgb(245, 158, 11)`
34
+ - **`a.btn-solid`** background cyan amber
35
35
 
36
36
  ▾ Show the property change
37
37
  | Property | Before | After |
@@ -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[];
@@ -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/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 identical dimensions
10
- * (centered on each side's own coordinates) so the pair stays comparable
11
- * even when the change moved things around.
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 essence line and screenshot stay visible). Set
31
- * to e.g. 5 to keep small changes inline and fold only verbose ones, or
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: `${ow.before} ${os.before} ${oc.before}`,
277
- after: `${ow.after} ${os.after} ${oc.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
- .sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop));
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 diff state row whose value is one of these placeholders means "this state
355
- // has no effect here" meaningless to show, so render it as an em dash.
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 byPath.values()) {
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: the essence line, then the property tables — folded
470
- * under a toggle once they would be a wall (the screenshot and approval checkbox
471
- * above always stay visible). Blank lines around the table block are mandatory or
472
- * GitHub prints the tables as literal text. `foldAt` is the row count at which the
473
- * tables collapse; ≤ 0 folds always, Infinity never. */
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, no essence
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: the essence line is the visible stand-in for what the toggle hides.
484
- return [
485
- '',
486
- changeEssence(findings),
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
- const hasRealChange = (f) => f.kind === 'dom' || f.props.some((p) => !DERIVED_PROPS.has(p.prop));
523
- const stripDerived = (f) => f.kind === 'dom' ? f : { ...f, props: f.props.filter((p) => !DERIVED_PROPS.has(p.prop)) };
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
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 elements (only
530
- // size/position-derived changes) and strip those props from the rest, unless
531
- // includeLayoutNoise is set. Surfaces left with no real change are dropped.
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.filter(hasRealChange).map(stripDerived),
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
@@ -634,14 +686,18 @@ export function generateStyleMapReport(opts) {
634
686
  const region = visible(g.after) ? g.after : g.before;
635
687
  let images = {};
636
688
  if (region && pngA && pngB) {
637
- // Same crop dimensions on both sides so the pair reads as a pair.
638
- const w = Math.max(minWidth, visible(g.before) ? g.before.w : 0, visible(g.after) ? g.after.w : 0);
639
- const h = Math.min(maxHeight, Math.max(minHeight, visible(g.before) ? g.before.h : 0, visible(g.after) ? g.after.h : 0));
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));
640
696
  // Path-safe, report-unique stem: `hero@1280` → `hero-1280-3` so relative
641
697
  // image links resolve cleanly and two crops never collide on one filename.
642
698
  const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
643
- const before = cropPng(pngA, visible(g.before) ? g.before : region, w, h);
644
- const after = cropPng(pngB, visible(g.after) ? g.after : region, w, h);
699
+ const before = cropPng(pngA, cropBox, w, h);
700
+ const after = cropPng(pngB, cropBox, w, h);
645
701
  const composite = compositePair(before, after);
646
702
  writePng(path.join(outDir, `${stem}-before.png`), before);
647
703
  writePng(path.join(outDir, `${stem}-after.png`), after);
@@ -656,8 +712,8 @@ export function generateStyleMapReport(opts) {
656
712
  else {
657
713
  md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
658
714
  }
659
- // What this crop changed: a scannable essence line, then the property
660
- // tables — folded under a toggle once they'd be a wall (foldDetailsAt).
715
+ // What this crop changed: plain-English bullets, then the property tables —
716
+ // folded under a toggle once they'd be a wall (foldDetailsAt).
661
717
  md.push(...renderCropChanges(regionFindings, foldDetailsAt));
662
718
  surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
663
719
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.5.0",
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",