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 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
  ![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 — 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
- `background-color` `rgb(95, 202, 219)` → `rgb(245, 158, 11)`
34
+ - **`a.btn-solid`** — background `brand-cyan` (`#5fcadb`) → `brand-amber` (`#f59e0b`)
35
35
 
36
36
  ▾ Show the property change
37
- | Property | Before | After |
38
- | background-color | rgb(95, 202, 219) | rgb(245, 158, 11) |
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[];
@@ -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 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, 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: `${ow.before} ${os.before} ${oc.before}`,
277
- after: `${ow.after} ${os.after} ${oc.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
- .sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop));
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 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}\``);
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 byPath.values()) {
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: 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. */
474
- function renderCropChanges(findings, foldAt) {
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, no essence
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: 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
- ];
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
- 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)) };
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 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.
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.filter(hasRealChange).map(stripDerived),
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.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** ` +
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
- // 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));
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, visible(g.before) ? g.before : region, w, h);
644
- const after = cropPng(pngB, visible(g.after) ? g.after : region, w, h);
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: clean inline render, single upload.
651
- md.push('', `![before after](${img(images.composite)})`, '', `<sub>◀ before · after ${sd.surface}</sub>`);
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('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<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: a scannable essence line, then the property
660
- // tables — folded under a toggle once they'd be a wall (foldDetailsAt).
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.5.0",
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",