styleproof 3.19.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.
@@ -0,0 +1,91 @@
1
+ import { type Finding, type PropChange } from './diff.js';
2
+ export declare const isNonValue: (v: string) => boolean;
3
+ export declare function summarizeProps(props: PropChange[]): PropChange[];
4
+ /** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
5
+ export declare function prettyLabel(p: string, cls: string): string;
6
+ export declare const safeKey: (s: string) => string;
7
+ export declare const surfaceBase: (s: string) => string;
8
+ export declare const surfaceWidth: (s: string) => number;
9
+ export declare function pushSurfaceWidth(byBase: Map<string, number[]>, base: string, surface: string): void;
10
+ export declare function renderSurfaceGroups(byBase: Map<string, number[]>): string;
11
+ /** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
12
+ export declare function formatSurfaceList(surfaces: string[]): string;
13
+ /** Group findings by their element path (one group per changed element). */
14
+ export declare function groupByPath(findings: Finding[]): Finding[][];
15
+ /** Canonical signature of a surface's findings: surfaces that changed in the
16
+ * same way collapse into one section + one image (the rects differ per width;
17
+ * the change itself does not). */
18
+ export declare function signatureOf(findings: Finding[]): string;
19
+ /** A one-line heading for a change group: "1 element added", "2 elements restyled". */
20
+ export declare function groupTitle(findings: Finding[]): string;
21
+ /** How many of a surface's summarised props are derived/box longhands — the count
22
+ * the CLI folds behind `(+N derived longhands)`. Counts on the RAW finding props
23
+ * (before cleaning) so the CLI can advertise exactly what it suppressed. */
24
+ export declare function derivedLonghandCount(findings: Finding[]): number;
25
+ /**
26
+ * Strip the noise the visual report shouldn't carry, cross-referencing each
27
+ * element's layers so the forced-state layer stops echoing the base:
28
+ * - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
29
+ * - forced states: drop derived + grid-track props, drop a delta the BASE
30
+ * already changed (a `:hover color` that just follows a recoloured base is an
31
+ * echo, not a dropped variant), and drop non-value↔non-value rows;
32
+ * - any finding left with no props is removed entirely.
33
+ */
34
+ export declare function cleanFindings(findings: Finding[]): Finding[];
35
+ /** A surface's diff distilled to just what grouping needs: its key and the
36
+ * findings kept after noise-cleaning. */
37
+ export type SurfaceFindings = {
38
+ surface: string;
39
+ findings: Finding[];
40
+ };
41
+ /** Surfaces that changed the SAME way, collapsed to one group + a representative. */
42
+ export type SignatureGroup = {
43
+ surfaces: string[];
44
+ rep: SurfaceFindings;
45
+ findings: Finding[];
46
+ };
47
+ /**
48
+ * Group surfaces that changed identically (same signature) into one group each,
49
+ * keeping the widest surface as the representative. The rects differ per width;
50
+ * the change itself does not. Callers pass the already-prepared per-surface
51
+ * findings (missing/one-sided surfaces excluded upstream).
52
+ */
53
+ export declare function groupBySignature(prepared: SurfaceFindings[]): SignatureGroup[];
54
+ /**
55
+ * The set of element paths that changed as SHARED CHROME — a persistent frame
56
+ * element (nav rail, header, footer) that every view renders and that moved on
57
+ * every view that renders it.
58
+ *
59
+ * The rule is STRUCTURAL, deliberately NOT a tunable percentage. For each changed
60
+ * path we compare two base-key sets:
61
+ * - `hosting` — the surface bases whose style map contains the path at all;
62
+ * - `changed` — the surface bases where the path appears in a finding.
63
+ * The path is chrome iff it is hosted on MORE THAN ONE base and it changed on
64
+ * EVERY base that hosts it (`changed ⊇ hosting`). "Every surface that has this
65
+ * element changed it" is exactly what shared chrome means; it needs no threshold
66
+ * to tune or defend. A content element (hosted on one base) fails the >1 guard; a
67
+ * partial change (some hosting bases unchanged) fails the coverage guard.
68
+ *
69
+ * Widths of one base collapse to the base key, so a nav present at @1280 and @390
70
+ * counts once. `surfacePaths` maps each captured surface key → the element paths
71
+ * it renders (union of both sides from the caller).
72
+ */
73
+ export declare function chromePaths(changedOnSurfaces: Array<{
74
+ path: string;
75
+ surfaces: string[];
76
+ }>, surfacePaths: Map<string, Set<string>>): Set<string>;
77
+ /**
78
+ * Split signature groups into the shared-chrome tier and the rest. A group is
79
+ * promoted only when EVERY one of its affected element paths is a chrome path
80
+ * (see `chromePaths`) — a group that entangles a frame change with a view's own
81
+ * content change stays in `rest` and renders in place, so we never hide a
82
+ * content change under a chrome banner.
83
+ */
84
+ export declare function classifyChrome<G extends {
85
+ surfaces: string[];
86
+ findings: Finding[];
87
+ }>(groups: G[], surfacePaths: Map<string, Set<string>>): {
88
+ chrome: G[];
89
+ rest: G[];
90
+ chromePaths: Set<string>;
91
+ };
@@ -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
+ }
@@ -14,9 +14,14 @@ export function cliErrorMessage(error) {
14
14
  */
15
15
  export function cachedMapsUnavailableMessage(command, purpose, error) {
16
16
  return [
17
- `${command}: cached maps are not available for this ${purpose}`,
17
+ `${command}: cached maps are not available for this ${purpose} — nothing was compared`,
18
18
  cliErrorMessage(error),
19
- 'Next: run styleproof-map on the base and head commits to upload maps, or let CI recapture both sides.',
19
+ // Name the two ways forward explicitly so a newcomer never reads "nothing compared"
20
+ // as "certified clean": the cached-map path only works where the base is restorable
21
+ // (CI, or a repo with the map-store remote), and the two-directory form always works
22
+ // off already-captured maps with no git remote at all.
23
+ `Next: run this in CI (or a repo with the 'origin' remote) where the base map is restorable, ` +
24
+ `or capture both sides and compare them directly: ${command} <beforeDir> <afterDir>.`,
20
25
  ].join('\n');
21
26
  }
22
27
  export function unknownFlagMessage(command, flag) {
package/dist/crawl.js CHANGED
@@ -80,9 +80,18 @@ function toLink(href, base, keyFor, match) {
80
80
  return { key: keyFor(url), url: path };
81
81
  }
82
82
  /**
83
- * Dedup identity for a navigable path+query: a trailing slash on the path is not a
84
- * distinct surface (`/about` and `/about/` render the same route), so it's stripped
85
- * from the identity but never from the root `/` itself, and never from the query.
83
+ * Dedup identity for a navigable path+query. Two forms of the same route must share
84
+ * one identity, or a static multi-page site (whose nav links the `.html` files) gets
85
+ * captured twice as byte-near-identical maps, doubling the work and duplicating every
86
+ * finding in the diff:
87
+ *
88
+ * - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same
89
+ * route), so it's stripped — but never from the root `/` itself, nor from the query.
90
+ * - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and
91
+ * `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the
92
+ * literal `index.html` filename normalizes — a real `about.html` is left untouched
93
+ * and stays a distinct surface from `about`.
94
+ *
86
95
  * The navigable url the caller returns keeps its original form; only the SET
87
96
  * membership test is normalized, so the first-seen href still wins.
88
97
  */
@@ -90,7 +99,10 @@ function dedupIdentity(pathAndSearch) {
90
99
  const q = pathAndSearch.indexOf('?');
91
100
  const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
92
101
  const search = q === -1 ? '' : pathAndSearch.slice(q);
93
- const normPath = path.length > 1 ? path.replace(/\/+$/, '') || '/' : path;
102
+ // `/index.html` `/`, `/docs/index.html` `/docs/` (the preceding slash stays so
103
+ // the trailing-slash step below folds it into the same identity as `/docs` / `/docs/`).
104
+ const withoutIndex = path.replace(/(^|\/)index\.html$/, '$1');
105
+ const normPath = withoutIndex.length > 1 ? withoutIndex.replace(/\/+$/, '') || '/' : withoutIndex;
94
106
  return normPath + search;
95
107
  }
96
108
  /**
package/dist/diff.js CHANGED
@@ -63,9 +63,44 @@ const ORIGIN_EPSILON_PX = 0.05;
63
63
  function sameRect(a, b) {
64
64
  return !!a && !!b && a.every((v, i) => v === b[i]);
65
65
  }
66
+ const HORIZONTAL_MARGIN_PAIRS = [
67
+ ['margin-left', 'margin-right'],
68
+ ['margin-inline-start', 'margin-inline-end'],
69
+ ];
70
+ function marginPxDelta(p) {
71
+ const before = pxParts(p.before);
72
+ const after = pxParts(p.after);
73
+ return before?.length === 1 && after?.length === 1 ? after[0] - before[0] : null;
74
+ }
75
+ // True when one horizontal side moved by a different px amount than its
76
+ // opposite. Such a change would shift the box on its own, so an *identical*
77
+ // rect means something else compensated — a real restyle, not layout-equivalent
78
+ // drift. Non-px or state-sentinel values (`(state no longer changes it)`) aren't
79
+ // demonstrable, so they fall through to the balanced (drop) path unchanged.
80
+ function marginChangeHasPxImbalance(props) {
81
+ const delta = new Map();
82
+ for (const p of props) {
83
+ if (LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop))
84
+ delta.set(p.prop, marginPxDelta(p));
85
+ }
86
+ for (const [start, end] of HORIZONTAL_MARGIN_PAIRS) {
87
+ const ds = delta.has(start) ? delta.get(start) : 0;
88
+ const de = delta.has(end) ? delta.get(end) : 0;
89
+ if (ds !== null && de !== null && ds !== de)
90
+ return true;
91
+ }
92
+ return false;
93
+ }
66
94
  function dropLayoutEquivalentMarginProps(props, a, b) {
67
95
  if (!sameRect(a?.rect, b?.rect))
68
96
  return props;
97
+ // ponytail: a balanced margin change with an unchanged rect is treated as
98
+ // layout-equivalent from computed style alone. That still drops the rare case
99
+ // where a *balanced* change was held in place by external compensation — a
100
+ // consciously-deferred, low-reach soundness corner; closing it needs
101
+ // cross-element layout reasoning. The common one-sided case is caught here.
102
+ if (marginChangeHasPxImbalance(props))
103
+ return props;
69
104
  return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
70
105
  }
71
106
  function pxParts(value) {