styleproof 1.6.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,37 @@ 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
+
10
41
  ## [1.6.0]
11
42
 
12
43
  The report tells you what to look for, in plain English, and stops being a
package/README.md CHANGED
@@ -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 (a plain-English bullet first, then the exact table 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
- - **`a.btn-solid`** — background cyan → amber
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`. */
@@ -20,9 +20,19 @@ export type ElementChange = {
20
20
  };
21
21
  /** A short colour word for an rgb/rgba value: "cyan", "dark blue", "transparent". */
22
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
+ };
23
33
  /**
24
34
  * Plain-English bullets for a crop's changes — DOM verbs, then labelled restyle
25
35
  * phrases, then a flag for interaction-state changes a static screenshot can't
26
36
  * show. Capped so the summary stays a glance (the exact tables live in the fold).
27
37
  */
28
- export declare function describeChange(els: ElementChange[], maxBullets?: number): string[];
38
+ export declare function describeChange(els: ElementChange[], ctx?: DescribeCtx, maxBullets?: number): string[];
package/dist/describe.js CHANGED
@@ -51,10 +51,69 @@ export function colorName(v) {
51
51
  const qual = chromatic ? (lum > 200 ? 'light ' : lum < 70 ? 'dark ' : '') : '';
52
52
  return `${qual}${best}`;
53
53
  }
54
- const shift = (before, after) => {
55
- const cb = colorName(before);
56
- const ca = colorName(after);
57
- return cb && ca ? `${cb} → ${ca}` : `${before} → ${after}`;
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)}`;
58
117
  };
59
118
  // --- track counting for grid columns/rows ------------------------------------
60
119
  /** Count grid tracks in a `grid-template-*` value, honouring the `Npx ×K` form. */
@@ -98,18 +157,23 @@ const gridRule = (m, mark) => {
98
157
  continue;
99
158
  mark(prop);
100
159
  const [b, a] = [trackCount(g.before), trackCount(g.after)];
101
- if (b !== a && (b > 0 || a > 0))
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))) {
102
164
  out.push(`**${word}: ${b} → ${a}**`);
165
+ }
103
166
  }
104
167
  return out;
105
168
  };
169
+ const noBorder = (v) => /^0/.test(v) || v === '(unset)';
106
170
  const borderWidthRule = (m, mark) => {
107
171
  const bw = m.get('border-width');
108
172
  if (!bw)
109
173
  return [];
110
174
  mark('border-width', 'border-style', 'border-color');
111
- const wasZero = /^0/.test(bw.before);
112
- const isZero = /^0/.test(bw.after);
175
+ const wasZero = noBorder(bw.before);
176
+ const isZero = noBorder(bw.after);
113
177
  if (wasZero && !isZero)
114
178
  return [`gains a ${bw.after} border`];
115
179
  if (!wasZero && isZero)
@@ -127,12 +191,13 @@ const borderRadiusRule = (m, mark) => {
127
191
  return ['corners fully rounded'];
128
192
  return [`corner radius ${r.before} → ${r.after}`];
129
193
  };
130
- const colorRule = (m, mark) => {
194
+ const colorRule = (m, mark, ctx) => {
131
195
  const fields = [
132
196
  ['color', 'text'],
133
197
  ['background-color', 'background'],
134
198
  ['border-color', 'border colour'],
135
199
  ];
200
+ const sh = (c) => shift(c.before, c.after, ctx.tokensBefore, ctx.tokensAfter);
136
201
  const present = fields.map(([p, w]) => [m.get(p), w]).filter(([c]) => c);
137
202
  if (!present.length)
138
203
  return [];
@@ -140,8 +205,8 @@ const colorRule = (m, mark) => {
140
205
  const same = present.length > 1 && present.every(([c]) => c.before === first[0].before && c.after === first[0].after);
141
206
  fields.forEach(([p]) => m.has(p) && mark(p));
142
207
  if (same)
143
- return [`recoloured ${shift(first[0].before, first[0].after)}`];
144
- return present.map(([c, w]) => `${w} ${shift(c.before, c.after)}`);
208
+ return [`recoloured ${sh(first[0])}`];
209
+ return present.map(([c, w]) => `${w} ${sh(c)}`);
145
210
  };
146
211
  const fillRule = (m, mark) => {
147
212
  const bgi = m.get('background-image');
@@ -197,17 +262,38 @@ const RULES = [
197
262
  typographyRule,
198
263
  spacingRule,
199
264
  ];
200
- /** Build the English phrases for ONE element's property deltas. */
201
- function phrasesFor(props) {
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) {
202
287
  const m = new Map(props.map((p) => [p.prop, p]));
203
288
  const used = new Set();
204
289
  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;
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;
211
297
  }
212
298
  /** Tally added/removed/retagged elements into "3 added" style lines. */
213
299
  function domVerbLines(els) {
@@ -219,27 +305,48 @@ function domVerbLines(els) {
219
305
  }
220
306
  return Object.entries(verbs).map(([verb, n]) => `**${n}** element${n === 1 ? '' : 's'} ${verb}`);
221
307
  }
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 = [];
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();
228
323
  for (const el of els) {
229
324
  if (el.added || el.removed)
230
325
  continue; // values, not deltas — heading covers them
231
- const line = phrasesFor(el.props).join(', ');
232
- if (!line)
326
+ if (!phrasesFor(el.props, ctx).length)
233
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);
234
336
  if (!byLine.has(line)) {
235
- byLine.set(line, { count: 0, label: el.label });
337
+ byLine.set(line, { count: 0, labels: new Set() });
236
338
  order.push(line);
237
339
  }
238
- byLine.get(line).count++;
340
+ const e = byLine.get(line);
341
+ e.count += group.length;
342
+ e.labels.add(label);
239
343
  }
240
344
  return order.map((line) => {
241
- const { count, label } = byLine.get(line);
242
- return count > 1 ? `${line} _(×${count})_` : `**\`${label}\`** — ${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}`;
243
350
  });
244
351
  }
245
352
  /**
@@ -247,8 +354,8 @@ function restyleLines(els) {
247
354
  * phrases, then a flag for interaction-state changes a static screenshot can't
248
355
  * show. Capped so the summary stays a glance (the exact tables live in the fold).
249
356
  */
250
- export function describeChange(els, maxBullets = 6) {
251
- const lines = [...domVerbLines(els), ...restyleLines(els)];
357
+ export function describeChange(els, ctx = {}, maxBullets = 6) {
358
+ const lines = [...domVerbLines(els), ...restyleLines(els, ctx)];
252
359
  const states = [...new Set(els.flatMap((e) => e.states ?? []))];
253
360
  if (states.length)
254
361
  lines.push(`interaction states changed: ${states.map((s) => `\`:${s}\``).join(', ')}`);
package/dist/report.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type PropChange } from './diff.js';
2
- export { describeChange, colorName } from './describe.js';
2
+ export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
3
3
  /**
4
4
  * Visual diff report: for every surface with findings, crop the before/after
5
5
  * full-page screenshots around the changed elements and write a markdown
package/dist/report.js CHANGED
@@ -3,10 +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';
6
+ import { describeChange, tokenIndex, toHex } from './describe.js';
7
7
  // Re-export the plain-English summariser so consumers (and tests) reach it
8
8
  // through the package's report module rather than a deep path.
9
- export { describeChange, colorName } from './describe.js';
9
+ export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
10
10
  // Hidden marker appended to a new-surface heading. Invisible in rendered
11
11
  // markdown; lets the PR-comment layer attach an OPTIONAL "approve" box to a new
12
12
  // surface (vs the required box on a real change), so new surfaces never gate.
@@ -24,6 +24,17 @@ const visible = (b) => !!b && b.w > 0 && b.h > 0;
24
24
  function outermost(paths) {
25
25
  return paths.filter((p) => !paths.some((q) => q !== p && p.startsWith(q + ' > ')));
26
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
+ }
27
38
  /** Group findings by their element path (one group per changed element). */
28
39
  function groupByPath(findings) {
29
40
  const byPath = new Map();
@@ -375,8 +386,9 @@ function regionHeading(regionPaths, findings) {
375
386
  const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
376
387
  return `${label} · ${groupTitle(findings)}`;
377
388
  }
378
- // A "no value here" marker renders as an em dash rather than its literal text.
379
- const cell = (v) => (isNonValue(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)}\``);
380
392
  function beforeAfterTable(rows) {
381
393
  return [
382
394
  '| Property | Before | After |',
@@ -469,7 +481,7 @@ function foldSummary(findings) {
469
481
  * Blank lines around the table block are mandatory or GitHub prints the tables as
470
482
  * literal text. `foldAt` is the row count at which the tables collapse; ≤ 0 folds
471
483
  * always, Infinity never. */
472
- function renderCropChanges(findings, foldAt) {
484
+ function renderCropChanges(findings, foldAt, ctx) {
473
485
  const tables = renderElements(findings);
474
486
  if (!tables.length)
475
487
  return [];
@@ -478,7 +490,7 @@ function renderCropChanges(findings, foldAt) {
478
490
  if (rows < foldAt)
479
491
  return tables;
480
492
  // Folded: plain-English bullets are the visible stand-in for what the toggle hides.
481
- const bullets = describeChange(buildElementChanges(findings));
493
+ const bullets = describeChange(buildElementChanges(findings), ctx);
482
494
  const summary = bullets.length ? bullets.map((b) => `- ${b}`) : ['_see changes_'];
483
495
  return ['', ...summary, '', '<details>', `<summary>${foldSummary(findings)}</summary>`, ...tables, '', '</details>'];
484
496
  }
@@ -632,8 +644,7 @@ export function generateStyleMapReport(opts) {
632
644
  }
633
645
  else {
634
646
  if (changeGroups.length > 0) {
635
- md.push(`**${shown.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** ` +
636
- `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).`);
637
648
  }
638
649
  if (missing.length > 0) {
639
650
  if (changeGroups.length > 0)
@@ -652,6 +663,8 @@ export function generateStyleMapReport(opts) {
652
663
  totalFindings += surfaceFindings.length;
653
664
  const mapA = loadStyleMap(findCapture(beforeDir, sd.surface));
654
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) };
655
668
  const pngA = readPng(path.join(beforeDir, `${sd.surface}.png`));
656
669
  const pngB = readPng(path.join(afterDir, `${sd.surface}.png`));
657
670
  const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
@@ -703,8 +716,10 @@ export function generateStyleMapReport(opts) {
703
716
  writePng(path.join(outDir, `${stem}-after.png`), after);
704
717
  writePng(path.join(outDir, `${stem}-composite.png`), composite);
705
718
  images = { before: `${stem}-before.png`, after: `${stem}-after.png`, composite: `${stem}-composite.png` };
706
- // One side-by-side image per crop: clean inline render, single upload.
707
- 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>`);
708
723
  }
709
724
  else if (!region) {
710
725
  md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
@@ -714,7 +729,7 @@ export function generateStyleMapReport(opts) {
714
729
  }
715
730
  // What this crop changed: plain-English bullets, then the property tables —
716
731
  // folded under a toggle once they'd be a wall (foldDetailsAt).
717
- md.push(...renderCropChanges(regionFindings, foldDetailsAt));
732
+ md.push(...renderCropChanges(regionFindings, foldDetailsAt, describeCtx));
718
733
  surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
719
734
  }
720
735
  surfaceJson.findings = surfaceFindings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.6.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",