styleproof 1.0.0 → 1.2.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 +61 -1
- package/README.md +55 -13
- package/dist/report.js +191 -59
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,64 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.0]
|
|
11
|
+
|
|
12
|
+
Visual-review approval gate. StyleProof can now act as a per-PR review gate
|
|
13
|
+
("here is what changed visually — sign off if it's intentional") rather than only
|
|
14
|
+
a zero-diff refactor certifier.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`require-approval` Action input.** Instead of failing the job on any diff, the
|
|
19
|
+
Action sets a `StyleProof` commit status: green when there are no visual changes,
|
|
20
|
+
red ("needs sign-off") until the changes are approved. The report comment gains
|
|
21
|
+
**one approval checkbox per change** — each distinct visual change is signed off
|
|
22
|
+
on its own, and the gate goes green only when **every** box is ticked.
|
|
23
|
+
- **`example/styleproof-approve.yml`** — a template `issue_comment` workflow (copy
|
|
24
|
+
to your default branch). As a write-access reviewer ticks the per-change boxes,
|
|
25
|
+
it updates the `StyleProof` status ("2 of 3 approved") and flips it green only at
|
|
26
|
+
full approval. Its trust model:
|
|
27
|
+
- acts only on a **human edit** of the **bot's own** report comment
|
|
28
|
+
(`comment.user.type == 'Bot'` and `sender.type == 'User'`), which excludes both
|
|
29
|
+
the Action's own comment upserts and any attacker-authored comment;
|
|
30
|
+
- **binds approval to the exact commit** the report was generated for (a
|
|
31
|
+
`<!-- styleproof-sha -->` marker), so a push after the report can never inherit
|
|
32
|
+
a green status — a new render re-opens the gate;
|
|
33
|
+
- **verifies write access** (`getCollaboratorPermissionLevel`, fails closed)
|
|
34
|
+
before moving the status. The marker is not the trust boundary — write access is.
|
|
35
|
+
- **`status-context` input** to rename the commit status (must match the approve
|
|
36
|
+
workflow + branch protection).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The report no longer appends its own "regenerate the baseline" footer — the
|
|
41
|
+
consumer (the Action, or your own wrapper) owns the call to action. This also
|
|
42
|
+
removes the duplicate footer the Action used to add. `fail-on-diff` (legacy
|
|
43
|
+
refactor mode) is unchanged and still the default.
|
|
44
|
+
|
|
45
|
+
## [1.1.0]
|
|
46
|
+
|
|
47
|
+
Report readability overhaul. No change to the capture format, the diff, or the
|
|
48
|
+
public API — `styleproof-diff` certification is byte-for-byte identical; only the
|
|
49
|
+
human-facing `styleproof-report` / `generateStyleMapReport` output changed.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **Group an identical change across surfaces into one section + one image.** A
|
|
54
|
+
change that appears on every breakpoint of a responsive page (e.g. a new footer
|
|
55
|
+
link across 7 landing widths) was repeated once per surface with a near-duplicate
|
|
56
|
+
crop each time. Surfaces whose findings are identical now collapse into a single
|
|
57
|
+
section that lists the affected surfaces (`landing @ 1280, 1080, 390 …`) and shows
|
|
58
|
+
one representative crop (the widest). The summary counts distinct changes, not
|
|
59
|
+
per-surface repetitions.
|
|
60
|
+
- **One heading per element.** An element's base, pseudo, and forced-state findings
|
|
61
|
+
render under a single heading instead of a separate `DOM added` / `:hover` /
|
|
62
|
+
`:focus` block each.
|
|
63
|
+
- **Newly-added elements read naturally.** A brand-new element has no meaningful
|
|
64
|
+
"before", so its forced states render as a `State · Property · Value` table of the
|
|
65
|
+
values it takes, instead of a `Before` column full of `(state does not change it)`.
|
|
66
|
+
- `outline-width`/`-style`/`-color` collapse into one `outline` row.
|
|
67
|
+
|
|
10
68
|
## [1.0.0]
|
|
11
69
|
|
|
12
70
|
First stable release and first publish to npm. The capture format, the public API
|
|
@@ -192,7 +250,9 @@ number)`), so each viewport band can capture at its own height. Default remains
|
|
|
192
250
|
- `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
|
|
193
251
|
property, and state that drifted (exit 1).
|
|
194
252
|
|
|
195
|
-
[Unreleased]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.
|
|
253
|
+
[Unreleased]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.2.0...HEAD
|
|
254
|
+
[1.2.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.1.0...v1.2.0
|
|
255
|
+
[1.1.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v1.0.0...v1.1.0
|
|
196
256
|
[1.0.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.7.0...v1.0.0
|
|
197
257
|
[0.7.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.6.0...v0.7.0
|
|
198
258
|
[0.6.0]: https://github.com/BenSheridanEdwards/styleproof/compare/v0.5.0...v0.6.0
|
package/README.md
CHANGED
|
@@ -394,11 +394,11 @@ Numeric flags also accept the `--flag=value` form. The remaining `maxHeight` kno
|
|
|
394
394
|
|
|
395
395
|
## GitHub Action
|
|
396
396
|
|
|
397
|
-
The repo ships a composite action that diffs
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
397
|
+
The repo ships a composite action that diffs two capture dirs and, on changes, commits a
|
|
398
|
+
compact report to an orphan branch and posts it to the PR. The comment updates in place on
|
|
399
|
+
every push and flips to ✓ when clean. It runs in one of two modes — **certify a refactor**
|
|
400
|
+
(default) or **review visual changes** with per-change sign-off (`require-approval`); see
|
|
401
|
+
[Two modes](#two-modes-certify-a-refactor-or-review-visual-changes) below.
|
|
402
402
|
|
|
403
403
|
```yaml
|
|
404
404
|
- name: Capture style maps
|
|
@@ -416,14 +416,16 @@ flips to ✓ when clean.
|
|
|
416
416
|
|
|
417
417
|
### Inputs
|
|
418
418
|
|
|
419
|
-
| Input
|
|
420
|
-
|
|
|
421
|
-
| `baseline-dir`
|
|
422
|
-
| `fresh-dir`
|
|
423
|
-
| `report-branch`
|
|
424
|
-
| `inline-images`
|
|
425
|
-
| `github-token`
|
|
426
|
-
| `fail-on-diff`
|
|
419
|
+
| Input | Required | Default | Description |
|
|
420
|
+
| ------------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
421
|
+
| `baseline-dir` | yes | — | Directory with the committed baseline captures (`.json.gz` + `.png`). |
|
|
422
|
+
| `fresh-dir` | yes | — | Directory with the freshly captured maps to compare. |
|
|
423
|
+
| `report-branch` | no | `styleproof-reports` | Orphan branch that stores reports (created on first run). One `pr-<n>/` folder per PR; never pruned. |
|
|
424
|
+
| `inline-images` | no | `auto` | `auto` \| `always` \| `never`. `auto` embeds composites in the comment for public repos and links the report for private repos. |
|
|
425
|
+
| `github-token` | no | `${{ github.token }}` | Token used to push the report branch and post the comment. |
|
|
426
|
+
| `fail-on-diff` | no | `'true'` | Legacy refactor mode: fail the job on any diff. Ignored when `require-approval` is `'true'`. |
|
|
427
|
+
| `require-approval` | no | `'false'` | Review-gate mode: set a `StyleProof` commit status (red until each change is approved) instead of failing the job. See below. |
|
|
428
|
+
| `status-context` | no | `StyleProof` | Name of the commit status set in review-gate mode (must match the approve workflow + branch protection). |
|
|
427
429
|
|
|
428
430
|
### Outputs
|
|
429
431
|
|
|
@@ -432,6 +434,46 @@ flips to ✓ when clean.
|
|
|
432
434
|
| `changed` | `"true"` when any computed style, pseudo-element, or state changed. |
|
|
433
435
|
| `report-url` | Blob URL of the committed report (when changed). |
|
|
434
436
|
|
|
437
|
+
### Two modes: certify a refactor, or review visual changes
|
|
438
|
+
|
|
439
|
+
- **Certify (default, `fail-on-diff: true`).** Any computed-style change fails the job.
|
|
440
|
+
Right for a refactor you expect to be a no-op (a CSS-to-Tailwind migration); "approving"
|
|
441
|
+
a change means regenerating the committed baseline and pushing it.
|
|
442
|
+
- **Review gate (`require-approval: true`).** The report is the product: every PR shows
|
|
443
|
+
_what changed visually_, and a reviewer signs each change off against the PR's stated
|
|
444
|
+
intent. Instead of failing the job, the Action sets a **`StyleProof` commit status** —
|
|
445
|
+
green when nothing changed, red until approved — and posts **one approval checkbox per
|
|
446
|
+
change**. The gate goes green only when **every** box is ticked.
|
|
447
|
+
|
|
448
|
+
To enable the review gate:
|
|
449
|
+
|
|
450
|
+
1. Set `require-approval: 'true'` on the Action, and grant the job `statuses: write`
|
|
451
|
+
(plus the existing `contents: write` / `pull-requests: write`).
|
|
452
|
+
2. Copy [`example/styleproof-approve.yml`](example/styleproof-approve.yml) to
|
|
453
|
+
`.github/workflows/` **on your default branch** (`issue_comment` workflows only run
|
|
454
|
+
from the default branch).
|
|
455
|
+
3. Add a branch-protection rule that **requires the `StyleProof` status** to pass.
|
|
456
|
+
|
|
457
|
+
A reviewer with write access walks the report change-by-change and ticks each box; the
|
|
458
|
+
status updates ("2 of 3 approved") and flips green at full approval. A new push that
|
|
459
|
+
changes styles re-opens the gate. The approval is bound to the exact reviewed commit and
|
|
460
|
+
acts only on a human edit of the bot's own comment, so it can't be flipped green by a
|
|
461
|
+
later push, by the bot's own comment updates, or by anyone without write access. Pair it
|
|
462
|
+
naturally with a head-vs-**base-branch** diff (capture the base branch into `baseline-dir`)
|
|
463
|
+
so each PR's report is exactly _what this PR changes_.
|
|
464
|
+
|
|
465
|
+
Two things to know about the trust model:
|
|
466
|
+
|
|
467
|
+
- **Single-reviewer by design.** Any write-access user can tick the boxes, **including the
|
|
468
|
+
PR author on their own PR** — the sign-off is "I confirm this is intentional," not
|
|
469
|
+
multi-party review. If you need a different person to approve, add a branch-protection
|
|
470
|
+
rule requiring a review from someone other than the author (CODEOWNERS); this gate
|
|
471
|
+
doesn't enforce that itself.
|
|
472
|
+
- **Use the default `GITHUB_TOKEN`.** The approve workflow only acts on a comment authored
|
|
473
|
+
by a bot (so an attacker-authored comment can never trigger it). If you post the report
|
|
474
|
+
with a personal access token (a `User`), the workflow won't fire and the gate can never
|
|
475
|
+
go green. A GitHub App token works; a PAT does not.
|
|
476
|
+
|
|
435
477
|
### Why two image modes (and why it's automatic)
|
|
436
478
|
|
|
437
479
|
The two ways an image can appear on a PR have **opposite** privacy behaviour, so
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
287
|
-
const
|
|
288
|
-
if (
|
|
289
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
304
|
-
|
|
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
|
-
//
|
|
359
|
-
// not
|
|
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
|
|
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
|
-
|
|
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)**
|
|
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
|
|
383
|
-
|
|
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 = {
|
|
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('', `})`, '',
|
|
563
|
+
md.push('', `})`, '', `<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,19 +568,16 @@ 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
|
-
|
|
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
|
}
|
|
447
|
-
|
|
448
|
-
md.push('', '
|
|
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 });
|
|
449
581
|
}
|
|
450
582
|
const reportMdPath = path.join(outDir, 'report.md');
|
|
451
583
|
const reportJsonPath = path.join(outDir, 'report.json');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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",
|