styleproof 1.0.0 → 1.1.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 CHANGED
@@ -7,6 +7,29 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0]
11
+
12
+ Report readability overhaul. No change to the capture format, the diff, or the
13
+ public API — `styleproof-diff` certification is byte-for-byte identical; only the
14
+ human-facing `styleproof-report` / `generateStyleMapReport` output changed.
15
+
16
+ ### Changed
17
+
18
+ - **Group an identical change across surfaces into one section + one image.** A
19
+ change that appears on every breakpoint of a responsive page (e.g. a new footer
20
+ link across 7 landing widths) was repeated once per surface with a near-duplicate
21
+ crop each time. Surfaces whose findings are identical now collapse into a single
22
+ section that lists the affected surfaces (`landing @ 1280, 1080, 390 …`) and shows
23
+ one representative crop (the widest). The summary counts distinct changes, not
24
+ per-surface repetitions.
25
+ - **One heading per element.** An element's base, pseudo, and forced-state findings
26
+ render under a single heading instead of a separate `DOM added` / `:hover` /
27
+ `:focus` block each.
28
+ - **Newly-added elements read naturally.** A brand-new element has no meaningful
29
+ "before", so its forced states render as a `State · Property · Value` table of the
30
+ values it takes, instead of a `Before` column full of `(state does not change it)`.
31
+ - `outline-width`/`-style`/`-color` collapse into one `outline` row.
32
+
10
33
  ## [1.0.0]
11
34
 
12
35
  First stable release and first publish to npm. The capture format, the public API
@@ -192,7 +215,8 @@ number)`), so each viewport band can capture at its own height. Default remains
192
215
  - `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
193
216
  property, and state that drifted (exit 1).
194
217
 
195
- [Unreleased]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.0.0...HEAD
218
+ [Unreleased]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.1.0...HEAD
219
+ [1.1.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.0.0...v1.1.0
196
220
  [1.0.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.7.0...v1.0.0
197
221
  [0.7.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.6.0...v0.7.0
198
222
  [0.6.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.5.0...v0.6.0
package/dist/report.js CHANGED
@@ -261,6 +261,20 @@ export function summarizeProps(props) {
261
261
  map.set(fam.short, { prop: fam.short, before: members[0].before, after: members[0].after });
262
262
  }
263
263
  }
264
+ // outline-width/-style/-color → one `outline` row (offset stays separate).
265
+ const ow = map.get('outline-width');
266
+ const os = map.get('outline-style');
267
+ const oc = map.get('outline-color');
268
+ if (ow && os && oc) {
269
+ map.delete('outline-width');
270
+ map.delete('outline-style');
271
+ map.delete('outline-color');
272
+ map.set('outline', {
273
+ prop: 'outline',
274
+ before: `${ow.before} ${os.before} ${oc.before}`,
275
+ after: `${ow.after} ${os.after} ${oc.after}`,
276
+ });
277
+ }
264
278
  return [...map.values()]
265
279
  .map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
266
280
  .sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop));
@@ -271,44 +285,143 @@ export function prettyLabel(p, cls) {
271
285
  const first = cls.split(/\s+/)[0] ?? '';
272
286
  return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
273
287
  }
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();
288
+ const surfaceBase = (s) => s.replace(/@\d+$/, '');
289
+ const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
290
+ /** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
291
+ function formatSurfaceList(surfaces) {
292
+ const byBase = new Map();
293
+ for (const s of surfaces) {
294
+ const arr = byBase.get(surfaceBase(s)) ?? [];
295
+ arr.push(surfaceWidth(s));
296
+ byBase.set(surfaceBase(s), arr);
297
+ }
298
+ return [...byBase]
299
+ .map(([base, ws]) => {
300
+ const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
301
+ return widths.length ? `${base} @ ${widths.join(', ')}` : base;
302
+ })
303
+ .join(' · ');
304
+ }
305
+ /** Canonical signature of a surface's findings: surfaces that changed in the
306
+ * same way collapse into one section + one image (the rects differ per width;
307
+ * the change itself does not). */
308
+ function signatureOf(findings) {
309
+ return JSON.stringify(findings
310
+ .map((f) => ({
311
+ p: f.path,
312
+ k: f.kind,
313
+ t: f.kind === 'dom' ? f.change : f.kind === 'state' ? f.state : (f.pseudo ?? ''),
314
+ v: f.kind === 'dom'
315
+ ? ''
316
+ : summarizeProps(f.props)
317
+ .map((c) => `${c.prop}=${c.before}>${c.after}`)
318
+ .join('|'),
319
+ }))
320
+ .sort((a, b) => `${a.p}|${a.k}|${a.t}`.localeCompare(`${b.p}|${b.k}|${b.t}`)));
321
+ }
322
+ /** A one-line heading for a change group: "1 element added", "2 elements restyled". */
323
+ function groupTitle(findings) {
324
+ const added = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'added').map((f) => f.path));
325
+ const removed = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'removed').map((f) => f.path));
326
+ const retagged = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'retagged').map((f) => f.path));
327
+ const restyled = new Set(findings.filter((f) => f.kind !== 'dom' && !added.has(f.path) && !removed.has(f.path)).map((f) => f.path));
328
+ const n = (c, w) => `${c} ${w}${c === 1 ? '' : 's'}`;
329
+ const parts = [];
330
+ if (added.size)
331
+ parts.push(`${n(added.size, 'element')} added`);
332
+ if (removed.size)
333
+ parts.push(`${n(removed.size, 'element')} removed`);
334
+ if (retagged.size)
335
+ parts.push(`${n(retagged.size, 'element')} retagged`);
336
+ if (restyled.size)
337
+ parts.push(`${n(restyled.size, 'element')} restyled`);
338
+ return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
339
+ }
340
+ // A diff state row whose value is one of these placeholders means "this state
341
+ // has no effect here" — meaningless to show, so render it as an em dash.
342
+ const STATE_PLACEHOLDER = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)']);
343
+ const cell = (v) => (STATE_PLACEHOLDER.has(v) ? '—' : `\`${v}\``);
344
+ function beforeAfterTable(rows) {
345
+ return [
346
+ '| Property | Before | After |',
347
+ '| --- | --- | --- |',
348
+ ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.before)} | ${cell(r.after)} |`),
349
+ ];
350
+ }
351
+ /** One element's heading + body lines (no leading blank, no ×N suffix). */
352
+ function renderOneElement(group) {
353
+ const label = prettyLabel(group[0].path, group[0].cls);
354
+ const dom = group.find((f) => f.kind === 'dom');
355
+ const styles = group.filter((f) => f.kind === 'style');
356
+ const states = group.filter((f) => f.kind === 'state');
357
+ if (dom?.change === 'removed')
358
+ return { head: `**Removed** \`${label}\``, body: [] };
359
+ const added = dom?.change === 'added';
360
+ const head = added
361
+ ? `**Added** \`${label}\``
362
+ : dom?.change === 'retagged'
363
+ ? `**Retagged** \`${label}\` ${dom.detail ?? ''}`
364
+ : `**\`${label}\`**`;
365
+ const body = [];
366
+ for (const s of styles) {
367
+ const rows = summarizeProps(s.props);
368
+ if (rows.length)
369
+ body.push('', s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:', '', ...beforeAfterTable(rows));
370
+ }
371
+ if (states.length) {
372
+ const rows = [];
373
+ for (const st of states)
374
+ for (const c of summarizeProps(st.props))
375
+ rows.push(added
376
+ ? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
377
+ : `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
378
+ if (rows.length)
379
+ body.push('', added ? 'Interactive states:' : 'Interactive-state changes:', '', added ? '| State | Property | Value |' : '| State | Property | Before → After |', '| --- | --- | --- |', ...rows);
380
+ }
381
+ // Existing element with nothing left to show (all derived) → skip; an
382
+ // added/removed/retagged element always renders its heading.
383
+ if (!dom && !body.length)
384
+ return null;
385
+ return { head, body };
386
+ }
387
+ /**
388
+ * Render each changed element ONCE — its base / pseudo / state findings grouped
389
+ * under a single heading — then collapse identical siblings (same label, same
390
+ * change at the same level) into one block with a `×N` count. A newly-added
391
+ * element shows only the values it takes (a brand-new element has no meaningful
392
+ * "before"); an existing element shows before → after.
393
+ */
394
+ function renderElements(findings, maxElements = 40) {
395
+ const byPath = new Map();
279
396
  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)
397
+ const arr = byPath.get(f.path) ?? [];
398
+ arr.push(f);
399
+ byPath.set(f.path, arr);
400
+ }
401
+ const blocks = [];
402
+ const bySig = new Map();
403
+ for (const group of byPath.values()) {
404
+ const el = renderOneElement(group);
405
+ if (!el)
285
406
  continue;
286
- const key = `${label}|${note}|${headOnly}|${JSON.stringify(rows)}`;
287
- const g = byKey.get(key);
288
- if (g)
289
- g.count++;
407
+ const sig = `${el.head}\n${el.body.join('\n')}`;
408
+ const seen = bySig.get(sig);
409
+ if (seen)
410
+ seen.count++;
290
411
  else {
291
- const ng = { label, note: headOnly || note, rows, count: 1 };
292
- byKey.set(key, ng);
293
- groups.push(ng);
412
+ const block = { ...el, count: 1 };
413
+ bySig.set(sig, block);
414
+ blocks.push(block);
294
415
  }
295
416
  }
296
417
  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._`);
418
+ for (let i = 0; i < blocks.length; i++) {
419
+ if (i >= maxElements) {
420
+ out.push('', `_…and ${blocks.length - i} more element(s) see report.json._`);
301
421
  break;
302
422
  }
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;
423
+ const b = blocks[i];
424
+ out.push('', b.count > 1 ? `${b.head} ×${b.count}` : b.head, ...b.body);
312
425
  }
313
426
  return out;
314
427
  }
@@ -355,11 +468,31 @@ export function generateStyleMapReport(opts) {
355
468
  findings: sd.missing || includeNoise ? sd.findings : sd.findings.filter(hasRealChange).map(stripDerived),
356
469
  }))
357
470
  .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.
471
+ // Group surfaces that changed in the SAME way (the rects differ per width; the
472
+ // change itself does not) so an identical change shows once, not once per
473
+ // surface — with one representative image (the widest surface in the group).
474
+ const missing = prepared.filter((p) => p.sd.missing);
475
+ const bySig = new Map();
476
+ for (const p of prepared) {
477
+ if (p.sd.missing)
478
+ continue;
479
+ const sig = signatureOf(p.findings);
480
+ const existing = bySig.get(sig);
481
+ if (existing) {
482
+ existing.surfaces.push(p.sd.surface);
483
+ if (surfaceWidth(p.sd.surface) > surfaceWidth(existing.rep.sd.surface))
484
+ existing.rep = p;
485
+ }
486
+ else {
487
+ bySig.set(sig, { surfaces: [p.sd.surface], rep: p, findings: p.findings });
488
+ }
489
+ }
490
+ const changeGroups = [...bySig.values()];
491
+ // Counts reflect the GROUPED view: each distinct change counts once, not once
492
+ // per surface it appears on (after shorthand/dedupe collapsing).
360
493
  const shown = { dom: 0, style: 0, state: 0 };
361
- for (const { findings } of prepared)
362
- for (const f of findings) {
494
+ for (const cg of changeGroups)
495
+ for (const f of cg.findings) {
363
496
  if (f.kind === 'dom')
364
497
  shown.dom++;
365
498
  else if (f.kind === 'style')
@@ -367,26 +500,26 @@ export function generateStyleMapReport(opts) {
367
500
  else
368
501
  shown.state += summarizeProps(f.props).length;
369
502
  }
503
+ const surfaceCount = changeGroups.reduce((acc, g) => acc + g.surfaces.length, 0) + missing.length;
370
504
  const md = [];
371
505
  const json = [];
372
506
  const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
373
- md.push('## 🗺️ StyleProof report');
374
- md.push('');
375
- if (prepared.length === 0) {
507
+ md.push('## 🗺️ StyleProof report', '');
508
+ if (changeGroups.length === 0 && missing.length === 0) {
376
509
  md.push('✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
377
510
  }
378
511
  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).`);
512
+ md.push(`**${shown.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** ` +
513
+ `across ${changeGroups.length} distinct change(s) in ${surfaceCount} surface(s).`);
380
514
  }
381
515
  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
- }
516
+ for (const cg of changeGroups) {
517
+ const { sd, findings: surfaceFindings } = cg.rep;
389
518
  totalFindings += surfaceFindings.length;
519
+ md.push('', `### ${groupTitle(surfaceFindings)}`);
520
+ md.push('', cg.surfaces.length > 1
521
+ ? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceList(cg.surfaces)}_`
522
+ : `_${formatSurfaceList(cg.surfaces)}_`);
390
523
  const mapA = loadStyleMap(findCapture(beforeDir, sd.surface));
391
524
  const mapB = loadStyleMap(findCapture(afterDir, sd.surface));
392
525
  const pngA = readPng(path.join(beforeDir, `${sd.surface}.png`));
@@ -402,12 +535,14 @@ export function generateStyleMapReport(opts) {
402
535
  })),
403
536
  ];
404
537
  }
405
- const surfaceJson = { surface: sd.surface, regions: [] };
538
+ const surfaceJson = {
539
+ surfaces: cg.surfaces,
540
+ representative: sd.surface,
541
+ regions: [],
542
+ };
406
543
  let n = 0;
407
544
  for (const g of groups) {
408
545
  n++;
409
- const findings = surfaceFindings.filter((f) => g.paths.some((p) => f.path === p || f.path.startsWith(p + ' > ')));
410
- const lines = renderFindings(findings);
411
546
  const region = visible(g.after) ? g.after : g.before;
412
547
  let images = {};
413
548
  if (region && pngA && pngB) {
@@ -425,7 +560,7 @@ export function generateStyleMapReport(opts) {
425
560
  writePng(path.join(outDir, `${stem}-composite.png`), composite);
426
561
  images = { before: `${stem}-before.png`, after: `${stem}-after.png`, composite: `${stem}-composite.png` };
427
562
  // One side-by-side image per change: clean inline render, single upload.
428
- md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', '<sub>◀ before · after ▶</sub>');
563
+ md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<sub>◀ before · after ▶ — ${sd.surface}</sub>`);
429
564
  }
430
565
  else if (!region) {
431
566
  md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
@@ -433,17 +568,17 @@ export function generateStyleMapReport(opts) {
433
568
  else {
434
569
  md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
435
570
  }
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
- });
571
+ surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
444
572
  }
573
+ // The findings, grouped per element, rendered once for the whole group.
574
+ md.push(...renderElements(surfaceFindings));
575
+ surfaceJson.findings = surfaceFindings;
445
576
  json.push(surfaceJson);
446
577
  }
578
+ for (const p of missing) {
579
+ md.push('', `### \`${p.sd.surface}\``, '', `⚠️ captured only in the **${p.sd.missing === 'before' ? 'after' : 'before'}** set — re-run both captures.`);
580
+ json.push({ surface: p.sd.surface, missing: p.sd.missing });
581
+ }
447
582
  if (surfaces.length) {
448
583
  md.push('', '---', '_To accept these changes, regenerate the committed baseline from this build and commit it with your diff._');
449
584
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
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
5
  "keywords": [
6
6
  "playwright",