styleproof 1.3.1 → 1.4.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 +33 -0
- package/README.md +1 -0
- package/bin/styleproof-diff.mjs +15 -6
- package/bin/styleproof-report.mjs +6 -3
- package/dist/capture.js +2 -2
- package/dist/diff.js +4 -1
- package/dist/report.d.ts +3 -0
- package/dist/report.js +47 -6
- package/package.json +9 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.0]
|
|
11
|
+
|
|
12
|
+
New surfaces (present on only one side, no baseline to diff) are shown for
|
|
13
|
+
reference and never block the review gate.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A surface captured on only one side is now treated as a _new surface_, not a
|
|
18
|
+
change.** Previously every one-sided surface counted as a DOM change, so a
|
|
19
|
+
bootstrap PR (base branch has no capture spec yet → empty baseline) rendered a
|
|
20
|
+
self-contradicting report — a `0 DOM change(s) · 0 … · 0 … across 0 distinct
|
|
21
|
+
change(s) in N surface(s)` headline sitting above N "re-run both captures"
|
|
22
|
+
warnings, each with an approval checkbox that could turn the gate green on a
|
|
23
|
+
capture set that has no baseline at all.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **New surfaces are shown, not blocked.** A surface present on only one side is
|
|
28
|
+
rendered with its captured-side screenshot under a `🆕 new surface` heading,
|
|
29
|
+
summarised on its own headline line (`🆕 N new surface(s) … don't block the
|
|
30
|
+
check`), and excluded from the change tallies. In review-gate mode it gets an
|
|
31
|
+
**optional** `Approve this new surface` checkbox that the approve workflow
|
|
32
|
+
deliberately ignores — so a new surface never holds the `StyleProof` status red.
|
|
33
|
+
Real before↔after changes still gate exactly as before.
|
|
34
|
+
- **`styleproof-diff` exit codes:** `0` identical, `1` reviewable differences,
|
|
35
|
+
`2` usage/capture error (unchanged), and new **`3`** = only new surfaces (no
|
|
36
|
+
baseline to diff). The Action reports on `1` or `3` but only gates on `1`;
|
|
37
|
+
certify mode (`fail-on-diff`) still fails on either, since "nothing changed"
|
|
38
|
+
means the capture set didn't grow either.
|
|
39
|
+
- The Action `changed` output is now `"false"` for a brand-new surface with no
|
|
40
|
+
baseline (it was a change before). `generateStyleMapReport` returns a new
|
|
41
|
+
`newSurfaces` count alongside `changedSurfaces`.
|
|
42
|
+
|
|
10
43
|
## [1.3.1]
|
|
11
44
|
|
|
12
45
|
### Changed
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base br
|
|
|
16
16
|
|
|
17
17
|
- A summary line, then **one section per distinct change**, with a side-by-side before/after cropped screenshot and the property changes folded under a toggle.
|
|
18
18
|
- An **approval checkbox per change**, driving a `StyleProof` commit status: red until every change is signed off, green when there are none.
|
|
19
|
+
- **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown with its screenshot under a `🆕 new surface` heading and an _optional_ approval box, but it never holds the status red. It becomes part of the baseline once merged.
|
|
19
20
|
- No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
|
|
20
21
|
|
|
21
22
|
## What a report looks like
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* `hover:` variant a screenshot can never catch.
|
|
15
15
|
*
|
|
16
16
|
* Custom properties (--*) are ignored: they are inputs, not outcomes (see
|
|
17
|
-
* README). Exit code 0 = identical, 1 = differences, 2 = usage/capture
|
|
17
|
+
* README). Exit code 0 = identical, 1 = reviewable differences, 2 = usage/capture
|
|
18
|
+
* error, 3 = only new surfaces (present on one side, no baseline to diff against).
|
|
18
19
|
*/
|
|
19
20
|
import fs from 'node:fs';
|
|
20
21
|
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
@@ -28,7 +29,8 @@ options:
|
|
|
28
29
|
--json <file> also write the full structured diff to <file>
|
|
29
30
|
-h, --help show this help
|
|
30
31
|
|
|
31
|
-
exit: 0 identical (certified), 1 differences found, 2 usage/capture error
|
|
32
|
+
exit: 0 identical (certified), 1 differences found, 2 usage/capture error,
|
|
33
|
+
3 only new surfaces (present on one side, no baseline to diff against).
|
|
32
34
|
`;
|
|
33
35
|
|
|
34
36
|
const argv = process.argv.slice(2);
|
|
@@ -71,7 +73,8 @@ const { surfaces, counts } = result;
|
|
|
71
73
|
|
|
72
74
|
for (const sd of surfaces) {
|
|
73
75
|
if (sd.missing) {
|
|
74
|
-
|
|
76
|
+
const side = sd.missing === 'before' ? 'after' : 'before';
|
|
77
|
+
console.log(`\n${sd.surface}: new surface — captured only in the ${side} set, no baseline to compare`);
|
|
75
78
|
continue;
|
|
76
79
|
}
|
|
77
80
|
const lines = [];
|
|
@@ -98,13 +101,19 @@ for (const sd of surfaces) {
|
|
|
98
101
|
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null, 2));
|
|
99
102
|
|
|
100
103
|
const total = counts.dom + counts.style + counts.state;
|
|
104
|
+
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
101
105
|
const surfaceCount = new Set([
|
|
102
106
|
...fs.readdirSync(dirA).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
103
107
|
...fs.readdirSync(dirB).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
104
108
|
]).size;
|
|
109
|
+
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
105
110
|
console.log(
|
|
106
111
|
total === 0
|
|
107
|
-
?
|
|
108
|
-
|
|
112
|
+
? newSurfaces === 0
|
|
113
|
+
? `\n✓ ${surfaceCount} surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches`
|
|
114
|
+
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — shown for reference, no reviewable change`
|
|
115
|
+
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
|
|
109
116
|
);
|
|
110
|
-
|
|
117
|
+
// 0 = identical, 1 = reviewable differences, 3 = only new surfaces (no baseline,
|
|
118
|
+
// nothing to review). 2 stays reserved for usage/capture errors.
|
|
119
|
+
process.exit(total > 0 ? 1 : newSurfaces > 0 ? 3 : 0);
|
|
@@ -108,10 +108,13 @@ try {
|
|
|
108
108
|
process.exit(2);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const newNote = result.newSurfaces ? ` (+${result.newSurfaces} new surface(s) with no baseline)` : '';
|
|
111
112
|
console.log(
|
|
112
113
|
result.changedSurfaces === 0
|
|
113
|
-
?
|
|
114
|
-
|
|
114
|
+
? result.newSurfaces === 0
|
|
115
|
+
? '✓ no changes — empty report written'
|
|
116
|
+
: `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for reference`
|
|
117
|
+
: `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
|
|
115
118
|
);
|
|
116
119
|
console.log(`report: ${result.reportMdPath}`);
|
|
117
|
-
process.exit(result.changedSurfaces === 0 ? 0 : 1);
|
|
120
|
+
process.exit(result.changedSurfaces === 0 && result.newSurfaces === 0 ? 0 : 1);
|
package/dist/capture.js
CHANGED
|
@@ -310,7 +310,7 @@ export function loadStyleMap(filePath) {
|
|
|
310
310
|
raw = fs.readFileSync(filePath);
|
|
311
311
|
}
|
|
312
312
|
catch (e) {
|
|
313
|
-
throw new Error(`styleproof: cannot read capture ${filePath}: ${e.message}
|
|
313
|
+
throw new Error(`styleproof: cannot read capture ${filePath}: ${e.message}`, { cause: e });
|
|
314
314
|
}
|
|
315
315
|
try {
|
|
316
316
|
const text = filePath.endsWith('.gz') ? gunzipSync(raw).toString('utf8') : raw.toString('utf8');
|
|
@@ -318,6 +318,6 @@ export function loadStyleMap(filePath) {
|
|
|
318
318
|
}
|
|
319
319
|
catch (e) {
|
|
320
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.');
|
|
321
|
+
'Re-capture it — a partial write or interrupted upload produces an unreadable .gz.', { cause: e });
|
|
322
322
|
}
|
|
323
323
|
}
|
package/dist/diff.js
CHANGED
|
@@ -94,8 +94,11 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
94
94
|
const counts = { dom: 0, style: 0, state: 0 };
|
|
95
95
|
for (const surface of names) {
|
|
96
96
|
if (!indexA[surface] || !indexB[surface]) {
|
|
97
|
+
// A surface present on only one side has no baseline to diff against — it's
|
|
98
|
+
// a NEW surface, not a style change. It does NOT count toward the change
|
|
99
|
+
// tallies (those drive the review gate); the consumer flags it separately
|
|
100
|
+
// off the `missing` marker and shows it for reference without blocking.
|
|
97
101
|
surfaces.push({ surface, missing: indexA[surface] ? 'after' : 'before', findings: [] });
|
|
98
|
-
counts.dom++;
|
|
99
102
|
continue;
|
|
100
103
|
}
|
|
101
104
|
const findings = diffStyleMaps(loadStyleMap(indexA[surface]), loadStyleMap(indexB[surface]));
|
package/dist/report.d.ts
CHANGED
|
@@ -41,7 +41,10 @@ export type ReportOptions = {
|
|
|
41
41
|
includeLayoutNoise?: boolean;
|
|
42
42
|
};
|
|
43
43
|
export type ReportResult = {
|
|
44
|
+
/** Surfaces carrying a reviewable change (excludes new, one-sided surfaces). */
|
|
44
45
|
changedSurfaces: number;
|
|
46
|
+
/** New surfaces present on only one side, with no baseline to compare. */
|
|
47
|
+
newSurfaces: number;
|
|
45
48
|
totalFindings: number;
|
|
46
49
|
reportMdPath: string;
|
|
47
50
|
reportJsonPath: string;
|
package/dist/report.js
CHANGED
|
@@ -3,6 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
4
|
import { loadStyleMap } from './capture.js';
|
|
5
5
|
import { diffStyleMapDirs } from './diff.js';
|
|
6
|
+
// Hidden marker appended to a new-surface heading. Invisible in rendered
|
|
7
|
+
// markdown; lets the PR-comment layer attach an OPTIONAL "approve" box to a new
|
|
8
|
+
// surface (vs the required box on a real change), so new surfaces never gate.
|
|
9
|
+
const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
|
|
6
10
|
const rectToBox = (r) => ({ x: r[0], y: r[1], w: r[2], h: r[3] });
|
|
7
11
|
const pad = (b, by) => ({ x: b.x - by, y: b.y - by, w: b.w + 2 * by, h: b.h + 2 * by });
|
|
8
12
|
const union = (a, b) => {
|
|
@@ -563,7 +567,10 @@ export function generateStyleMapReport(opts) {
|
|
|
563
567
|
else
|
|
564
568
|
shown.state += summarizeProps(f.props).length;
|
|
565
569
|
}
|
|
566
|
-
|
|
570
|
+
// Surfaces carrying a reviewable change — NOT the new (one-sided) ones, which
|
|
571
|
+
// have no baseline to compare and are summarised on their own line below so the
|
|
572
|
+
// headline never reads "0 changes" while warnings sit beneath it.
|
|
573
|
+
const changedSurfaceCount = changeGroups.reduce((acc, g) => acc + g.surfaces.length, 0);
|
|
567
574
|
const md = [];
|
|
568
575
|
const json = [];
|
|
569
576
|
const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
|
|
@@ -572,8 +579,16 @@ export function generateStyleMapReport(opts) {
|
|
|
572
579
|
md.push('✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
|
|
573
580
|
}
|
|
574
581
|
else {
|
|
575
|
-
|
|
576
|
-
|
|
582
|
+
if (changeGroups.length > 0) {
|
|
583
|
+
md.push(`**${shown.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** ` +
|
|
584
|
+
`across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
|
|
585
|
+
}
|
|
586
|
+
if (missing.length > 0) {
|
|
587
|
+
if (changeGroups.length > 0)
|
|
588
|
+
md.push('');
|
|
589
|
+
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for reference. ` +
|
|
590
|
+
`New surfaces don't block the check.`);
|
|
591
|
+
}
|
|
577
592
|
}
|
|
578
593
|
let totalFindings = 0;
|
|
579
594
|
let cropSeq = 0;
|
|
@@ -646,15 +661,41 @@ export function generateStyleMapReport(opts) {
|
|
|
646
661
|
surfaceJson.findings = surfaceFindings;
|
|
647
662
|
json.push(surfaceJson);
|
|
648
663
|
}
|
|
664
|
+
// New surfaces: present on only one side, so there's nothing to diff. Show the
|
|
665
|
+
// captured side as a single screenshot for reference and mark the heading so the
|
|
666
|
+
// PR comment can attach an OPTIONAL approval box — these never gate the check.
|
|
649
667
|
for (const p of missing) {
|
|
650
|
-
|
|
651
|
-
|
|
668
|
+
const side = p.sd.missing === 'before' ? 'after' : 'before';
|
|
669
|
+
const srcDir = side === 'after' ? afterDir : beforeDir;
|
|
670
|
+
const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
|
|
671
|
+
md.push('', `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`, '', `_${formatSurfaceList([p.sd.surface])}_`);
|
|
672
|
+
const surfaceJson = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
|
|
673
|
+
if (png) {
|
|
674
|
+
cropSeq++;
|
|
675
|
+
const h = Math.min(maxHeight, png.height);
|
|
676
|
+
const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h);
|
|
677
|
+
const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
|
|
678
|
+
writePng(path.join(outDir, `${stem}.png`), crop);
|
|
679
|
+
md.push('', `})`, '', `<sub>${side} · ${p.sd.surface}${png.height > h ? ' (top of page)' : ''}</sub>`);
|
|
680
|
+
surfaceJson.image = `${stem}.png`;
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
|
|
684
|
+
}
|
|
685
|
+
md.push('', `_No baseline to compare against — this surface is new, so it doesn't block the check. It becomes part of the baseline once this merges._`);
|
|
686
|
+
json.push(surfaceJson);
|
|
652
687
|
}
|
|
653
688
|
const reportMdPath = path.join(outDir, 'report.md');
|
|
654
689
|
const reportJsonPath = path.join(outDir, 'report.json');
|
|
655
690
|
fs.writeFileSync(reportMdPath, md.join('\n') + '\n');
|
|
656
691
|
fs.writeFileSync(reportJsonPath, JSON.stringify({ counts: shown, surfaces: json }, null, 2));
|
|
657
|
-
return {
|
|
692
|
+
return {
|
|
693
|
+
changedSurfaces: prepared.length - missing.length,
|
|
694
|
+
newSurfaces: missing.length,
|
|
695
|
+
totalFindings,
|
|
696
|
+
reportMdPath,
|
|
697
|
+
reportJsonPath,
|
|
698
|
+
};
|
|
658
699
|
}
|
|
659
700
|
function findCapture(dir, surface) {
|
|
660
701
|
for (const ext of ['.json.gz', '.json']) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"test:watch": "node --test --watch test/*.test.mjs",
|
|
64
64
|
"test:e2e": "npm run build && playwright test test/smoke.e2e.spec.ts",
|
|
65
65
|
"init": "node ./bin/styleproof-init.mjs",
|
|
66
|
-
"prepare": "npm run build",
|
|
66
|
+
"prepare": "npm run build && husky",
|
|
67
67
|
"prepublishOnly": "npm run clean && npm run build && npm run typecheck"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
@@ -73,14 +73,16 @@
|
|
|
73
73
|
"pngjs": "^7.0.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@eslint/js": "^
|
|
76
|
+
"@eslint/js": "^10.0.1",
|
|
77
77
|
"@playwright/test": "^1.60.0",
|
|
78
78
|
"@types/node": "^25.9.3",
|
|
79
79
|
"@types/pngjs": "^6.0.5",
|
|
80
|
-
"eslint": "^
|
|
81
|
-
"globals": "^
|
|
80
|
+
"eslint": "^10.5.0",
|
|
81
|
+
"globals": "^17.6.0",
|
|
82
82
|
"prettier": "^3.4.2",
|
|
83
|
-
"typescript": "^
|
|
84
|
-
"typescript-eslint": "^8.18.0"
|
|
83
|
+
"typescript": "^6.0.3",
|
|
84
|
+
"typescript-eslint": "^8.18.0",
|
|
85
|
+
"husky": "^9.1.7",
|
|
86
|
+
"fallow": "^2.96.0"
|
|
85
87
|
}
|
|
86
88
|
}
|