styleproof 3.20.0 → 3.21.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 +54 -0
- package/README.md +1 -1
- package/bin/styleproof-diff.mjs +97 -22
- package/dist/capture.d.ts +8 -0
- package/dist/capture.js +22 -0
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/report.d.ts +1 -4
- package/dist/report.js +70 -352
- package/package.json +1 -1
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { trackCount } from './describe.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pure grouping / classification of diff findings — the report's dedup brain,
|
|
4
|
+
* lifted out of the crop-and-PNG machinery so BOTH the visual report
|
|
5
|
+
* (`report.ts`, which renders screenshots on top) and the terminal differ
|
|
6
|
+
* (`bin/styleproof-diff.mjs`, a leaf that must not pull Playwright-adjacent
|
|
7
|
+
* modules) share ONE implementation. No `fs`, no `pngjs`, no `capture.js`: this
|
|
8
|
+
* is a leaf so a bin can import it directly (#186 — bins import leaves, not the
|
|
9
|
+
* barrel).
|
|
10
|
+
*
|
|
11
|
+
* What lives here: collapse logical→physical longhands and shorthand families
|
|
12
|
+
* (`summarizeProps`), strip reflow-casualty/derived longhands (`cleanFindings`),
|
|
13
|
+
* a canonical per-surface signature so surfaces that changed the SAME way group
|
|
14
|
+
* once (`signatureOf` / `groupBySignature`), and the shared-chrome tier that
|
|
15
|
+
* promotes a change spanning every hosting surface to a single callout
|
|
16
|
+
* (`classifyChrome`).
|
|
17
|
+
*/
|
|
18
|
+
// ── property summarisation: dedupe logical longhands, collapse shorthand
|
|
19
|
+
// families, humanize values ────────────────────────────────────────────────
|
|
20
|
+
// Logical longhand → its physical equivalent (LTR, horizontal-tb). Dropped when
|
|
21
|
+
// the physical one changed identically, so each change appears once.
|
|
22
|
+
const LOGICAL_TO_PHYSICAL = (() => {
|
|
23
|
+
const m = {};
|
|
24
|
+
const sides = {
|
|
25
|
+
'block-start': 'top',
|
|
26
|
+
'block-end': 'bottom',
|
|
27
|
+
'inline-start': 'left',
|
|
28
|
+
'inline-end': 'right',
|
|
29
|
+
};
|
|
30
|
+
for (const [l, p] of Object.entries(sides)) {
|
|
31
|
+
for (const k of ['color', 'width', 'style'])
|
|
32
|
+
m[`border-${l}-${k}`] = `border-${p}-${k}`;
|
|
33
|
+
m[`margin-${l}`] = `margin-${p}`;
|
|
34
|
+
m[`padding-${l}`] = `padding-${p}`;
|
|
35
|
+
m[`inset-${l}`] = p;
|
|
36
|
+
}
|
|
37
|
+
const radius = {
|
|
38
|
+
'start-start': 'top-left',
|
|
39
|
+
'start-end': 'top-right',
|
|
40
|
+
'end-start': 'bottom-left',
|
|
41
|
+
'end-end': 'bottom-right',
|
|
42
|
+
};
|
|
43
|
+
for (const [l, p] of Object.entries(radius))
|
|
44
|
+
m[`border-${l}-radius`] = `border-${p}-radius`;
|
|
45
|
+
return m;
|
|
46
|
+
})();
|
|
47
|
+
// Length-valued 4-side families → CSS 1–4-value shorthand whenever all four
|
|
48
|
+
// sides changed (e.g. `padding: 26px 24px → 28px`).
|
|
49
|
+
const BOX4 = [
|
|
50
|
+
{ short: 'margin', parts: ['top', 'right', 'bottom', 'left'].map((s) => `margin-${s}`) },
|
|
51
|
+
{ short: 'padding', parts: ['top', 'right', 'bottom', 'left'].map((s) => `padding-${s}`) },
|
|
52
|
+
{ short: 'border-width', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-width`) },
|
|
53
|
+
{
|
|
54
|
+
short: 'border-radius',
|
|
55
|
+
parts: ['top-left', 'top-right', 'bottom-right', 'bottom-left'].map((s) => `border-${s}-radius`),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
// Colour/keyword families → one row only when all four sides match (else
|
|
59
|
+
// per-side, which keeps each colour swatchable).
|
|
60
|
+
const UNIFORM = [
|
|
61
|
+
{ short: 'border-color', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-color`) },
|
|
62
|
+
{ short: 'border-style', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-style`) },
|
|
63
|
+
];
|
|
64
|
+
const boxShorthand = ([t, r, b, l]) => t === r && r === b && b === l
|
|
65
|
+
? t
|
|
66
|
+
: t === b && r === l
|
|
67
|
+
? `${t} ${r}`
|
|
68
|
+
: r === l
|
|
69
|
+
? `${t} ${r} ${b}`
|
|
70
|
+
: `${t} ${r} ${b} ${l}`;
|
|
71
|
+
const PROP_ORDER = [
|
|
72
|
+
'display',
|
|
73
|
+
'position',
|
|
74
|
+
'grid-template-columns',
|
|
75
|
+
'grid-template-rows',
|
|
76
|
+
'flex-direction',
|
|
77
|
+
'justify-content',
|
|
78
|
+
'align-items',
|
|
79
|
+
'gap',
|
|
80
|
+
'margin',
|
|
81
|
+
'padding',
|
|
82
|
+
'border-width',
|
|
83
|
+
'border-style',
|
|
84
|
+
'border-color',
|
|
85
|
+
'border-radius',
|
|
86
|
+
'outline',
|
|
87
|
+
'background-color',
|
|
88
|
+
'background-image',
|
|
89
|
+
'color',
|
|
90
|
+
'box-shadow',
|
|
91
|
+
'opacity',
|
|
92
|
+
'transform',
|
|
93
|
+
'font-family',
|
|
94
|
+
'font-size',
|
|
95
|
+
'font-weight',
|
|
96
|
+
'line-height',
|
|
97
|
+
'letter-spacing',
|
|
98
|
+
'text-transform',
|
|
99
|
+
'text-align',
|
|
100
|
+
];
|
|
101
|
+
const orderIdx = (p) => {
|
|
102
|
+
const i = PROP_ORDER.indexOf(p);
|
|
103
|
+
return i === -1 ? PROP_ORDER.length : i;
|
|
104
|
+
};
|
|
105
|
+
function cleanVal(v) {
|
|
106
|
+
// Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2 —
|
|
107
|
+
// and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
|
|
108
|
+
let s = v;
|
|
109
|
+
if (!s.includes('(')) {
|
|
110
|
+
const toks = s.split(' ');
|
|
111
|
+
if (toks.length > 1 && new Set(toks).size === 1)
|
|
112
|
+
s = `${toks[0]} ×${toks.length}`;
|
|
113
|
+
}
|
|
114
|
+
return s.replace(/rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/g, 'transparent');
|
|
115
|
+
}
|
|
116
|
+
// These default to `currentColor`, so a `color` change drags them all along —
|
|
117
|
+
// pure echoes, dropped when they match the `color` change.
|
|
118
|
+
const CURRENTCOLOR_FOLLOWERS = [
|
|
119
|
+
'caret-color',
|
|
120
|
+
'outline-color',
|
|
121
|
+
'column-rule-color',
|
|
122
|
+
'text-decoration-color',
|
|
123
|
+
'text-emphasis-color',
|
|
124
|
+
'-webkit-text-fill-color',
|
|
125
|
+
'-webkit-text-stroke-color',
|
|
126
|
+
];
|
|
127
|
+
// "No value here" markers: a forced-state delta that doesn't apply, an unset
|
|
128
|
+
// longhand, or a capture artifact where a path didn't line up. A change BETWEEN
|
|
129
|
+
// two of these (e.g. `— → (gone)`) is meaningless and must never read as a diff.
|
|
130
|
+
const NON_VALUE = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)', '(gone)']);
|
|
131
|
+
export const isNonValue = (v) => NON_VALUE.has(v);
|
|
132
|
+
/** Combine longhands into a shorthand value; all-non-value sides collapse to one. */
|
|
133
|
+
const combineValues = (vals) => (vals.every(isNonValue) ? '(unset)' : vals.join(' '));
|
|
134
|
+
// A flat sequence of independent shorthand/longhand collapse rules — each `if` is
|
|
135
|
+
// one family, not nested logic. Pre-existing (moved verbatim from report.ts, where
|
|
136
|
+
// it was already in the health baseline); splitting it per-family would scatter one
|
|
137
|
+
// cohesive pass across a dozen tiny functions for no readability gain.
|
|
138
|
+
// fallow-ignore-next-line complexity
|
|
139
|
+
export function summarizeProps(props) {
|
|
140
|
+
const map = new Map(props.map((p) => [p.prop, { ...p }]));
|
|
141
|
+
for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
|
|
142
|
+
const lo = map.get(logical);
|
|
143
|
+
const ph = map.get(physical);
|
|
144
|
+
if (lo && ph && lo.before === ph.before && lo.after === ph.after)
|
|
145
|
+
map.delete(logical);
|
|
146
|
+
}
|
|
147
|
+
const color = map.get('color');
|
|
148
|
+
if (color)
|
|
149
|
+
for (const f of CURRENTCOLOR_FOLLOWERS) {
|
|
150
|
+
const m = map.get(f);
|
|
151
|
+
if (m && m.before === color.before && m.after === color.after)
|
|
152
|
+
map.delete(f);
|
|
153
|
+
}
|
|
154
|
+
for (const fam of BOX4) {
|
|
155
|
+
const members = fam.parts.map((p) => map.get(p));
|
|
156
|
+
if (members.every((m) => !!m)) {
|
|
157
|
+
fam.parts.forEach((p) => map.delete(p));
|
|
158
|
+
map.set(fam.short, {
|
|
159
|
+
prop: fam.short,
|
|
160
|
+
before: boxShorthand(members.map((m) => m.before)),
|
|
161
|
+
after: boxShorthand(members.map((m) => m.after)),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const rg = map.get('row-gap');
|
|
166
|
+
const cg = map.get('column-gap');
|
|
167
|
+
if (rg && cg) {
|
|
168
|
+
map.delete('row-gap');
|
|
169
|
+
map.delete('column-gap');
|
|
170
|
+
map.set('gap', {
|
|
171
|
+
prop: 'gap',
|
|
172
|
+
before: rg.before === cg.before ? rg.before : `${rg.before} ${cg.before}`,
|
|
173
|
+
after: rg.after === cg.after ? rg.after : `${rg.after} ${cg.after}`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
for (const fam of UNIFORM) {
|
|
177
|
+
const members = fam.parts.map((p) => map.get(p)).filter((m) => !!m);
|
|
178
|
+
if (members.length === fam.parts.length &&
|
|
179
|
+
members.every((m) => m.before === members[0].before && m.after === members[0].after)) {
|
|
180
|
+
fam.parts.forEach((p) => map.delete(p));
|
|
181
|
+
map.set(fam.short, { prop: fam.short, before: members[0].before, after: members[0].after });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// outline-width/-style/-color → one `outline` row (offset stays separate).
|
|
185
|
+
const ow = map.get('outline-width');
|
|
186
|
+
const os = map.get('outline-style');
|
|
187
|
+
const oc = map.get('outline-color');
|
|
188
|
+
if (ow && os && oc) {
|
|
189
|
+
map.delete('outline-width');
|
|
190
|
+
map.delete('outline-style');
|
|
191
|
+
map.delete('outline-color');
|
|
192
|
+
map.set('outline', {
|
|
193
|
+
prop: 'outline',
|
|
194
|
+
before: combineValues([ow.before, os.before, oc.before]),
|
|
195
|
+
after: combineValues([ow.after, os.after, oc.after]),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return ([...map.values()]
|
|
199
|
+
.map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
|
|
200
|
+
// Drop no-ops: a value that didn't actually change, or a change between two
|
|
201
|
+
// "no value here" markers (`— → (gone)`), which carries no information.
|
|
202
|
+
.filter((p) => p.before !== p.after && !(isNonValue(p.before) && isNonValue(p.after)))
|
|
203
|
+
.sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop)));
|
|
204
|
+
}
|
|
205
|
+
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
206
|
+
export function prettyLabel(p, cls) {
|
|
207
|
+
const tag = (p.split('>').pop() ?? '').trim().replace(/:nth-child\(\d+\)/, '') || 'el';
|
|
208
|
+
const first = cls.split(/\s+/)[0] ?? '';
|
|
209
|
+
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
210
|
+
}
|
|
211
|
+
// ── surface-key helpers (pure — no map reads) ────────────────────────────────
|
|
212
|
+
// Surface keys originate from artifact filenames — attacker-controlled in the
|
|
213
|
+
// fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
|
|
214
|
+
// (the Action slices report.md above the first `### `). Strip the Markdown/HTML
|
|
215
|
+
// control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
|
|
216
|
+
// or table into that bot comment. Escaping at the render boundary — the keys stay
|
|
217
|
+
// legible; only the injection surface is removed. (Crop FILENAMES are separately
|
|
218
|
+
// restricted to [a-z0-9-]; this is the display-side equivalent.)
|
|
219
|
+
export const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
|
|
220
|
+
export const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
221
|
+
export const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
222
|
+
export function pushSurfaceWidth(byBase, base, surface) {
|
|
223
|
+
const arr = byBase.get(base) ?? [];
|
|
224
|
+
arr.push(surfaceWidth(surface));
|
|
225
|
+
byBase.set(base, arr);
|
|
226
|
+
}
|
|
227
|
+
export function renderSurfaceGroups(byBase) {
|
|
228
|
+
return [...byBase]
|
|
229
|
+
.map(([base, ws]) => {
|
|
230
|
+
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
231
|
+
return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
|
|
232
|
+
})
|
|
233
|
+
.join(' · ');
|
|
234
|
+
}
|
|
235
|
+
/** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
|
|
236
|
+
export function formatSurfaceList(surfaces) {
|
|
237
|
+
const byBase = new Map();
|
|
238
|
+
for (const s of surfaces)
|
|
239
|
+
pushSurfaceWidth(byBase, surfaceBase(s), s);
|
|
240
|
+
return renderSurfaceGroups(byBase);
|
|
241
|
+
}
|
|
242
|
+
// ── signature + grouping ─────────────────────────────────────────────────────
|
|
243
|
+
/** Group findings by their element path (one group per changed element). */
|
|
244
|
+
export function groupByPath(findings) {
|
|
245
|
+
const byPath = new Map();
|
|
246
|
+
for (const f of findings) {
|
|
247
|
+
const arr = byPath.get(f.path) ?? [];
|
|
248
|
+
arr.push(f);
|
|
249
|
+
byPath.set(f.path, arr);
|
|
250
|
+
}
|
|
251
|
+
return [...byPath.values()];
|
|
252
|
+
}
|
|
253
|
+
// Grid-track longhands compute to width-dependent pixels (`282px ×2` at one width,
|
|
254
|
+
// `282px 228px` at another), so the SAME responsive change would otherwise get a
|
|
255
|
+
// different signature per width. Key them by track COUNT — what actually
|
|
256
|
+
// identifies the change — so responsive variants group into one section.
|
|
257
|
+
function sigValue(c) {
|
|
258
|
+
if (c.prop === 'grid-template-columns' || c.prop === 'grid-template-rows') {
|
|
259
|
+
return `${c.prop}=${trackCount(c.before)}t>${trackCount(c.after)}t`;
|
|
260
|
+
}
|
|
261
|
+
return `${c.prop}=${c.before}>${c.after}`;
|
|
262
|
+
}
|
|
263
|
+
/** Canonical signature of a surface's findings: surfaces that changed in the
|
|
264
|
+
* same way collapse into one section + one image (the rects differ per width;
|
|
265
|
+
* the change itself does not). */
|
|
266
|
+
export function signatureOf(findings) {
|
|
267
|
+
return JSON.stringify(findings
|
|
268
|
+
.map((f) => ({
|
|
269
|
+
p: f.path,
|
|
270
|
+
k: f.kind,
|
|
271
|
+
t: f.kind === 'dom' ? f.change : f.kind === 'state' ? f.state : (f.pseudo ?? ''),
|
|
272
|
+
v: f.kind === 'dom' ? '' : summarizeProps(f.props).map(sigValue).join('|'),
|
|
273
|
+
}))
|
|
274
|
+
.sort((a, b) => `${a.p}|${a.k}|${a.t}`.localeCompare(`${b.p}|${b.k}|${b.t}`)));
|
|
275
|
+
}
|
|
276
|
+
/** A one-line heading for a change group: "1 element added", "2 elements restyled". */
|
|
277
|
+
export function groupTitle(findings) {
|
|
278
|
+
const added = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'added').map((f) => f.path));
|
|
279
|
+
const removed = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'removed').map((f) => f.path));
|
|
280
|
+
const retagged = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'retagged').map((f) => f.path));
|
|
281
|
+
const restyled = new Set(findings.filter((f) => f.kind !== 'dom' && !added.has(f.path) && !removed.has(f.path)).map((f) => f.path));
|
|
282
|
+
const n = (c, w) => `${c} ${w}${c === 1 ? '' : 's'}`;
|
|
283
|
+
const parts = [];
|
|
284
|
+
if (added.size)
|
|
285
|
+
parts.push(`${n(added.size, 'element')} added`);
|
|
286
|
+
if (removed.size)
|
|
287
|
+
parts.push(`${n(removed.size, 'element')} removed`);
|
|
288
|
+
if (retagged.size)
|
|
289
|
+
parts.push(`${n(retagged.size, 'element')} retagged`);
|
|
290
|
+
if (restyled.size)
|
|
291
|
+
parts.push(`${n(restyled.size, 'element')} restyled`);
|
|
292
|
+
return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
|
|
293
|
+
}
|
|
294
|
+
// ── noise cleaning ───────────────────────────────────────────────────────────
|
|
295
|
+
// Computed values that follow from an element's box size or position rather than
|
|
296
|
+
// its styling. On any reflow they change all the way up the ancestor chain
|
|
297
|
+
// (body, main, section…), so an element whose ONLY changes are these is a reflow
|
|
298
|
+
// casualty: it must not anchor a crop region (that would zoom to the whole page)
|
|
299
|
+
// nor clutter the findings. The certification differ keeps them — a reflow IS a
|
|
300
|
+
// change to certify — but the visual report focuses on styling intent.
|
|
301
|
+
const DERIVED_PROPS = new Set([
|
|
302
|
+
'width',
|
|
303
|
+
'height',
|
|
304
|
+
'block-size',
|
|
305
|
+
'inline-size',
|
|
306
|
+
'min-width',
|
|
307
|
+
'min-height',
|
|
308
|
+
'max-width',
|
|
309
|
+
'max-height',
|
|
310
|
+
'perspective-origin',
|
|
311
|
+
'transform-origin',
|
|
312
|
+
// position offsets shift with the document on any reflow
|
|
313
|
+
'top',
|
|
314
|
+
'right',
|
|
315
|
+
'bottom',
|
|
316
|
+
'left',
|
|
317
|
+
'inset-block-start',
|
|
318
|
+
'inset-block-end',
|
|
319
|
+
'inset-inline-start',
|
|
320
|
+
'inset-inline-end',
|
|
321
|
+
]);
|
|
322
|
+
// Props stripped from forced :hover/:focus/:active deltas specifically. Layout
|
|
323
|
+
// and grid-track values that shift when a state forces a relayout are capture
|
|
324
|
+
// noise, not interaction feedback — a state finding is meant to catch a changed
|
|
325
|
+
// hover/focus/active *style* (colour, outline, shadow), not a reflow.
|
|
326
|
+
const STATE_STRIP = new Set([
|
|
327
|
+
...DERIVED_PROPS,
|
|
328
|
+
'grid-template-columns',
|
|
329
|
+
'grid-template-rows',
|
|
330
|
+
'grid-template-areas',
|
|
331
|
+
'grid-auto-columns',
|
|
332
|
+
'grid-auto-rows',
|
|
333
|
+
'grid-auto-flow',
|
|
334
|
+
]);
|
|
335
|
+
/** How many of a surface's summarised props are derived/box longhands — the count
|
|
336
|
+
* the CLI folds behind `(+N derived longhands)`. Counts on the RAW finding props
|
|
337
|
+
* (before cleaning) so the CLI can advertise exactly what it suppressed. */
|
|
338
|
+
export function derivedLonghandCount(findings) {
|
|
339
|
+
let n = 0;
|
|
340
|
+
for (const f of findings) {
|
|
341
|
+
if (f.kind === 'dom')
|
|
342
|
+
continue;
|
|
343
|
+
const strip = f.kind === 'state' ? STATE_STRIP : DERIVED_PROPS;
|
|
344
|
+
for (const p of summarizeProps(f.props))
|
|
345
|
+
if (strip.has(p.prop))
|
|
346
|
+
n++;
|
|
347
|
+
}
|
|
348
|
+
return n;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Strip the noise the visual report shouldn't carry, cross-referencing each
|
|
352
|
+
* element's layers so the forced-state layer stops echoing the base:
|
|
353
|
+
* - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
|
|
354
|
+
* - forced states: drop derived + grid-track props, drop a delta the BASE
|
|
355
|
+
* already changed (a `:hover color` that just follows a recoloured base is an
|
|
356
|
+
* echo, not a dropped variant), and drop non-value↔non-value rows;
|
|
357
|
+
* - any finding left with no props is removed entirely.
|
|
358
|
+
*/
|
|
359
|
+
export function cleanFindings(findings) {
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const group of groupByPath(findings)) {
|
|
362
|
+
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
363
|
+
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
364
|
+
// For an ADDED element the base style is a full (unset)→value snapshot, not a
|
|
365
|
+
// delta — so a forced-state value is never an "echo" of a base *change*; keep
|
|
366
|
+
// every state row (suppressing them would drop a real :hover/:focus value).
|
|
367
|
+
const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
|
|
368
|
+
for (const f of group) {
|
|
369
|
+
if (f.kind === 'dom') {
|
|
370
|
+
out.push(f);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const props = f.kind === 'style'
|
|
374
|
+
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
375
|
+
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
376
|
+
(isAdded || !baseChanged.has(p.prop)) &&
|
|
377
|
+
!(isNonValue(p.before) && isNonValue(p.after)));
|
|
378
|
+
if (props.length)
|
|
379
|
+
out.push({ ...f, props });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Group surfaces that changed identically (same signature) into one group each,
|
|
386
|
+
* keeping the widest surface as the representative. The rects differ per width;
|
|
387
|
+
* the change itself does not. Callers pass the already-prepared per-surface
|
|
388
|
+
* findings (missing/one-sided surfaces excluded upstream).
|
|
389
|
+
*/
|
|
390
|
+
export function groupBySignature(prepared) {
|
|
391
|
+
const bySig = new Map();
|
|
392
|
+
for (const p of prepared) {
|
|
393
|
+
const sig = signatureOf(p.findings);
|
|
394
|
+
const existing = bySig.get(sig);
|
|
395
|
+
if (existing) {
|
|
396
|
+
existing.surfaces.push(p.surface);
|
|
397
|
+
if (surfaceWidth(p.surface) > surfaceWidth(existing.rep.surface))
|
|
398
|
+
existing.rep = p;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
bySig.set(sig, { surfaces: [p.surface], rep: p, findings: p.findings });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return [...bySig.values()];
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The set of element paths that changed as SHARED CHROME — a persistent frame
|
|
408
|
+
* element (nav rail, header, footer) that every view renders and that moved on
|
|
409
|
+
* every view that renders it.
|
|
410
|
+
*
|
|
411
|
+
* The rule is STRUCTURAL, deliberately NOT a tunable percentage. For each changed
|
|
412
|
+
* path we compare two base-key sets:
|
|
413
|
+
* - `hosting` — the surface bases whose style map contains the path at all;
|
|
414
|
+
* - `changed` — the surface bases where the path appears in a finding.
|
|
415
|
+
* The path is chrome iff it is hosted on MORE THAN ONE base and it changed on
|
|
416
|
+
* EVERY base that hosts it (`changed ⊇ hosting`). "Every surface that has this
|
|
417
|
+
* element changed it" is exactly what shared chrome means; it needs no threshold
|
|
418
|
+
* to tune or defend. A content element (hosted on one base) fails the >1 guard; a
|
|
419
|
+
* partial change (some hosting bases unchanged) fails the coverage guard.
|
|
420
|
+
*
|
|
421
|
+
* Widths of one base collapse to the base key, so a nav present at @1280 and @390
|
|
422
|
+
* counts once. `surfacePaths` maps each captured surface key → the element paths
|
|
423
|
+
* it renders (union of both sides from the caller).
|
|
424
|
+
*/
|
|
425
|
+
export function chromePaths(changedOnSurfaces, surfacePaths) {
|
|
426
|
+
const hosting = new Map();
|
|
427
|
+
for (const [surface, paths] of surfacePaths) {
|
|
428
|
+
const base = surfaceBase(surface);
|
|
429
|
+
for (const p of paths) {
|
|
430
|
+
const set = hosting.get(p) ?? new Set();
|
|
431
|
+
set.add(base);
|
|
432
|
+
hosting.set(p, set);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const changed = new Map();
|
|
436
|
+
for (const f of changedOnSurfaces) {
|
|
437
|
+
const set = changed.get(f.path) ?? new Set();
|
|
438
|
+
for (const s of f.surfaces)
|
|
439
|
+
set.add(surfaceBase(s));
|
|
440
|
+
changed.set(f.path, set);
|
|
441
|
+
}
|
|
442
|
+
const chrome = new Set();
|
|
443
|
+
for (const [path, changedBases] of changed) {
|
|
444
|
+
const hostingBases = hosting.get(path) ?? new Set([path]);
|
|
445
|
+
if (hostingBases.size > 1 && [...hostingBases].every((b) => changedBases.has(b)))
|
|
446
|
+
chrome.add(path);
|
|
447
|
+
}
|
|
448
|
+
return chrome;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Split signature groups into the shared-chrome tier and the rest. A group is
|
|
452
|
+
* promoted only when EVERY one of its affected element paths is a chrome path
|
|
453
|
+
* (see `chromePaths`) — a group that entangles a frame change with a view's own
|
|
454
|
+
* content change stays in `rest` and renders in place, so we never hide a
|
|
455
|
+
* content change under a chrome banner.
|
|
456
|
+
*/
|
|
457
|
+
export function classifyChrome(groups, surfacePaths) {
|
|
458
|
+
// Findings tagged with the surfaces the group spans, so chromePaths sees which
|
|
459
|
+
// bases each path changed on across ALL groups (a path bundled with content on
|
|
460
|
+
// one view still counts as changed there).
|
|
461
|
+
const tagged = groups.flatMap((g) => g.findings.map((f) => ({ path: f.path, surfaces: g.surfaces })));
|
|
462
|
+
const paths = chromePaths(tagged, surfacePaths);
|
|
463
|
+
const chrome = [];
|
|
464
|
+
const rest = [];
|
|
465
|
+
for (const g of groups) {
|
|
466
|
+
const affected = new Set(g.findings.map((f) => f.path));
|
|
467
|
+
const isChrome = affected.size > 0 && [...affected].every((p) => paths.has(p));
|
|
468
|
+
(isChrome ? chrome : rest).push(g);
|
|
469
|
+
}
|
|
470
|
+
return { chrome, rest, chromePaths: paths };
|
|
471
|
+
}
|
package/dist/report.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type PropChange } from './diff.js';
|
|
2
1
|
export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
|
|
2
|
+
export { summarizeProps, prettyLabel } from './change-groups.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
|
|
@@ -77,8 +77,5 @@ export type ReportResult = {
|
|
|
77
77
|
reportMdPath: string;
|
|
78
78
|
reportJsonPath: string;
|
|
79
79
|
};
|
|
80
|
-
export declare function summarizeProps(props: PropChange[]): PropChange[];
|
|
81
|
-
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
82
|
-
export declare function prettyLabel(p: string, cls: string): string;
|
|
83
80
|
export declare function excerptPair(before: string, after: string): [string, string];
|
|
84
81
|
export declare function generateStyleMapReport(opts: ReportOptions): ReportResult;
|