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 +31 -0
- package/README.md +4 -4
- package/dist/capture.d.ts +8 -0
- package/dist/capture.js +30 -0
- package/dist/describe.d.ts +11 -1
- package/dist/describe.js +139 -32
- package/dist/report.d.ts +1 -1
- package/dist/report.js +26 -11
- package/package.json +1 -1
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
|

|
|
27
27
|
|
|
28
|
-
As it renders in the PR comment (a plain-English bullet first
|
|
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
|
|
38
|
-
| background-color |
|
|
37
|
+
| Property | Before | After |
|
|
38
|
+
| background-color | #5fcadb | #f59e0b |
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
## Works with any styling system
|
package/dist/capture.d.ts
CHANGED
|
@@ -53,6 +53,14 @@ export type StyleMap = {
|
|
|
53
53
|
* stream/ticker never reads as a change. Empty/absent on a fully-settled page.
|
|
54
54
|
*/
|
|
55
55
|
volatile?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Colour-valued `:root` custom properties (design/theme tokens), normalised to
|
|
58
|
+
* the same `rgb(...)` form the longhands resolve to — `{ "--red-200": "rgb(254,
|
|
59
|
+
* 202, 202)" }`. Lets the report name the token behind a colour change
|
|
60
|
+
* (`red-100 → red-200`) instead of only the raw value. Captured once per
|
|
61
|
+
* surface from the document root; per-subtree overrides aren't tracked.
|
|
62
|
+
*/
|
|
63
|
+
tokens?: Record<string, string>;
|
|
56
64
|
};
|
|
57
65
|
export type CaptureOptions = {
|
|
58
66
|
/**
|
package/dist/capture.js
CHANGED
|
@@ -300,6 +300,34 @@ async function stabilizePage(page, ignore, interval, quietFor, timeout) {
|
|
|
300
300
|
}
|
|
301
301
|
return recent; // never went quiet for quietFor within budget → still-moving paths are live
|
|
302
302
|
}
|
|
303
|
+
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
304
|
+
// Colour-valued `:root` custom properties (theme tokens), each normalised to the
|
|
305
|
+
// browser's `rgb(...)` form (via a probe element) so they match the resolved
|
|
306
|
+
// longhand values the diff compares — letting the report name `red-200` behind a
|
|
307
|
+
// colour. Non-colour tokens (spacing, etc.) are skipped.
|
|
308
|
+
function capturePageTokens() {
|
|
309
|
+
const root = document.documentElement;
|
|
310
|
+
const cs = getComputedStyle(root);
|
|
311
|
+
const probe = document.createElement('span');
|
|
312
|
+
probe.style.cssText = 'position:absolute;left:-9999px';
|
|
313
|
+
document.body.appendChild(probe);
|
|
314
|
+
const tokens = {};
|
|
315
|
+
for (let i = 0; i < cs.length; i++) {
|
|
316
|
+
const name = cs.item(i);
|
|
317
|
+
if (!name.startsWith('--'))
|
|
318
|
+
continue;
|
|
319
|
+
const raw = cs.getPropertyValue(name).trim();
|
|
320
|
+
if (!raw)
|
|
321
|
+
continue;
|
|
322
|
+
probe.style.color = '';
|
|
323
|
+
probe.style.color = raw; // invalid (non-colour) values leave it empty
|
|
324
|
+
if (!probe.style.color)
|
|
325
|
+
continue;
|
|
326
|
+
tokens[name] = getComputedStyle(probe).color; // canonical rgb(a)(...)
|
|
327
|
+
}
|
|
328
|
+
probe.remove();
|
|
329
|
+
return tokens;
|
|
330
|
+
}
|
|
303
331
|
/**
|
|
304
332
|
* Capture the page's complete style map. Drive the page to the state you want
|
|
305
333
|
* first (navigate, open menus); by default the capture then auto-settles the
|
|
@@ -362,12 +390,14 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
362
390
|
states = forced.states;
|
|
363
391
|
statesSkipped = forced.skipped;
|
|
364
392
|
}
|
|
393
|
+
const tokens = await page.evaluate(capturePageTokens);
|
|
365
394
|
return {
|
|
366
395
|
defaults: base.defaults,
|
|
367
396
|
elements: base.elements,
|
|
368
397
|
states,
|
|
369
398
|
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
370
399
|
...(volatile.length ? { volatile } : {}),
|
|
400
|
+
...(Object.keys(tokens).length ? { tokens } : {}),
|
|
371
401
|
};
|
|
372
402
|
}
|
|
373
403
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
package/dist/describe.d.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
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
|
-
|
|
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 =
|
|
112
|
-
const isZero =
|
|
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 ${
|
|
144
|
-
return present.map(([c, w]) => `${w} ${
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
*
|
|
225
|
-
function
|
|
226
|
-
const
|
|
227
|
-
|
|
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
|
-
|
|
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,
|
|
337
|
+
byLine.set(line, { count: 0, labels: new Set() });
|
|
236
338
|
order.push(line);
|
|
237
339
|
}
|
|
238
|
-
byLine.get(line)
|
|
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,
|
|
242
|
-
|
|
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
|
|
379
|
-
|
|
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
|
|
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
|
|
707
|
-
|
|
719
|
+
// One side-by-side image per crop, left as a PLAIN image (not wrapped in a
|
|
720
|
+
// link) so GitHub's built-in lightbox opens it in a zoomable popup on click —
|
|
721
|
+
// a link wrapper would navigate to the file instead.
|
|
722
|
+
md.push('', `})`, '', `<sub>◀ before · after ▶ — ${sd.surface} · click to zoom</sub>`);
|
|
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.
|
|
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",
|