styleproof 1.0.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 +204 -0
- package/LICENSE +21 -0
- package/README.md +661 -0
- package/bin/styleproof-diff.mjs +110 -0
- package/bin/styleproof-init.mjs +190 -0
- package/bin/styleproof-report.mjs +106 -0
- package/dist/capture.d.ts +79 -0
- package/dist/capture.js +323 -0
- package/dist/diff.d.ts +51 -0
- package/dist/diff.js +121 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/report.d.ts +45 -0
- package/dist/report.js +463 -0
- package/dist/runner.d.ts +45 -0
- package/dist/runner.js +35 -0
- package/docs/demo-composite.png +0 -0
- package/package.json +86 -0
package/dist/capture.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
4
|
+
const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"], [tabindex]';
|
|
5
|
+
// Freeze motion so every captured value is a settled end state, not a frame
|
|
6
|
+
// of an animation or a mid-flight transition after a forced :hover.
|
|
7
|
+
const FREEZE_CSS = '*,*::before,*::after{animation:none!important;transition:none!important}';
|
|
8
|
+
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
9
|
+
function capturePage({ ignore, motionOnly }) {
|
|
10
|
+
const MOTION = /^(transition|animation)/;
|
|
11
|
+
const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
|
|
12
|
+
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
13
|
+
const pathOf = (el) => {
|
|
14
|
+
if (el === document.documentElement)
|
|
15
|
+
return 'html';
|
|
16
|
+
if (el === document.body)
|
|
17
|
+
return 'body';
|
|
18
|
+
const parts = [];
|
|
19
|
+
let n = el;
|
|
20
|
+
while (n && n !== document.body) {
|
|
21
|
+
const parent = n.parentElement;
|
|
22
|
+
if (!parent)
|
|
23
|
+
break;
|
|
24
|
+
parts.unshift(`${n.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, n) + 1})`);
|
|
25
|
+
n = parent;
|
|
26
|
+
}
|
|
27
|
+
return 'body > ' + parts.join(' > ');
|
|
28
|
+
};
|
|
29
|
+
// Per-tag (and per-tag-per-pseudo) UA defaults from a stylesheet-free iframe,
|
|
30
|
+
// used to prune the maps. A pseudo-element's UA defaults are NOT the host
|
|
31
|
+
// element's defaults, so they are measured and cached separately under a
|
|
32
|
+
// composite key (e.g. `li::marker`) — pruning a pseudo against the wrong
|
|
33
|
+
// baseline can both drop real changes and bloat the file.
|
|
34
|
+
const frame = document.createElement('iframe');
|
|
35
|
+
frame.style.cssText = 'position:absolute;left:-9999px;width:100px;height:100px;border:0';
|
|
36
|
+
document.body.appendChild(frame);
|
|
37
|
+
const fdoc = frame.contentDocument;
|
|
38
|
+
const defaults = {};
|
|
39
|
+
const probeCache = {};
|
|
40
|
+
const probeFor = (tag) => {
|
|
41
|
+
if (!(tag in probeCache)) {
|
|
42
|
+
const probe = fdoc.createElement(tag);
|
|
43
|
+
fdoc.body.appendChild(probe);
|
|
44
|
+
probeCache[tag] = probe;
|
|
45
|
+
}
|
|
46
|
+
return probeCache[tag];
|
|
47
|
+
};
|
|
48
|
+
const defaultFor = (tag, pseudo) => {
|
|
49
|
+
const key = pseudo ? `${tag}${pseudo}` : tag;
|
|
50
|
+
if (!(key in defaults)) {
|
|
51
|
+
const cs = fdoc.defaultView.getComputedStyle(probeFor(tag), pseudo ?? null);
|
|
52
|
+
const o = {};
|
|
53
|
+
for (let i = 0; i < cs.length; i++)
|
|
54
|
+
o[cs.item(i)] = cs.getPropertyValue(cs.item(i));
|
|
55
|
+
defaults[key] = o;
|
|
56
|
+
}
|
|
57
|
+
return defaults[key];
|
|
58
|
+
};
|
|
59
|
+
const snap = (cs, def) => {
|
|
60
|
+
const o = {};
|
|
61
|
+
for (let i = 0; i < cs.length; i++) {
|
|
62
|
+
const p = cs.item(i);
|
|
63
|
+
if (motionOnly !== MOTION.test(p))
|
|
64
|
+
continue;
|
|
65
|
+
const v = cs.getPropertyValue(p);
|
|
66
|
+
if (!def || def[p] !== v)
|
|
67
|
+
o[p] = v;
|
|
68
|
+
}
|
|
69
|
+
return o;
|
|
70
|
+
};
|
|
71
|
+
const elements = {};
|
|
72
|
+
const all = [document.documentElement, document.body, ...document.querySelectorAll('body *')];
|
|
73
|
+
// Surface untraversed shadow roots and same-origin iframes so a refactor
|
|
74
|
+
// inside one is not silently certified identical (see file header).
|
|
75
|
+
let shadowHosts = 0;
|
|
76
|
+
let sameOriginFrames = 0;
|
|
77
|
+
for (const el of all) {
|
|
78
|
+
if (el === frame || (skipSel && el.matches(skipSel)))
|
|
79
|
+
continue;
|
|
80
|
+
const tag = el.tagName.toLowerCase();
|
|
81
|
+
if (tag === 'script' || tag === 'style' || tag === 'link' || tag === 'noscript')
|
|
82
|
+
continue;
|
|
83
|
+
if (!motionOnly && el.shadowRoot)
|
|
84
|
+
shadowHosts++;
|
|
85
|
+
if (!motionOnly && (tag === 'iframe' || tag === 'frame')) {
|
|
86
|
+
try {
|
|
87
|
+
if (el.contentDocument)
|
|
88
|
+
sameOriginFrames++;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// cross-origin: genuinely untraversable, not counted
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const entry = {
|
|
95
|
+
tag,
|
|
96
|
+
cls: el.getAttribute('class') || '',
|
|
97
|
+
style: snap(getComputedStyle(el), defaultFor(tag)),
|
|
98
|
+
};
|
|
99
|
+
if (!motionOnly) {
|
|
100
|
+
// Document-space box so report crops can locate the element in a
|
|
101
|
+
// full-page screenshot regardless of scroll position at capture time.
|
|
102
|
+
const r = el.getBoundingClientRect();
|
|
103
|
+
entry.rect = [
|
|
104
|
+
Math.round(r.x + window.scrollX),
|
|
105
|
+
Math.round(r.y + window.scrollY),
|
|
106
|
+
Math.round(r.width),
|
|
107
|
+
Math.round(r.height),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
for (const ps of PSEUDOS) {
|
|
111
|
+
if (ps === '::marker' && getComputedStyle(el).display !== 'list-item')
|
|
112
|
+
continue;
|
|
113
|
+
if (ps === '::placeholder' && !(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement))
|
|
114
|
+
continue;
|
|
115
|
+
const cs = getComputedStyle(el, ps);
|
|
116
|
+
if ((ps === '::before' || ps === '::after') && cs.getPropertyValue('content') === 'none')
|
|
117
|
+
continue;
|
|
118
|
+
const props = snap(cs, defaultFor(tag, ps));
|
|
119
|
+
if (Object.keys(props).length)
|
|
120
|
+
(entry.pseudo ??= {})[ps] = props;
|
|
121
|
+
}
|
|
122
|
+
elements[pathOf(el)] = entry;
|
|
123
|
+
}
|
|
124
|
+
frame.remove();
|
|
125
|
+
return { defaults, elements, shadowHosts, sameOriginFrames };
|
|
126
|
+
}
|
|
127
|
+
/** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
|
|
128
|
+
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
129
|
+
function snapSubtree({ selector, index }) {
|
|
130
|
+
const el = document.querySelectorAll(selector)[index];
|
|
131
|
+
const pathOf = (n) => {
|
|
132
|
+
if (n === document.documentElement)
|
|
133
|
+
return 'html';
|
|
134
|
+
if (n === document.body)
|
|
135
|
+
return 'body';
|
|
136
|
+
const parts = [];
|
|
137
|
+
let c = n;
|
|
138
|
+
while (c && c !== document.body) {
|
|
139
|
+
const parent = c.parentElement;
|
|
140
|
+
if (!parent)
|
|
141
|
+
break;
|
|
142
|
+
parts.unshift(`${c.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, c) + 1})`);
|
|
143
|
+
c = parent;
|
|
144
|
+
}
|
|
145
|
+
return 'body > ' + parts.join(' > ');
|
|
146
|
+
};
|
|
147
|
+
const out = {};
|
|
148
|
+
if (!el)
|
|
149
|
+
return out;
|
|
150
|
+
for (const n of [el, ...el.querySelectorAll('*')]) {
|
|
151
|
+
for (const ps of [null, '::before', '::after']) {
|
|
152
|
+
const cs = getComputedStyle(n, ps);
|
|
153
|
+
if (ps && cs.getPropertyValue('content') === 'none')
|
|
154
|
+
continue;
|
|
155
|
+
const o = {};
|
|
156
|
+
for (let i = 0; i < cs.length; i++) {
|
|
157
|
+
const p = cs.item(i);
|
|
158
|
+
if (/^(transition|animation)/.test(p))
|
|
159
|
+
continue; // frozen by FREEZE_CSS
|
|
160
|
+
o[p] = cs.getPropertyValue(p);
|
|
161
|
+
}
|
|
162
|
+
out[pathOf(n) + (ps || '')] = o;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
function deltaBetween(base, forced) {
|
|
168
|
+
const delta = {};
|
|
169
|
+
for (const key of new Set([...Object.keys(base), ...Object.keys(forced)])) {
|
|
170
|
+
const a = base[key] || {};
|
|
171
|
+
const b = forced[key] || {};
|
|
172
|
+
const d = {};
|
|
173
|
+
for (const p of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
174
|
+
if (a[p] !== b[p])
|
|
175
|
+
d[p] = b[p] ?? '(gone)';
|
|
176
|
+
}
|
|
177
|
+
if (Object.keys(d).length)
|
|
178
|
+
delta[key] = d;
|
|
179
|
+
}
|
|
180
|
+
return delta;
|
|
181
|
+
}
|
|
182
|
+
const STATE_SETS = {
|
|
183
|
+
hover: ['hover'],
|
|
184
|
+
focus: ['focus', 'focus-visible'],
|
|
185
|
+
active: ['active'],
|
|
186
|
+
};
|
|
187
|
+
/** Structural paths for every selector match, index-aligned with CDP's DOM.querySelectorAll. */
|
|
188
|
+
function pathsForSelector({ selector, skipSel }) {
|
|
189
|
+
const pathOf = (el) => {
|
|
190
|
+
if (el === document.documentElement)
|
|
191
|
+
return 'html';
|
|
192
|
+
if (el === document.body)
|
|
193
|
+
return 'body';
|
|
194
|
+
const parts = [];
|
|
195
|
+
let n = el;
|
|
196
|
+
while (n && n !== document.body) {
|
|
197
|
+
const parent = n.parentElement;
|
|
198
|
+
if (!parent)
|
|
199
|
+
break;
|
|
200
|
+
parts.unshift(`${n.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, n) + 1})`);
|
|
201
|
+
n = parent;
|
|
202
|
+
}
|
|
203
|
+
return 'body > ' + parts.join(' > ');
|
|
204
|
+
};
|
|
205
|
+
return [...document.querySelectorAll(selector)].map((el) => (skipSel && el.matches(skipSel) ? null : pathOf(el)));
|
|
206
|
+
}
|
|
207
|
+
// Forced pseudo-class states on interactive elements, via CDP so no real
|
|
208
|
+
// mouse or focus is involved and parent-state descendant rules still apply.
|
|
209
|
+
async function captureForcedStates(page, ignore, maxInteractive) {
|
|
210
|
+
const client = await page.context().newCDPSession(page);
|
|
211
|
+
await client.send('DOM.enable');
|
|
212
|
+
await client.send('CSS.enable');
|
|
213
|
+
const { root } = await client.send('DOM.getDocument');
|
|
214
|
+
const { nodeIds } = await client.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: INTERACTIVE });
|
|
215
|
+
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
216
|
+
const paths = await page.evaluate(pathsForSelector, { selector: INTERACTIVE, skipSel });
|
|
217
|
+
// The CDP DOM snapshot and the live querySelectorAll are two separate,
|
|
218
|
+
// non-atomic reads. They can legitimately disagree — display:contents,
|
|
219
|
+
// nodes detached or injected between the two calls (next-route-announcer,
|
|
220
|
+
// Playwright internals, late hydration adding [tabindex]). A mismatch is NOT
|
|
221
|
+
// a fatal error: positional nodeId↔path alignment is only valid when the
|
|
222
|
+
// counts agree, so on skew we warn and skip the forced-state layer for this
|
|
223
|
+
// surface rather than aborting the whole capture (base + pseudo layers,
|
|
224
|
+
// the load-bearing certification, still succeed).
|
|
225
|
+
if (paths.length !== nodeIds.length) {
|
|
226
|
+
// eslint-disable-next-line no-console
|
|
227
|
+
console.warn(`styleproof: interactive-element count skew (CDP saw ${nodeIds.length}, page saw ${paths.length}); ` +
|
|
228
|
+
'skipping forced :hover/:focus/:active capture for this surface. This is usually a benign, ' +
|
|
229
|
+
'transient DOM difference (display:contents, injected/detached nodes). Re-run if it persists.');
|
|
230
|
+
await client.detach();
|
|
231
|
+
return { states: {}, skipped: true };
|
|
232
|
+
}
|
|
233
|
+
const limit = Math.min(nodeIds.length, maxInteractive);
|
|
234
|
+
if (nodeIds.length > maxInteractive) {
|
|
235
|
+
// eslint-disable-next-line no-console
|
|
236
|
+
console.warn(`styleproof: ${nodeIds.length} interactive elements exceeds maxInteractive=${maxInteractive}; ` +
|
|
237
|
+
`forced-state capture truncated to the first ${maxInteractive}. Raise maxInteractive to cover them all.`);
|
|
238
|
+
}
|
|
239
|
+
const states = {};
|
|
240
|
+
for (let i = 0; i < limit; i++) {
|
|
241
|
+
const p = paths[i];
|
|
242
|
+
if (!p)
|
|
243
|
+
continue;
|
|
244
|
+
const baseSnap = await page.evaluate(snapSubtree, { selector: INTERACTIVE, index: i });
|
|
245
|
+
for (const [stateName, forcedPseudoClasses] of Object.entries(STATE_SETS)) {
|
|
246
|
+
await client.send('CSS.forcePseudoState', { nodeId: nodeIds[i], forcedPseudoClasses });
|
|
247
|
+
const forcedSnap = await page.evaluate(snapSubtree, { selector: INTERACTIVE, index: i });
|
|
248
|
+
await client.send('CSS.forcePseudoState', { nodeId: nodeIds[i], forcedPseudoClasses: [] });
|
|
249
|
+
const delta = deltaBetween(baseSnap, forcedSnap);
|
|
250
|
+
if (Object.keys(delta).length)
|
|
251
|
+
(states[p] ??= {})[stateName] = delta;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
await client.detach();
|
|
255
|
+
return { states, skipped: false };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Capture the page's complete style map. Drive the page to the state you
|
|
259
|
+
* want first (navigate, open menus, settle fonts/animations) — the capture
|
|
260
|
+
* reads whatever is in front of it.
|
|
261
|
+
*/
|
|
262
|
+
export async function captureStyleMap(page, options = {}) {
|
|
263
|
+
const ignore = options.ignore ?? [];
|
|
264
|
+
const captureStates = options.captureStates ?? true;
|
|
265
|
+
const maxInteractive = options.maxInteractive ?? 800;
|
|
266
|
+
// Motion longhands first (FREEZE_CSS would null them), then everything else.
|
|
267
|
+
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true });
|
|
268
|
+
await page.addStyleTag({ content: FREEZE_CSS });
|
|
269
|
+
const base = await page.evaluate(capturePage, { ignore, motionOnly: false });
|
|
270
|
+
if (base.shadowHosts || base.sameOriginFrames) {
|
|
271
|
+
// eslint-disable-next-line no-console
|
|
272
|
+
console.warn(`styleproof: ${base.shadowHosts} shadow host(s) and ${base.sameOriginFrames} same-origin iframe(s) were ` +
|
|
273
|
+
'NOT traversed — styles inside shadow roots and frames are not captured or diffed. A refactor inside ' +
|
|
274
|
+
'one would be reported as identical. See README "Limitations".');
|
|
275
|
+
}
|
|
276
|
+
for (const [p, entry] of Object.entries(base.elements)) {
|
|
277
|
+
const m = motion.elements[p];
|
|
278
|
+
if (!m)
|
|
279
|
+
continue;
|
|
280
|
+
Object.assign(entry.style, m.style);
|
|
281
|
+
for (const [ps, props] of Object.entries(m.pseudo ?? {})) {
|
|
282
|
+
if (entry.pseudo?.[ps])
|
|
283
|
+
Object.assign(entry.pseudo[ps], props);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
let states = {};
|
|
287
|
+
let statesSkipped = false;
|
|
288
|
+
if (captureStates) {
|
|
289
|
+
const forced = await captureForcedStates(page, ignore, maxInteractive);
|
|
290
|
+
states = forced.states;
|
|
291
|
+
statesSkipped = forced.skipped;
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
defaults: base.defaults,
|
|
295
|
+
elements: base.elements,
|
|
296
|
+
states,
|
|
297
|
+
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
|
301
|
+
export function saveStyleMap(filePath, map) {
|
|
302
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
303
|
+
const json = JSON.stringify(map);
|
|
304
|
+
fs.writeFileSync(filePath, filePath.endsWith('.gz') ? gzipSync(json) : json);
|
|
305
|
+
}
|
|
306
|
+
/** Read a style map written by {@link saveStyleMap} (`.json` or `.json.gz`). */
|
|
307
|
+
export function loadStyleMap(filePath) {
|
|
308
|
+
let raw;
|
|
309
|
+
try {
|
|
310
|
+
raw = fs.readFileSync(filePath);
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
throw new Error(`styleproof: cannot read capture ${filePath}: ${e.message}`);
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const text = filePath.endsWith('.gz') ? gunzipSync(raw).toString('utf8') : raw.toString('utf8');
|
|
317
|
+
return JSON.parse(text);
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
throw new Error(`styleproof: capture ${filePath} is corrupt or truncated (${e.message}). ` +
|
|
321
|
+
'Re-capture it — a partial write or interrupted upload produces an unreadable .gz.');
|
|
322
|
+
}
|
|
323
|
+
}
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type StyleMap } from './capture.js';
|
|
2
|
+
/**
|
|
3
|
+
* Structured diff between two style maps. Custom properties (--*) are
|
|
4
|
+
* ignored: they are inputs, not outcomes — every visual effect of a variable
|
|
5
|
+
* lands in a real longhand which is compared in full.
|
|
6
|
+
*/
|
|
7
|
+
export type PropChange = {
|
|
8
|
+
prop: string;
|
|
9
|
+
before: string;
|
|
10
|
+
after: string;
|
|
11
|
+
};
|
|
12
|
+
export type Finding = {
|
|
13
|
+
kind: 'dom';
|
|
14
|
+
path: string;
|
|
15
|
+
cls: string;
|
|
16
|
+
change: 'added' | 'removed' | 'retagged';
|
|
17
|
+
detail?: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'style';
|
|
20
|
+
path: string;
|
|
21
|
+
cls: string;
|
|
22
|
+
pseudo: string | null;
|
|
23
|
+
props: PropChange[];
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'state';
|
|
26
|
+
path: string;
|
|
27
|
+
cls: string;
|
|
28
|
+
state: string;
|
|
29
|
+
sub: string;
|
|
30
|
+
props: PropChange[];
|
|
31
|
+
};
|
|
32
|
+
export type SurfaceDiff = {
|
|
33
|
+
surface: string;
|
|
34
|
+
/** Set when the surface was captured in only one of the two sets. */
|
|
35
|
+
missing?: 'before' | 'after';
|
|
36
|
+
findings: Finding[];
|
|
37
|
+
};
|
|
38
|
+
export type DiffCounts = {
|
|
39
|
+
dom: number;
|
|
40
|
+
style: number;
|
|
41
|
+
state: number;
|
|
42
|
+
};
|
|
43
|
+
/** Diff two style maps of the same surface. */
|
|
44
|
+
export declare function diffStyleMaps(a: StyleMap, b: StyleMap): Finding[];
|
|
45
|
+
/** Diff every same-named capture between two directories. */
|
|
46
|
+
export declare function diffStyleMapDirs(dirA: string, dirB: string): {
|
|
47
|
+
surfaces: SurfaceDiff[];
|
|
48
|
+
counts: DiffCounts;
|
|
49
|
+
};
|
|
50
|
+
/** Human label: structural path plus a truncated class hint. */
|
|
51
|
+
export declare function findingLabel(path: string, cls: string): string;
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadStyleMap } from './capture.js';
|
|
4
|
+
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
5
|
+
const changed = [];
|
|
6
|
+
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
7
|
+
if (prop.startsWith('--'))
|
|
8
|
+
continue;
|
|
9
|
+
const before = propsA[prop] ?? fallbackA[prop] ?? unsetA;
|
|
10
|
+
const after = propsB[prop] ?? fallbackB[prop] ?? unsetB;
|
|
11
|
+
if (before !== after)
|
|
12
|
+
changed.push({ prop, before, after });
|
|
13
|
+
}
|
|
14
|
+
return changed;
|
|
15
|
+
}
|
|
16
|
+
/** Diff two style maps of the same surface. */
|
|
17
|
+
export function diffStyleMaps(a, b) {
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const p of [...new Set([...Object.keys(a.elements), ...Object.keys(b.elements)])].sort()) {
|
|
20
|
+
const ea = a.elements[p];
|
|
21
|
+
const eb = b.elements[p];
|
|
22
|
+
if (!ea || !eb) {
|
|
23
|
+
findings.push({ kind: 'dom', path: p, cls: (ea ?? eb).cls, change: !ea ? 'added' : 'removed' });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (ea.tag !== eb.tag) {
|
|
27
|
+
findings.push({ kind: 'dom', path: p, cls: ea.cls, change: 'retagged', detail: `<${ea.tag}> → <${eb.tag}>` });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const defsA = a.defaults[ea.tag] ?? {};
|
|
31
|
+
const defsB = b.defaults[eb.tag] ?? {};
|
|
32
|
+
for (const pseudo of [null, ...new Set([...Object.keys(ea.pseudo ?? {}), ...Object.keys(eb.pseudo ?? {})])]) {
|
|
33
|
+
const propsA = pseudo ? (ea.pseudo?.[pseudo] ?? {}) : ea.style;
|
|
34
|
+
const propsB = pseudo ? (eb.pseudo?.[pseudo] ?? {}) : eb.style;
|
|
35
|
+
// A pseudo-element is pruned against its own UA defaults (capture stores
|
|
36
|
+
// them under a composite `tag::pseudo` key); fall back to the element's
|
|
37
|
+
// tag defaults for maps written before that fix.
|
|
38
|
+
const pdefsA = pseudo ? (a.defaults[ea.tag + pseudo] ?? defsA) : defsA;
|
|
39
|
+
const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
|
|
40
|
+
const props = diffProps(propsA, propsB, pdefsA, pdefsB, '(unset)', '(unset)');
|
|
41
|
+
if (props.length)
|
|
42
|
+
findings.push({ kind: 'style', path: p, cls: ea.cls, pseudo, props });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// If the forced-state layer was skipped on exactly one side (CDP skew during
|
|
46
|
+
// that capture), the :hover/:focus/:active layer was not compared — flag it
|
|
47
|
+
// loudly rather than letting {} vs {} read as "identical".
|
|
48
|
+
if (!!a.statesSkipped !== !!b.statesSkipped) {
|
|
49
|
+
findings.push({
|
|
50
|
+
kind: 'state',
|
|
51
|
+
path: '(surface)',
|
|
52
|
+
cls: '',
|
|
53
|
+
state: 'forced-state capture',
|
|
54
|
+
sub: '(surface)',
|
|
55
|
+
props: [
|
|
56
|
+
{
|
|
57
|
+
prop: 'forced :hover/:focus/:active layer',
|
|
58
|
+
before: a.statesSkipped ? 'skipped (CDP skew)' : 'captured',
|
|
59
|
+
after: b.statesSkipped ? 'skipped (CDP skew)' : 'captured',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
for (const p of new Set([...Object.keys(a.states ?? {}), ...Object.keys(b.states ?? {})])) {
|
|
65
|
+
const sa = a.states?.[p] ?? {};
|
|
66
|
+
const sb = b.states?.[p] ?? {};
|
|
67
|
+
const cls = (a.elements[p] ?? b.elements[p])?.cls ?? '';
|
|
68
|
+
for (const state of new Set([...Object.keys(sa), ...Object.keys(sb)])) {
|
|
69
|
+
const da = sa[state] ?? {};
|
|
70
|
+
const db = sb[state] ?? {};
|
|
71
|
+
for (const sub of new Set([...Object.keys(da), ...Object.keys(db)])) {
|
|
72
|
+
const props = diffProps(da[sub] ?? {}, db[sub] ?? {}, {}, {}, '(state does not change it)', '(state no longer changes it)');
|
|
73
|
+
if (props.length)
|
|
74
|
+
findings.push({ kind: 'state', path: p, cls, state, sub, props });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
function indexDir(dir) {
|
|
81
|
+
return Object.fromEntries(fs
|
|
82
|
+
.readdirSync(dir)
|
|
83
|
+
.filter((f) => /\.json(\.gz)?$/.test(f))
|
|
84
|
+
.map((f) => [f.replace(/\.json(\.gz)?$/, ''), path.join(dir, f)]));
|
|
85
|
+
}
|
|
86
|
+
/** Diff every same-named capture between two directories. */
|
|
87
|
+
export function diffStyleMapDirs(dirA, dirB) {
|
|
88
|
+
const indexA = indexDir(dirA);
|
|
89
|
+
const indexB = indexDir(dirB);
|
|
90
|
+
const names = [...new Set([...Object.keys(indexA), ...Object.keys(indexB)])].sort();
|
|
91
|
+
if (names.length === 0)
|
|
92
|
+
throw new Error(`no .json(.gz) captures found in ${dirA} or ${dirB}`);
|
|
93
|
+
const surfaces = [];
|
|
94
|
+
const counts = { dom: 0, style: 0, state: 0 };
|
|
95
|
+
for (const surface of names) {
|
|
96
|
+
if (!indexA[surface] || !indexB[surface]) {
|
|
97
|
+
surfaces.push({ surface, missing: indexA[surface] ? 'after' : 'before', findings: [] });
|
|
98
|
+
counts.dom++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const findings = diffStyleMaps(loadStyleMap(indexA[surface]), loadStyleMap(indexB[surface]));
|
|
102
|
+
for (const f of findings) {
|
|
103
|
+
if (f.kind === 'dom')
|
|
104
|
+
counts.dom++;
|
|
105
|
+
else if (f.kind === 'style')
|
|
106
|
+
counts.style += f.props.length;
|
|
107
|
+
else
|
|
108
|
+
counts.state += f.props.length;
|
|
109
|
+
}
|
|
110
|
+
if (findings.length)
|
|
111
|
+
surfaces.push({ surface, findings });
|
|
112
|
+
}
|
|
113
|
+
return { surfaces, counts };
|
|
114
|
+
}
|
|
115
|
+
/** Human label: structural path plus a truncated class hint. */
|
|
116
|
+
export function findingLabel(path, cls) {
|
|
117
|
+
if (!cls)
|
|
118
|
+
return path;
|
|
119
|
+
const classes = cls.split(' ').filter(Boolean);
|
|
120
|
+
return `${path} (.${classes.slice(0, 3).join('.')}${classes.length > 3 ? '…' : ''})`;
|
|
121
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
2
|
+
export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
|
|
3
|
+
export { defineStyleMapCapture } from './runner.js';
|
|
4
|
+
export type { Surface, DefineOptions } from './runner.js';
|
|
5
|
+
export { diffStyleMaps, diffStyleMapDirs, findingLabel } from './diff.js';
|
|
6
|
+
export type { Finding, PropChange, SurfaceDiff, DiffCounts } from './diff.js';
|
|
7
|
+
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
8
|
+
export type { ReportOptions, ReportResult } from './report.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
2
|
+
export { defineStyleMapCapture } from './runner.js';
|
|
3
|
+
export { diffStyleMaps, diffStyleMapDirs, findingLabel } from './diff.js';
|
|
4
|
+
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type PropChange } from './diff.js';
|
|
2
|
+
/**
|
|
3
|
+
* Visual diff report: for every surface with findings, crop the before/after
|
|
4
|
+
* full-page screenshots around the changed elements and write a markdown
|
|
5
|
+
* report with side-by-side images plus the exact property changes.
|
|
6
|
+
*
|
|
7
|
+
* Cropping zooms out to the OUTERMOST changed element: changed paths that are
|
|
8
|
+
* descendants of other changed paths are folded into their ancestor, nearby
|
|
9
|
+
* regions are merged, and both sides are cropped at identical dimensions
|
|
10
|
+
* (centered on each side's own coordinates) so the pair stays comparable
|
|
11
|
+
* even when the change moved things around.
|
|
12
|
+
*/
|
|
13
|
+
export type ReportOptions = {
|
|
14
|
+
beforeDir: string;
|
|
15
|
+
afterDir: string;
|
|
16
|
+
outDir: string;
|
|
17
|
+
/** Prefix for image URLs in report.md (default: relative paths). */
|
|
18
|
+
imageBaseUrl?: string;
|
|
19
|
+
/** Padding around the union of changed rects (default 24px). */
|
|
20
|
+
pad?: number;
|
|
21
|
+
/** Minimum crop size, for context around tiny changes (default 320×180). */
|
|
22
|
+
minWidth?: number;
|
|
23
|
+
minHeight?: number;
|
|
24
|
+
/** Crops taller than this are clamped (default 1600px). */
|
|
25
|
+
maxHeight?: number;
|
|
26
|
+
/** Max crop regions per surface before collapsing into one union crop (default 6). */
|
|
27
|
+
maxCrops?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Include size/position-derived longhands (height, width, transform-origin…)
|
|
30
|
+
* in the report. Off by default: on a reflow they change up the whole ancestor
|
|
31
|
+
* chain and would anchor crops to the entire page. The certification differ
|
|
32
|
+
* (`styleproof-diff`) always keeps them.
|
|
33
|
+
*/
|
|
34
|
+
includeLayoutNoise?: boolean;
|
|
35
|
+
};
|
|
36
|
+
export type ReportResult = {
|
|
37
|
+
changedSurfaces: number;
|
|
38
|
+
totalFindings: number;
|
|
39
|
+
reportMdPath: string;
|
|
40
|
+
reportJsonPath: string;
|
|
41
|
+
};
|
|
42
|
+
export declare function summarizeProps(props: PropChange[]): PropChange[];
|
|
43
|
+
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
44
|
+
export declare function prettyLabel(p: string, cls: string): string;
|
|
45
|
+
export declare function generateStyleMapReport(opts: ReportOptions): ReportResult;
|