styleproof 1.3.0 → 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,49 @@ 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
+
43
+ ## [1.3.1]
44
+
45
+ ### Changed
46
+
47
+ - The before/after composite no longer draws coloured accent strips (grey for
48
+ before, blue for after) above each crop. They were a font-free before/after
49
+ cue, but a strip that differs between the two sides reads as a _second_ change
50
+ in the pair. Now the only thing that differs across the composite is the actual
51
+ change; before/after stays conveyed by position (left = before) and the caption.
52
+
10
53
  ## [1.3.0]
11
54
 
12
55
  Clearer reports: one section per screenshot.
package/README.md CHANGED
@@ -16,9 +16,35 @@ 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
- A second mode certifies a refactor changed _nothing_: any diff fails the job (use it for CSS-to-Tailwind migrations, design-system swaps, build-tooling changes).
22
+ ## What a report looks like
23
+
24
+ One change — the hero CTA recoloured cyan → amber — posts as a single section: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
25
+
26
+ ![A StyleProof report: the CTA button before (cyan) and after (amber), side by side](docs/demo-composite.png)
27
+
28
+ As it renders in the PR comment (colours become live swatches; the full table sits inside the toggle):
29
+
30
+ ```text
31
+ ### `a.btn-solid` · 1 element restyled
32
+ _landing @ 1280_
33
+
34
+ `background-color` `rgb(95, 202, 219)` → `rgb(245, 158, 11)`
35
+
36
+ ▾ Show the property change
37
+ | Property | Before | After |
38
+ | background-color | rgb(95, 202, 219) | rgb(245, 158, 11) |
39
+ ```
40
+
41
+ ## Works with any styling system
42
+
43
+ StyleProof reads the browser's **computed styles** — the values it actually resolves — never your source CSS. Tailwind, CSS Modules, styled-components, Sass, vanilla CSS, inline styles: all produce the same computed output, and that's what it diffs. Elements are keyed by **DOM structure, not class name**, so a refactor that rewrites every `class` still lines up element-for-element.
44
+
45
+ ## Certify a refactor
46
+
47
+ The same engine has a second mode that proves a change touched _nothing_ visual: with `fail-on-diff: true`, any difference at all fails the job. It's the job StyleProof was born for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical. Reach for it on any change whose whole promise is "the output is unchanged": a utility-class migration, a design-system swap, a dependency or build-tooling bump. Zero diff is the contract; one drifting longhand is a regression to investigate, not a change to approve.
22
48
 
23
49
  ## Install
24
50
 
@@ -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) => {
@@ -80,28 +84,26 @@ function fillRect(png, x, y, w, h, [r, g, b]) {
80
84
  }
81
85
  }
82
86
  /**
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.
87
+ * One before|after image: the two equal-size crops on a dark canvas with a
88
+ * neutral divider between them. Left is always before; before/after is labelled
89
+ * by the caption under the image. The divider is identical on both sides, so the
90
+ * ONLY thing that differs across the pair is the actual change — no extra chrome
91
+ * (e.g. a coloured accent strip) that reads as a second diff.
86
92
  */
87
93
  function compositePair(before, after) {
88
94
  const PAD = 20;
89
95
  const GAP = 28;
90
- const BAR = 6; // accent strip height
91
96
  const w = Math.max(before.width, after.width);
92
97
  const h = Math.max(before.height, after.height);
93
98
  const width = PAD + w + GAP + w + PAD;
94
- const height = PAD + BAR + h + PAD;
99
+ const height = PAD + h + PAD;
95
100
  const canvas = new PNG({ width, height });
96
101
  fillRect(canvas, 0, 0, width, height, [13, 17, 23]); // GitHub dark
97
102
  const leftX = PAD;
98
103
  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
104
+ PNG.bitblt(before, canvas, 0, 0, before.width, before.height, leftX, PAD);
105
+ PNG.bitblt(after, canvas, 0, 0, after.width, after.height, rightX, PAD);
106
+ fillRect(canvas, PAD + w + GAP / 2 - 1, PAD, 2, h, [48, 54, 61]); // divider
105
107
  return canvas;
106
108
  }
107
109
  function readPng(file) {
@@ -565,7 +567,10 @@ export function generateStyleMapReport(opts) {
565
567
  else
566
568
  shown.state += summarizeProps(f.props).length;
567
569
  }
568
- 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);
569
574
  const md = [];
570
575
  const json = [];
571
576
  const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
@@ -574,8 +579,16 @@ export function generateStyleMapReport(opts) {
574
579
  md.push('✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
575
580
  }
576
581
  else {
577
- md.push(`**${shown.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** ` +
578
- `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
+ }
579
592
  }
580
593
  let totalFindings = 0;
581
594
  let cropSeq = 0;
@@ -648,15 +661,41 @@ export function generateStyleMapReport(opts) {
648
661
  surfaceJson.findings = surfaceFindings;
649
662
  json.push(surfaceJson);
650
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.
651
667
  for (const p of missing) {
652
- md.push('', `### \`${p.sd.surface}\``, '', `⚠️ captured only in the **${p.sd.missing === 'before' ? 'after' : 'before'}** set — re-run both captures.`);
653
- 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);
654
687
  }
655
688
  const reportMdPath = path.join(outDir, 'report.md');
656
689
  const reportJsonPath = path.join(outDir, 'report.json');
657
690
  fs.writeFileSync(reportMdPath, md.join('\n') + '\n');
658
691
  fs.writeFileSync(reportJsonPath, JSON.stringify({ counts: shown, surfaces: json }, null, 2));
659
- 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
+ };
660
699
  }
661
700
  function findCapture(dir, surface) {
662
701
  for (const ext of ['.json.gz', '.json']) {
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.3.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.",
3
+ "version": "1.4.0",
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",
7
7
  "css",
@@ -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
  }