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 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
@@ -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 error.
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
- console.log(`\n${sd.surface}: captured in only one set re-run both captures`);
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
- ? `\n✓ ${surfaceCount} surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches`
108
- : `\n ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces`,
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
- process.exit(total === 0 ? 0 : 1);
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
- ? '✓ no changes — empty report written'
114
- : `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)`,
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
- const surfaceCount = changeGroups.reduce((acc, g) => acc + g.surfaces.length, 0) + missing.length;
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
- md.push(`**${shown.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** ` +
576
- `across ${changeGroups.length} distinct change(s) in ${surfaceCount} surface(s).`);
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
- md.push('', `### \`${p.sd.surface}\``, '', `⚠️ captured only in the **${p.sd.missing === 'before' ? 'after' : 'before'}** set — re-run both captures.`);
651
- json.push({ surface: p.sd.surface, missing: p.sd.missing });
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('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<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 { changedSurfaces: prepared.length, totalFindings, reportMdPath, reportJsonPath };
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.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": "^9.17.0",
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": "^9.17.0",
81
- "globals": "^15.14.0",
80
+ "eslint": "^10.5.0",
81
+ "globals": "^17.6.0",
82
82
  "prettier": "^3.4.2",
83
- "typescript": "^5.6.2",
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
  }