styleproof 1.6.0 → 1.7.1

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,47 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.7.1]
11
+
12
+ ### Fixed
13
+
14
+ - A colour bullet no longer repeats a role word that matches its token name
15
+ (`text \`text\` (#bfe9f5)` becomes `` `text` (`#bfe9f5`) ``).
16
+ - When several same-label elements fold but share no change, the line names the
17
+ most common changes (`e.g. … _(vary)_`) instead of an uninformative
18
+ `restyled _(details vary)_`.
19
+
20
+ ## [1.7.0]
21
+
22
+ Colour changes are named by their theme token, shown as hex with a live swatch,
23
+ and the bullets stay a glance.
24
+
25
+ ### Added
26
+
27
+ - **Theme-token linking.** A colour change now names the design token behind it —
28
+ ``background `red-100` (`#fee2e2`) → `red-200` (`#fecaca`)`` — not just the raw
29
+ value. Computed styles lose the `var(--token)` reference, so the capture now
30
+ records the colour-valued `:root` custom properties (`StyleMap.tokens`,
31
+ normalised to `rgb`) and the report matches a changed value back to its token by
32
+ value, preferring the scale step (`red-200`) over an alias.
33
+ - **Hex everywhere, with swatches.** Every colour renders as `#hex` (translucent
34
+ ones stay `rgba`), in both the bullets and the property tables, so GitHub draws
35
+ its live colour swatch next to each value in the PR comment.
36
+ - **Click any crop to enlarge.** Each before/after composite is wrapped in a link
37
+ to the full-resolution image, so a click opens it full size to zoom.
38
+
39
+ ### Changed
40
+
41
+ - **Tighter bullets.** Each element is capped to its few most-visible changes
42
+ (then `+N more`); low-signal props (`font-family`, `letter-spacing`, …) are kept
43
+ out of the count; near-identical same-label elements fold to one `×N` line with
44
+ their shared changes (`details vary` when they don't match).
45
+ - **No more `white → white`.** When two colours round to the same word, the bullet
46
+ shows just the hex so a subtle change doesn't read as a no-op.
47
+ - **Headline drops zero counts** (`0 state-delta difference(s)` was noise), and a
48
+ grid that becomes flex no longer prints a confusing `columns: 3 → 0` — the layout
49
+ rule names it.
50
+
10
51
  ## [1.6.0]
11
52
 
12
53
  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,15 @@ 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);
201
+ // Collapse a role word that just repeats the token name: `text \`text\`` → `\`text\``.
202
+ const deRole = (s) => s.replace(/([\w-]+) (`\1`)/g, '$2');
136
203
  const present = fields.map(([p, w]) => [m.get(p), w]).filter(([c]) => c);
137
204
  if (!present.length)
138
205
  return [];
@@ -140,8 +207,8 @@ const colorRule = (m, mark) => {
140
207
  const same = present.length > 1 && present.every(([c]) => c.before === first[0].before && c.after === first[0].after);
141
208
  fields.forEach(([p]) => m.has(p) && mark(p));
142
209
  if (same)
143
- return [`recoloured ${shift(first[0].before, first[0].after)}`];
144
- return present.map(([c, w]) => `${w} ${shift(c.before, c.after)}`);
210
+ return [deRole(`recoloured ${sh(first[0])}`)];
211
+ return present.map(([c, w]) => deRole(`${w} ${sh(c)}`));
145
212
  };
146
213
  const fillRule = (m, mark) => {
147
214
  const bgi = m.get('background-image');
@@ -197,17 +264,38 @@ const RULES = [
197
264
  typographyRule,
198
265
  spacingRule,
199
266
  ];
200
- /** Build the English phrases for ONE element's property deltas. */
201
- function phrasesFor(props) {
267
+ // Props that rarely matter to a visual reviewer excluded from the "+N more"
268
+ // tail (they're still in the table) so a bullet stays signal, not noise.
269
+ const LOW_SIGNAL = new Set([
270
+ 'font-family',
271
+ 'letter-spacing',
272
+ 'word-spacing',
273
+ 'flex-grow',
274
+ 'flex-shrink',
275
+ 'flex-basis',
276
+ 'object-fit',
277
+ 'white-space',
278
+ 'text-rendering',
279
+ '-webkit-font-smoothing',
280
+ '-webkit-box-orient',
281
+ 'text-overflow',
282
+ 'word-break',
283
+ 'overflow-wrap',
284
+ ]);
285
+ /** Build the English phrases for ONE element's deltas, capped so a bullet stays a
286
+ * glance: the top `cap` rule phrases (rules are priority-ordered), then a single
287
+ * "+N more" counting the rest (low-signal props excluded — they're in the table). */
288
+ function phrasesFor(props, ctx, cap = 4) {
202
289
  const m = new Map(props.map((p) => [p.prop, p]));
203
290
  const used = new Set();
204
291
  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;
292
+ const phrases = RULES.flatMap((rule) => rule(m, mark, ctx));
293
+ const rest = [...m.keys()].filter((p) => !used.has(p) && !LOW_SIGNAL.has(p)).length;
294
+ const shown = phrases.slice(0, cap);
295
+ const overflow = phrases.length - shown.length + rest;
296
+ if (overflow > 0)
297
+ shown.push(`+${overflow} more`);
298
+ return shown;
211
299
  }
212
300
  /** Tally added/removed/retagged elements into "3 added" style lines. */
213
301
  function domVerbLines(els) {
@@ -219,27 +307,59 @@ function domVerbLines(els) {
219
307
  }
220
308
  return Object.entries(verbs).map(([verb, n]) => `**${n}** element${n === 1 ? '' : 's'} ${verb}`);
221
309
  }
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 = [];
310
+ const sig = (phrases) => phrases.join(', ');
311
+ /** One line for a same-label group. Identical members their line. Otherwise the
312
+ * phrases shared by ALL (`details vary`); and if they share none, the most common
313
+ * phrases (`e.g. … vary`) so the line still tells you what to look for. */
314
+ function foldedLine(group, ctx) {
315
+ const lists = group.map((el) => phrasesFor(el.props, ctx));
316
+ if (lists.every((l) => sig(l) === sig(lists[0])))
317
+ return sig(lists[0]);
318
+ const common = lists[0].filter((p) => lists.every((l) => l.includes(p)));
319
+ if (common.length)
320
+ return `${common.join(', ')} _(details vary)_`;
321
+ const freq = new Map();
322
+ for (const l of lists)
323
+ for (const p of new Set(l))
324
+ freq.set(p, (freq.get(p) ?? 0) + 1);
325
+ const top = [...freq.entries()]
326
+ .sort((a, b) => b[1] - a[1])
327
+ .slice(0, 3)
328
+ .map(([p]) => p);
329
+ return top.length ? `e.g. ${top.join(', ')} _(vary)_` : 'restyled _(vary)_';
330
+ }
331
+ /** Restyle phrases, folded by element label (so two near-identical `span.led`s
332
+ * become one `×2` line with their shared changes), then deduped across labels (so
333
+ * the same line on different labels collapses to `… (×N)`). */
334
+ function restyleLines(els, ctx) {
335
+ const byLabel = new Map();
228
336
  for (const el of els) {
229
337
  if (el.added || el.removed)
230
338
  continue; // values, not deltas — heading covers them
231
- const line = phrasesFor(el.props).join(', ');
232
- if (!line)
339
+ if (!phrasesFor(el.props, ctx).length)
233
340
  continue;
341
+ const arr = byLabel.get(el.label) ?? [];
342
+ arr.push(el);
343
+ byLabel.set(el.label, arr);
344
+ }
345
+ const byLine = new Map();
346
+ const order = [];
347
+ for (const [label, group] of byLabel) {
348
+ const line = foldedLine(group, ctx);
234
349
  if (!byLine.has(line)) {
235
- byLine.set(line, { count: 0, label: el.label });
350
+ byLine.set(line, { count: 0, labels: new Set() });
236
351
  order.push(line);
237
352
  }
238
- byLine.get(line).count++;
353
+ const e = byLine.get(line);
354
+ e.count += group.length;
355
+ e.labels.add(label);
239
356
  }
240
357
  return order.map((line) => {
241
- const { count, label } = byLine.get(line);
242
- return count > 1 ? `${line} _(×${count})_` : `**\`${label}\`** — ${line}`;
358
+ const { count, labels } = byLine.get(line);
359
+ if (labels.size > 1)
360
+ return `${line} _(×${count})_`;
361
+ const label = [...labels][0];
362
+ return `**\`${label}\`**${count > 1 ? ` ×${count}` : ''} — ${line}`;
243
363
  });
244
364
  }
245
365
  /**
@@ -247,8 +367,8 @@ function restyleLines(els) {
247
367
  * phrases, then a flag for interaction-state changes a static screenshot can't
248
368
  * show. Capped so the summary stays a glance (the exact tables live in the fold).
249
369
  */
250
- export function describeChange(els, maxBullets = 6) {
251
- const lines = [...domVerbLines(els), ...restyleLines(els)];
370
+ export function describeChange(els, ctx = {}, maxBullets = 6) {
371
+ const lines = [...domVerbLines(els), ...restyleLines(els, ctx)];
252
372
  const states = [...new Set(els.flatMap((e) => e.states ?? []))];
253
373
  if (states.length)
254
374
  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.1",
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",