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/dist/report.js ADDED
@@ -0,0 +1,463 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PNG } from 'pngjs';
4
+ import { loadStyleMap } from './capture.js';
5
+ import { diffStyleMapDirs } from './diff.js';
6
+ const rectToBox = (r) => ({ x: r[0], y: r[1], w: r[2], h: r[3] });
7
+ const pad = (b, by) => ({ x: b.x - by, y: b.y - by, w: b.w + 2 * by, h: b.h + 2 * by });
8
+ const union = (a, b) => {
9
+ const x = Math.min(a.x, b.x);
10
+ const y = Math.min(a.y, b.y);
11
+ return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
12
+ };
13
+ const intersects = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
14
+ const visible = (b) => !!b && b.w > 0 && b.h > 0;
15
+ /** Outermost changed paths: drop any path that has a changed strict ancestor. */
16
+ function outermost(paths) {
17
+ return paths.filter((p) => !paths.some((q) => q !== p && p.startsWith(q + ' > ')));
18
+ }
19
+ function groupRegions(paths, a, b, padBy) {
20
+ const groups = paths.map((p) => {
21
+ const ra = a.elements[p]?.rect;
22
+ const rb = b.elements[p]?.rect;
23
+ const before = ra ? pad(rectToBox(ra), padBy) : null;
24
+ const after = rb ? pad(rectToBox(rb), padBy) : null;
25
+ return { paths: [p], before: visible(before) ? before : null, after: visible(after) ? after : null };
26
+ });
27
+ // Merge groups whose regions overlap on either side, to a fixpoint.
28
+ let merged = true;
29
+ while (merged) {
30
+ merged = false;
31
+ outer: for (let i = 0; i < groups.length; i++) {
32
+ for (let j = i + 1; j < groups.length; j++) {
33
+ const gi = groups[i];
34
+ const gj = groups[j];
35
+ const hit = (visible(gi.after) && visible(gj.after) && intersects(gi.after, gj.after)) ||
36
+ (visible(gi.before) && visible(gj.before) && intersects(gi.before, gj.before));
37
+ if (hit) {
38
+ gi.paths.push(...gj.paths);
39
+ gi.before = visible(gi.before) && visible(gj.before) ? union(gi.before, gj.before) : (gi.before ?? gj.before);
40
+ gi.after = visible(gi.after) && visible(gj.after) ? union(gi.after, gj.after) : (gi.after ?? gj.after);
41
+ groups.splice(j, 1);
42
+ merged = true;
43
+ break outer;
44
+ }
45
+ }
46
+ }
47
+ }
48
+ return groups;
49
+ }
50
+ function cropPng(src, box, w, h) {
51
+ // Center the fixed-size crop on the box, clamped to the image.
52
+ const cx = box.x + box.w / 2;
53
+ const cy = box.y + box.h / 2;
54
+ const x = Math.max(0, Math.min(Math.round(cx - w / 2), src.width - w));
55
+ const y = Math.max(0, Math.min(Math.round(cy - h / 2), src.height - h));
56
+ const cw = Math.min(w, src.width);
57
+ const ch = Math.min(h, src.height);
58
+ const out = new PNG({ width: cw, height: ch });
59
+ PNG.bitblt(src, out, Math.max(0, x), Math.max(0, y), cw, ch, 0, 0);
60
+ return out;
61
+ }
62
+ // Lossless but lean: drop the alpha channel (every crop/composite is opaque),
63
+ // max deflate, adaptive per-row filtering. ~15% smaller than the default, and
64
+ // faithful — these images are eyeballed for intentional change, so no lossy
65
+ // artifacts that could masquerade as a real diff. The bigger lever is in the
66
+ // action: it commits only the composite, never the separate before/after crops.
67
+ const PNG_OPTS = { deflateLevel: 9, filterType: -1, colorType: 2, inputColorType: 6 };
68
+ function writePng(file, png) {
69
+ fs.writeFileSync(file, PNG.sync.write(png, PNG_OPTS));
70
+ }
71
+ function fillRect(png, x, y, w, h, [r, g, b]) {
72
+ for (let yy = Math.max(0, y); yy < Math.min(png.height, y + h); yy++) {
73
+ for (let xx = Math.max(0, x); xx < Math.min(png.width, x + w); xx++) {
74
+ const i = (yy * png.width + xx) << 2;
75
+ png.data[i] = r;
76
+ png.data[i + 1] = g;
77
+ png.data[i + 2] = b;
78
+ png.data[i + 3] = 255;
79
+ }
80
+ }
81
+ }
82
+ /**
83
+ * One labelled before|after image: the two equal-size crops on a dark canvas,
84
+ * a divider between them, and a top accent bar per side (grey = before,
85
+ * blue = after) as a font-free before/after cue. Left is always before.
86
+ */
87
+ function compositePair(before, after) {
88
+ const PAD = 20;
89
+ const GAP = 28;
90
+ const BAR = 6; // accent strip height
91
+ const w = Math.max(before.width, after.width);
92
+ const h = Math.max(before.height, after.height);
93
+ const width = PAD + w + GAP + w + PAD;
94
+ const height = PAD + BAR + h + PAD;
95
+ const canvas = new PNG({ width, height });
96
+ fillRect(canvas, 0, 0, width, height, [13, 17, 23]); // GitHub dark
97
+ const leftX = PAD;
98
+ const rightX = PAD + w + GAP;
99
+ const top = PAD + BAR;
100
+ fillRect(canvas, leftX, PAD, w, BAR, [110, 118, 129]); // before: grey
101
+ fillRect(canvas, rightX, PAD, w, BAR, [88, 166, 255]); // after: blue
102
+ PNG.bitblt(before, canvas, 0, 0, before.width, before.height, leftX, top);
103
+ PNG.bitblt(after, canvas, 0, 0, after.width, after.height, rightX, top);
104
+ fillRect(canvas, PAD + w + GAP / 2 - 1, PAD, 2, BAR + h, [48, 54, 61]); // divider
105
+ return canvas;
106
+ }
107
+ function readPng(file) {
108
+ if (!fs.existsSync(file))
109
+ return null;
110
+ return PNG.sync.read(fs.readFileSync(file));
111
+ }
112
+ // --- readable findings: dedupe logical longhands, collapse shorthand families,
113
+ // humanize values, label by semantic marker, group identical siblings ------
114
+ // Logical longhand → its physical equivalent (LTR, horizontal-tb). Dropped when
115
+ // the physical one changed identically, so each change appears once.
116
+ const LOGICAL_TO_PHYSICAL = (() => {
117
+ const m = {};
118
+ const sides = {
119
+ 'block-start': 'top',
120
+ 'block-end': 'bottom',
121
+ 'inline-start': 'left',
122
+ 'inline-end': 'right',
123
+ };
124
+ for (const [l, p] of Object.entries(sides)) {
125
+ for (const k of ['color', 'width', 'style'])
126
+ m[`border-${l}-${k}`] = `border-${p}-${k}`;
127
+ m[`margin-${l}`] = `margin-${p}`;
128
+ m[`padding-${l}`] = `padding-${p}`;
129
+ m[`inset-${l}`] = p;
130
+ }
131
+ const radius = {
132
+ 'start-start': 'top-left',
133
+ 'start-end': 'top-right',
134
+ 'end-start': 'bottom-left',
135
+ 'end-end': 'bottom-right',
136
+ };
137
+ for (const [l, p] of Object.entries(radius))
138
+ m[`border-${l}-radius`] = `border-${p}-radius`;
139
+ return m;
140
+ })();
141
+ // Length-valued 4-side families → CSS 1–4-value shorthand whenever all four
142
+ // sides changed (e.g. `padding: 26px 24px → 28px`).
143
+ const BOX4 = [
144
+ { short: 'margin', parts: ['top', 'right', 'bottom', 'left'].map((s) => `margin-${s}`) },
145
+ { short: 'padding', parts: ['top', 'right', 'bottom', 'left'].map((s) => `padding-${s}`) },
146
+ { short: 'border-width', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-width`) },
147
+ {
148
+ short: 'border-radius',
149
+ parts: ['top-left', 'top-right', 'bottom-right', 'bottom-left'].map((s) => `border-${s}-radius`),
150
+ },
151
+ ];
152
+ // Colour/keyword families → one row only when all four sides match (else
153
+ // per-side, which keeps each colour swatchable).
154
+ const UNIFORM = [
155
+ { short: 'border-color', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-color`) },
156
+ { short: 'border-style', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-style`) },
157
+ ];
158
+ const boxShorthand = ([t, r, b, l]) => t === r && r === b && b === l
159
+ ? t
160
+ : t === b && r === l
161
+ ? `${t} ${r}`
162
+ : r === l
163
+ ? `${t} ${r} ${b}`
164
+ : `${t} ${r} ${b} ${l}`;
165
+ const PROP_ORDER = [
166
+ 'display',
167
+ 'position',
168
+ 'grid-template-columns',
169
+ 'grid-template-rows',
170
+ 'flex-direction',
171
+ 'justify-content',
172
+ 'align-items',
173
+ 'gap',
174
+ 'margin',
175
+ 'padding',
176
+ 'border-width',
177
+ 'border-style',
178
+ 'border-color',
179
+ 'border-radius',
180
+ 'outline',
181
+ 'background-color',
182
+ 'background-image',
183
+ 'color',
184
+ 'box-shadow',
185
+ 'opacity',
186
+ 'transform',
187
+ 'font-family',
188
+ 'font-size',
189
+ 'font-weight',
190
+ 'line-height',
191
+ 'letter-spacing',
192
+ 'text-transform',
193
+ 'text-align',
194
+ ];
195
+ const orderIdx = (p) => {
196
+ const i = PROP_ORDER.indexOf(p);
197
+ return i === -1 ? PROP_ORDER.length : i;
198
+ };
199
+ function cleanVal(v) {
200
+ let s = v.replace(/-?\d+\.\d+/g, (m) => String(Math.round(parseFloat(m) * 10) / 10));
201
+ if (!s.includes('(')) {
202
+ const toks = s.split(' ');
203
+ if (toks.length > 1 && new Set(toks).size === 1)
204
+ s = `${toks[0]} ×${toks.length}`;
205
+ }
206
+ return s.replace(/rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/g, 'transparent');
207
+ }
208
+ // These default to `currentColor`, so a `color` change drags them all along —
209
+ // pure echoes, dropped when they match the `color` change.
210
+ const CURRENTCOLOR_FOLLOWERS = [
211
+ 'caret-color',
212
+ 'outline-color',
213
+ 'column-rule-color',
214
+ 'text-decoration-color',
215
+ 'text-emphasis-color',
216
+ '-webkit-text-fill-color',
217
+ '-webkit-text-stroke-color',
218
+ ];
219
+ export function summarizeProps(props) {
220
+ const map = new Map(props.map((p) => [p.prop, { ...p }]));
221
+ for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
222
+ const lo = map.get(logical);
223
+ const ph = map.get(physical);
224
+ if (lo && ph && lo.before === ph.before && lo.after === ph.after)
225
+ map.delete(logical);
226
+ }
227
+ const color = map.get('color');
228
+ if (color)
229
+ for (const f of CURRENTCOLOR_FOLLOWERS) {
230
+ const m = map.get(f);
231
+ if (m && m.before === color.before && m.after === color.after)
232
+ map.delete(f);
233
+ }
234
+ for (const fam of BOX4) {
235
+ const members = fam.parts.map((p) => map.get(p));
236
+ if (members.every((m) => !!m)) {
237
+ fam.parts.forEach((p) => map.delete(p));
238
+ map.set(fam.short, {
239
+ prop: fam.short,
240
+ before: boxShorthand(members.map((m) => m.before)),
241
+ after: boxShorthand(members.map((m) => m.after)),
242
+ });
243
+ }
244
+ }
245
+ const rg = map.get('row-gap');
246
+ const cg = map.get('column-gap');
247
+ if (rg && cg) {
248
+ map.delete('row-gap');
249
+ map.delete('column-gap');
250
+ map.set('gap', {
251
+ prop: 'gap',
252
+ before: rg.before === cg.before ? rg.before : `${rg.before} ${cg.before}`,
253
+ after: rg.after === cg.after ? rg.after : `${rg.after} ${cg.after}`,
254
+ });
255
+ }
256
+ for (const fam of UNIFORM) {
257
+ const members = fam.parts.map((p) => map.get(p)).filter((m) => !!m);
258
+ if (members.length === fam.parts.length &&
259
+ members.every((m) => m.before === members[0].before && m.after === members[0].after)) {
260
+ fam.parts.forEach((p) => map.delete(p));
261
+ map.set(fam.short, { prop: fam.short, before: members[0].before, after: members[0].after });
262
+ }
263
+ }
264
+ return [...map.values()]
265
+ .map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
266
+ .sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop));
267
+ }
268
+ /** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
269
+ export function prettyLabel(p, cls) {
270
+ const tag = (p.split('>').pop() ?? '').trim().replace(/:nth-child\(\d+\)/, '') || 'el';
271
+ const first = cls.split(/\s+/)[0] ?? '';
272
+ return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
273
+ }
274
+ // Group findings (identical siblings collapse to one ×N block) and render each
275
+ // as a 3-column table; colours land in their own cell so GitHub adds swatches.
276
+ function renderFindings(findings, maxRows = 60) {
277
+ const groups = [];
278
+ const byKey = new Map();
279
+ for (const f of findings) {
280
+ const label = prettyLabel(f.path, f.cls);
281
+ const note = f.kind === 'state' ? `:${f.state}` : f.kind === 'style' ? (f.pseudo ?? '') : '';
282
+ const rows = f.kind === 'dom' ? [] : summarizeProps(f.props);
283
+ const headOnly = f.kind === 'dom' ? `DOM ${f.change}${f.detail ? ` ${f.detail}` : ''}` : '';
284
+ if (f.kind !== 'dom' && rows.length === 0)
285
+ continue;
286
+ const key = `${label}|${note}|${headOnly}|${JSON.stringify(rows)}`;
287
+ const g = byKey.get(key);
288
+ if (g)
289
+ g.count++;
290
+ else {
291
+ const ng = { label, note: headOnly || note, rows, count: 1 };
292
+ byKey.set(key, ng);
293
+ groups.push(ng);
294
+ }
295
+ }
296
+ const out = [];
297
+ let used = 0;
298
+ for (let i = 0; i < groups.length; i++) {
299
+ if (used >= maxRows) {
300
+ out.push('', `_…and ${groups.length - i} more element(s) — see report.json._`);
301
+ break;
302
+ }
303
+ const g = groups[i];
304
+ const head = `**\`${g.label}\`**${g.count > 1 ? ` ×${g.count}` : ''}${g.note ? ` ${g.note}` : ''}`;
305
+ if (!g.rows.length) {
306
+ out.push('', head);
307
+ used += 1;
308
+ continue;
309
+ }
310
+ out.push('', head, '', '| Property | Before | After |', '| --- | --- | --- |', ...g.rows.map((r) => `| \`${r.prop}\` | \`${r.before}\` | \`${r.after}\` |`));
311
+ used += 2 + g.rows.length;
312
+ }
313
+ return out;
314
+ }
315
+ // Computed values that follow from an element's box size or position rather than
316
+ // its styling. On any reflow they change all the way up the ancestor chain
317
+ // (body, main, section…), so an element whose ONLY changes are these is a reflow
318
+ // casualty: it must not anchor a crop region (that would zoom to the whole page)
319
+ // nor clutter the findings. The certification differ keeps them — a reflow IS a
320
+ // change to certify — but the visual report focuses on styling intent.
321
+ const DERIVED_PROPS = new Set([
322
+ 'width',
323
+ 'height',
324
+ 'block-size',
325
+ 'inline-size',
326
+ 'min-width',
327
+ 'min-height',
328
+ 'max-width',
329
+ 'max-height',
330
+ 'perspective-origin',
331
+ 'transform-origin',
332
+ // position offsets shift with the document on any reflow
333
+ 'top',
334
+ 'right',
335
+ 'bottom',
336
+ 'left',
337
+ 'inset-block-start',
338
+ 'inset-block-end',
339
+ 'inset-inline-start',
340
+ 'inset-inline-end',
341
+ ]);
342
+ const hasRealChange = (f) => f.kind === 'dom' || f.props.some((p) => !DERIVED_PROPS.has(p.prop));
343
+ const stripDerived = (f) => f.kind === 'dom' ? f : { ...f, props: f.props.filter((p) => !DERIVED_PROPS.has(p.prop)) };
344
+ export function generateStyleMapReport(opts) {
345
+ const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, } = opts;
346
+ const includeNoise = opts.includeLayoutNoise ?? false;
347
+ const { surfaces } = diffStyleMapDirs(beforeDir, afterDir);
348
+ fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
349
+ // Focus each surface on styling intent: drop reflow-casualty elements (only
350
+ // size/position-derived changes) and strip those props from the rest, unless
351
+ // includeLayoutNoise is set. Surfaces left with no real change are dropped.
352
+ const prepared = surfaces
353
+ .map((sd) => ({
354
+ sd,
355
+ findings: sd.missing || includeNoise ? sd.findings : sd.findings.filter(hasRealChange).map(stripDerived),
356
+ }))
357
+ .filter((p) => p.sd.missing || p.findings.length > 0);
358
+ // Count what the report actually shows (after shorthand/dedupe collapsing),
359
+ // not the raw longhand explosion.
360
+ const shown = { dom: 0, style: 0, state: 0 };
361
+ for (const { findings } of prepared)
362
+ for (const f of findings) {
363
+ if (f.kind === 'dom')
364
+ shown.dom++;
365
+ else if (f.kind === 'style')
366
+ shown.style += summarizeProps(f.props).length;
367
+ else
368
+ shown.state += summarizeProps(f.props).length;
369
+ }
370
+ const md = [];
371
+ const json = [];
372
+ const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
373
+ md.push('## 🗺️ StyleProof report');
374
+ md.push('');
375
+ if (prepared.length === 0) {
376
+ md.push('✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
377
+ }
378
+ else {
379
+ md.push(`**${shown.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** across ${prepared.length} changed surface(s).`);
380
+ }
381
+ let totalFindings = 0;
382
+ for (const { sd, findings: surfaceFindings } of prepared) {
383
+ md.push('', `### \`${sd.surface}\``);
384
+ if (sd.missing) {
385
+ md.push('', `⚠️ captured only in the **${sd.missing === 'before' ? 'after' : 'before'}** set — re-run both captures.`);
386
+ json.push({ surface: sd.surface, missing: sd.missing });
387
+ continue;
388
+ }
389
+ totalFindings += surfaceFindings.length;
390
+ const mapA = loadStyleMap(findCapture(beforeDir, sd.surface));
391
+ const mapB = loadStyleMap(findCapture(afterDir, sd.surface));
392
+ const pngA = readPng(path.join(beforeDir, `${sd.surface}.png`));
393
+ const pngB = readPng(path.join(afterDir, `${sd.surface}.png`));
394
+ const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
395
+ let groups = groupRegions(changedPaths, mapA, mapB, padBy);
396
+ if (groups.length > maxCrops) {
397
+ groups = [
398
+ groups.reduce((acc, g) => ({
399
+ paths: [...acc.paths, ...g.paths],
400
+ before: visible(acc.before) && visible(g.before) ? union(acc.before, g.before) : (acc.before ?? g.before),
401
+ after: visible(acc.after) && visible(g.after) ? union(acc.after, g.after) : (acc.after ?? g.after),
402
+ })),
403
+ ];
404
+ }
405
+ const surfaceJson = { surface: sd.surface, regions: [] };
406
+ let n = 0;
407
+ for (const g of groups) {
408
+ n++;
409
+ const findings = surfaceFindings.filter((f) => g.paths.some((p) => f.path === p || f.path.startsWith(p + ' > ')));
410
+ const lines = renderFindings(findings);
411
+ const region = visible(g.after) ? g.after : g.before;
412
+ let images = {};
413
+ if (region && pngA && pngB) {
414
+ // Same crop dimensions on both sides so the pair reads as a pair.
415
+ const w = Math.max(minWidth, visible(g.before) ? g.before.w : 0, visible(g.after) ? g.after.w : 0);
416
+ const h = Math.min(maxHeight, Math.max(minHeight, visible(g.before) ? g.before.h : 0, visible(g.after) ? g.after.h : 0));
417
+ // Path-safe stem: a surface key like `hero@1280` becomes `hero-1280`
418
+ // so relative image links resolve cleanly in any markdown host.
419
+ const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${n}`;
420
+ const before = cropPng(pngA, visible(g.before) ? g.before : region, w, h);
421
+ const after = cropPng(pngB, visible(g.after) ? g.after : region, w, h);
422
+ const composite = compositePair(before, after);
423
+ writePng(path.join(outDir, `${stem}-before.png`), before);
424
+ writePng(path.join(outDir, `${stem}-after.png`), after);
425
+ writePng(path.join(outDir, `${stem}-composite.png`), composite);
426
+ images = { before: `${stem}-before.png`, after: `${stem}-after.png`, composite: `${stem}-composite.png` };
427
+ // One side-by-side image per change: clean inline render, single upload.
428
+ md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', '<sub>◀ before · after ▶</sub>');
429
+ }
430
+ else if (!region) {
431
+ md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
432
+ }
433
+ else {
434
+ md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
435
+ }
436
+ md.push(...lines);
437
+ surfaceJson.regions.push({
438
+ paths: g.paths,
439
+ before: g.before,
440
+ after: g.after,
441
+ images,
442
+ findings,
443
+ });
444
+ }
445
+ json.push(surfaceJson);
446
+ }
447
+ if (surfaces.length) {
448
+ md.push('', '---', '_To accept these changes, regenerate the committed baseline from this build and commit it with your diff._');
449
+ }
450
+ const reportMdPath = path.join(outDir, 'report.md');
451
+ const reportJsonPath = path.join(outDir, 'report.json');
452
+ fs.writeFileSync(reportMdPath, md.join('\n') + '\n');
453
+ fs.writeFileSync(reportJsonPath, JSON.stringify({ counts: shown, surfaces: json }, null, 2));
454
+ return { changedSurfaces: prepared.length, totalFindings, reportMdPath, reportJsonPath };
455
+ }
456
+ function findCapture(dir, surface) {
457
+ for (const ext of ['.json.gz', '.json']) {
458
+ const p = path.join(dir, surface + ext);
459
+ if (fs.existsSync(p))
460
+ return p;
461
+ }
462
+ throw new Error(`no capture for ${surface} in ${dir}`);
463
+ }
@@ -0,0 +1,45 @@
1
+ import type { Page } from '@playwright/test';
2
+ /**
3
+ * A surface is one deterministic page state worth certifying: a route plus
4
+ * the interactions that reach the state, captured at one viewport width per
5
+ * @media band of its stylesheets.
6
+ */
7
+ export type Surface = {
8
+ /** Capture file name prefix; must be unique. */
9
+ key: string;
10
+ /** Navigate and drive the page to the state, ending settled (fonts loaded, entrance animations done). */
11
+ go: (page: Page) => Promise<void>;
12
+ /** Selectors for nondeterministic regions (live data, third-party embeds); skipped entirely. */
13
+ ignore?: string[];
14
+ /** Viewport widths to sweep — one per @media band, so breakpoint rules are verified too. */
15
+ widths: number[];
16
+ /** Viewport height: a number, or a function of the width (default 800). */
17
+ height?: number | ((width: number) => number);
18
+ };
19
+ export type DefineOptions = {
20
+ surfaces: Surface[];
21
+ /**
22
+ * Output directory label. Convention: drive it from an env var so the same
23
+ * spec captures `before`, `after`, or a CI label — and skips entirely when
24
+ * unset, keeping the spec inert during normal test runs.
25
+ */
26
+ dir: string | undefined;
27
+ /** Base output directory (default `__stylemaps__` next to the invoking spec's CWD). */
28
+ baseDir?: string;
29
+ /**
30
+ * Also save a full-page screenshot per capture (default true). The report
31
+ * generator crops these to show changed regions side by side; captures
32
+ * without screenshots still diff, but produce text-only reports.
33
+ */
34
+ screenshots?: boolean;
35
+ };
36
+ /**
37
+ * Generate one Playwright test per surface × width that captures the style
38
+ * map to `<baseDir>/<dir>/<key>@<width>.json.gz`.
39
+ *
40
+ * ```ts
41
+ * // styleproof.spec.ts
42
+ * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
43
+ * ```
44
+ */
45
+ export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, }: DefineOptions): void;
package/dist/runner.js ADDED
@@ -0,0 +1,35 @@
1
+ import { test } from '@playwright/test';
2
+ import path from 'node:path';
3
+ import { captureStyleMap, saveStyleMap } from './capture.js';
4
+ /**
5
+ * Generate one Playwright test per surface × width that captures the style
6
+ * map to `<baseDir>/<dir>/<key>@<width>.json.gz`.
7
+ *
8
+ * ```ts
9
+ * // styleproof.spec.ts
10
+ * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
11
+ * ```
12
+ */
13
+ export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, }) {
14
+ test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
15
+ test.describe('styleproof capture', () => {
16
+ for (const surface of surfaces) {
17
+ for (const width of surface.widths) {
18
+ test(`${surface.key} @ ${width}`, async ({ page }) => {
19
+ test.setTimeout(180_000);
20
+ const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
21
+ await page.setViewportSize({ width, height });
22
+ await surface.go(page);
23
+ const map = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
24
+ const stem = path.join(baseDir, dir, `${surface.key}@${width}`);
25
+ saveStyleMap(`${stem}.json.gz`, map);
26
+ if (screenshots) {
27
+ // captureStyleMap froze animations/transitions, so this is the
28
+ // same settled state the map describes.
29
+ await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
30
+ }
31
+ });
32
+ }
33
+ }
34
+ });
35
+ }
Binary file
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "styleproof",
3
+ "version": "1.0.0",
4
+ "description": "Certify CSS refactors with the browser's computed styles: capture every resolved longhand, pseudo-element, and forced hover/focus/active state, then diff before against after.",
5
+ "keywords": [
6
+ "playwright",
7
+ "css",
8
+ "refactoring",
9
+ "visual-regression",
10
+ "computed-style",
11
+ "computed-styles",
12
+ "tailwind",
13
+ "migration",
14
+ "snapshot",
15
+ "testing",
16
+ "ci"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "Ben Sheridan-Edwards <ben@codewalnut.com>",
20
+ "homepage": "https://github.com/BenSheridanEdwards/styleproof#readme",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/BenSheridanEdwards/styleproof.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/BenSheridanEdwards/styleproof/issues"
27
+ },
28
+ "type": "module",
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "bin": {
39
+ "styleproof-init": "./bin/styleproof-init.mjs",
40
+ "styleproof-diff": "./bin/styleproof-diff.mjs",
41
+ "styleproof-report": "./bin/styleproof-report.mjs"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "bin",
46
+ "docs/demo-composite.png",
47
+ "README.md",
48
+ "CHANGELOG.md",
49
+ "LICENSE"
50
+ ],
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "scripts": {
55
+ "clean": "rm -rf dist",
56
+ "build": "tsc",
57
+ "typecheck": "tsc --noEmit",
58
+ "lint": "eslint .",
59
+ "lint:fix": "eslint . --fix",
60
+ "format": "prettier --write \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
61
+ "format:check": "prettier --check \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
62
+ "test": "npm run build && node --test test/*.test.mjs",
63
+ "test:watch": "node --test --watch test/*.test.mjs",
64
+ "test:e2e": "npm run build && playwright test test/smoke.e2e.spec.ts",
65
+ "init": "node ./bin/styleproof-init.mjs",
66
+ "prepare": "npm run build",
67
+ "prepublishOnly": "npm run clean && npm run build && npm run typecheck"
68
+ },
69
+ "peerDependencies": {
70
+ "@playwright/test": ">=1.40"
71
+ },
72
+ "dependencies": {
73
+ "pngjs": "^7.0.0"
74
+ },
75
+ "devDependencies": {
76
+ "@eslint/js": "^9.17.0",
77
+ "@playwright/test": "^1.60.0",
78
+ "@types/node": "^25.9.3",
79
+ "@types/pngjs": "^6.0.5",
80
+ "eslint": "^9.17.0",
81
+ "globals": "^15.14.0",
82
+ "prettier": "^3.4.2",
83
+ "typescript": "^5.6.2",
84
+ "typescript-eslint": "^8.18.0"
85
+ }
86
+ }