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.
- package/CHANGELOG.md +131 -0
- package/README.md +20 -5
- package/bin/styleproof-diff.mjs +97 -22
- package/bin/styleproof-init.mjs +49 -20
- 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/cli-errors.js +7 -2
- package/dist/crawl.js +16 -4
- package/dist/diff.js +35 -0
- package/dist/report.d.ts +1 -4
- package/dist/report.js +70 -352
- package/dist/runner.js +109 -29
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/cli-errors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
|
|
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) {
|