styleproof 3.14.0 → 3.16.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,37 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.16.0] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **The scaffolded PR workflow prunes its own report branch, out of the box.**
15
+ `styleproof-init`'s `.github/workflows/styleproof.yml` now also runs on
16
+ `pull_request: closed`: when a PR closes, a `prune` job removes that PR's `pr-<n>/`
17
+ folder from the report branch (`styleproof-reports`), so the branch no longer grows
18
+ without bound as PRs come and go — no adopter has to remember to garbage-collect it.
19
+ The report job is unchanged, only guarded to skip the close event. Covered by the
20
+ `styleproof-init` workflow test.
21
+
22
+ ## [3.15.0] - 2026-07-05
23
+
24
+ ### Changed
25
+
26
+ - **Computed values are compared canonically, so a re-serialization isn't a change.** A
27
+ browser or build-tool version can serialize an _identical_ value differently — a Chromium
28
+ bump rewrites `rgba(8, 18, 32, 0.62)` as `#0812209e`, a Tailwind migration reformats a
29
+ font list's comma spacing — and StyleProof reported every one of those as a difference,
30
+ drowning a re-baseline in changes that aren't changes (and blowing up the report). The
31
+ diff now compares by a canonical form: colours are parsed to one `rgba()` (across
32
+ hex / `rgb()` / `rgba()` / `hsl()` / `hsla()`, short and long hex, comma and modern space
33
+ syntax) and comma/whitespace runs are normalised outside quotes. A green stops flickering
34
+ red across browser versions — captures are serialization-independent.
35
+
36
+ Safety: only _provably-equal_ values ever collapse. A value that can't be parsed with
37
+ confidence (or lives inside a quoted string) is left byte-for-byte, so a real change —
38
+ `#ff0000` → `#ff0001`, `0.5` → `0.6` alpha, a different font — always still surfaces. The
39
+ report shows the real captured strings; only the equality test is canonical.
40
+
10
41
  ## [3.14.0] - 2026-07-05
11
42
 
12
43
  ### Added
@@ -345,7 +345,9 @@ const CI_WORKFLOW = `name: StyleProof
345
345
  # report without a browser;
346
346
  # - on cache miss, CI recaptures both sides in one pinned environment so the
347
347
  # comparison stays valid.
348
- on: pull_request
348
+ on:
349
+ pull_request:
350
+ types: [opened, synchronize, reopened, closed]
349
351
 
350
352
  permissions:
351
353
  contents: write
@@ -355,6 +357,8 @@ permissions:
355
357
 
356
358
  jobs:
357
359
  styleproof:
360
+ # Report on open/update; the prune job below handles close.
361
+ if: github.event.action != 'closed'
358
362
  runs-on: ubuntu-latest
359
363
  steps:
360
364
  - uses: actions/checkout@v4
@@ -408,6 +412,41 @@ ${PM.setup}
408
412
  baseline-dir: __stylemaps__/base
409
413
  fresh-dir: __stylemaps__/head
410
414
  require-approval: true
415
+
416
+ prune:
417
+ # PR closed: drop its pr-<n>/ folder from the report branch so the branch
418
+ # never grows without bound. Keep BRANCH in sync with the report-branch
419
+ # input above (default: styleproof-reports).
420
+ if: github.event.action == 'closed'
421
+ runs-on: ubuntu-latest
422
+ permissions:
423
+ contents: write
424
+ steps:
425
+ - name: Prune this PR's report folder
426
+ shell: bash
427
+ env:
428
+ GH_TOKEN: \${{ github.token }}
429
+ BRANCH: styleproof-reports
430
+ PR: \${{ github.event.pull_request.number }}
431
+ run: |
432
+ set -euo pipefail
433
+ REMOTE="https://x-access-token:\${GH_TOKEN}@github.com/\${{ github.repository }}.git"
434
+ if ! git ls-remote --exit-code "$REMOTE" "refs/heads/$BRANCH" >/dev/null 2>&1; then
435
+ echo "No $BRANCH branch yet — nothing to prune."; exit 0
436
+ fi
437
+ TMP="$(mktemp -d)"
438
+ # Blobless clone keeps this fast; a very large report branch may prefer
439
+ # a --no-checkout plumbing rewrite instead.
440
+ git clone --filter=blob:none --single-branch --branch "$BRANCH" "$REMOTE" "$TMP"
441
+ cd "$TMP"
442
+ if [ ! -d "pr-$PR" ]; then
443
+ echo "No pr-$PR/ folder — nothing to prune."; exit 0
444
+ fi
445
+ git config user.name "github-actions[bot]"
446
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
447
+ git rm -r --quiet "pr-$PR"
448
+ git commit -m "chore(styleproof): prune report for closed PR #$PR"
449
+ git push origin "$BRANCH"
411
450
  `;
412
451
 
413
452
  function writeFileSafe(file, contents, { force: f } = {}) {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Canonicalize a computed-style value: rewrite every parseable color to one `rgba(...)`
3
+ * form and normalize comma/whitespace runs outside quotes. Unparseable colors and quoted
4
+ * strings are left untouched, so only provably-equal values ever collapse.
5
+ */
6
+ export declare function canonicalizeStyleValue(value: string): string;
7
+ /** Two computed-style values are equal if they canonicalize to the same string. */
8
+ export declare function styleValuesEqual(a: string, b: string): boolean;
@@ -0,0 +1,154 @@
1
+ // Canonicalize a computed-style value so two spellings of the SAME value don't read as a
2
+ // change. Browsers and build tools serialize identical values differently — a Chromium
3
+ // bump rewrites `rgba(8, 18, 32, 0.62)` as `#0812209e`, a Tailwind migration reformats a
4
+ // font list's comma spacing — and StyleProof would otherwise report every one of those as
5
+ // a diff, drowning a re-baseline in changes that aren't changes.
6
+ //
7
+ // SAFETY BAR: only ever collapse values that are PROVABLY equal. A token we can't parse
8
+ // with confidence is left exactly as-is, so a real change always still diffs. Colors are
9
+ // parsed to a single rgba() form; everything else only has its comma/whitespace runs
10
+ // normalized (never inside quotes).
11
+ const clamp255 = (n) => Math.min(255, Math.max(0, Math.round(n)));
12
+ // Round alpha so the hex round-trip matches the decimal source: 0.62 → 0x9e (158) →
13
+ // 158/255 = 0.6196… → 0.62 again. 3 dp is enough to reunite them without collapsing
14
+ // genuinely different alphas (0.62 vs 0.625 stay apart).
15
+ const roundAlpha = (n) => Math.round(Math.min(1, Math.max(0, n)) * 1000) / 1000;
16
+ function fromHex(hex) {
17
+ let h = hex.slice(1);
18
+ if (h.length === 3 || h.length === 4) {
19
+ h = h
20
+ .split('')
21
+ .map((c) => c + c)
22
+ .join('');
23
+ }
24
+ if (h.length !== 6 && h.length !== 8)
25
+ return null;
26
+ if (!/^[0-9a-fA-F]+$/.test(h))
27
+ return null;
28
+ const r = parseInt(h.slice(0, 2), 16);
29
+ const g = parseInt(h.slice(2, 4), 16);
30
+ const b = parseInt(h.slice(4, 6), 16);
31
+ const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
32
+ return { r, g, b, a };
33
+ }
34
+ // Parse one channel: a plain number, or a percentage of `max`. Alpha is just `max: 1`
35
+ // (`50%` → 0.5, `0.5` → 0.5).
36
+ function channel(tok, max) {
37
+ const t = tok.trim();
38
+ if (t.endsWith('%')) {
39
+ const n = Number(t.slice(0, -1));
40
+ return Number.isFinite(n) ? (n / 100) * max : null;
41
+ }
42
+ const n = Number(t);
43
+ return Number.isFinite(n) ? n : null;
44
+ }
45
+ function hslToRgb(h, s, l) {
46
+ h = ((h % 360) + 360) % 360;
47
+ s = Math.min(1, Math.max(0, s));
48
+ l = Math.min(1, Math.max(0, l));
49
+ const c = (1 - Math.abs(2 * l - 1)) * s;
50
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
51
+ const m = l - c / 2;
52
+ const [r1, g1, b1] = h < 60
53
+ ? [c, x, 0]
54
+ : h < 120
55
+ ? [x, c, 0]
56
+ : h < 180
57
+ ? [0, c, x]
58
+ : h < 240
59
+ ? [0, x, c]
60
+ : h < 300
61
+ ? [x, 0, c]
62
+ : [c, 0, x];
63
+ return { r: (r1 + m) * 255, g: (g1 + m) * 255, b: (b1 + m) * 255 };
64
+ }
65
+ // Split `rgb(...)`/`hsl(...)` inner args on commas or the modern space + optional `/ alpha`.
66
+ function args(inner) {
67
+ if (inner.includes(','))
68
+ return inner.split(',');
69
+ return inner.replace('/', ' ').split(/\s+/).filter(Boolean);
70
+ }
71
+ function parseColor(token) {
72
+ const t = token.trim();
73
+ if (t.startsWith('#'))
74
+ return fromHex(t);
75
+ const fn = t.match(/^(rgba?|hsla?)\((.*)\)$/i);
76
+ if (!fn)
77
+ return null;
78
+ const kind = fn[1].toLowerCase();
79
+ const parts = args(fn[2]);
80
+ if (parts.length < 3)
81
+ return null;
82
+ const a = parts.length >= 4 ? channel(parts[3], 1) : 1;
83
+ if (a === null)
84
+ return null;
85
+ if (kind.startsWith('rgb')) {
86
+ const r = channel(parts[0], 255);
87
+ const g = channel(parts[1], 255);
88
+ const b = channel(parts[2], 255);
89
+ if (r === null || g === null || b === null)
90
+ return null;
91
+ return { r: clamp255(r), g: clamp255(g), b: clamp255(b), a };
92
+ }
93
+ // hsl / hsla
94
+ const h = Number(parts[0].replace(/deg$/i, '').trim());
95
+ const s = channel(parts[1], 1); // s/l are percentages → 0..1
96
+ const l = channel(parts[2], 1);
97
+ if (!Number.isFinite(h) || s === null || l === null)
98
+ return null;
99
+ const { r, g, b } = hslToRgb(h, s, l);
100
+ return { r: clamp255(r), g: clamp255(g), b: clamp255(b), a };
101
+ }
102
+ const COLOR_TOKEN = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/gi;
103
+ /**
104
+ * Canonicalize a computed-style value: rewrite every parseable color to one `rgba(...)`
105
+ * form and normalize comma/whitespace runs outside quotes. Unparseable colors and quoted
106
+ * strings are left untouched, so only provably-equal values ever collapse.
107
+ */
108
+ export function canonicalizeStyleValue(value) {
109
+ // Colours first — but never inside a quoted string (a content: value could hold "#fff"),
110
+ // so operate only on the unquoted segments.
111
+ const segments = splitQuoted(value);
112
+ const canon = segments
113
+ .map((seg) => (seg.quoted ? seg.text : seg.text.replace(COLOR_TOKEN, (m) => canonColor(m))))
114
+ .join('');
115
+ // Normalize comma spacing and collapse whitespace runs — again, only outside quotes.
116
+ return splitQuoted(canon)
117
+ .map((seg) => (seg.quoted ? seg.text : seg.text.replace(/\s*,\s*/g, ', ').replace(/\s+/g, ' ')))
118
+ .join('')
119
+ .trim();
120
+ }
121
+ function canonColor(token) {
122
+ const c = parseColor(token);
123
+ if (!c)
124
+ return token;
125
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${roundAlpha(c.a)})`;
126
+ }
127
+ function splitQuoted(value) {
128
+ const segs = [];
129
+ let i = 0;
130
+ let buf = '';
131
+ while (i < value.length) {
132
+ const ch = value[i];
133
+ if (ch === '"' || ch === "'") {
134
+ if (buf) {
135
+ segs.push({ text: buf, quoted: false });
136
+ buf = '';
137
+ }
138
+ const end = value.indexOf(ch, i + 1);
139
+ const stop = end === -1 ? value.length : end + 1;
140
+ segs.push({ text: value.slice(i, stop), quoted: true });
141
+ i = stop;
142
+ continue;
143
+ }
144
+ buf += ch;
145
+ i++;
146
+ }
147
+ if (buf)
148
+ segs.push({ text: buf, quoted: false });
149
+ return segs;
150
+ }
151
+ /** Two computed-style values are equal if they canonicalize to the same string. */
152
+ export function styleValuesEqual(a, b) {
153
+ return a === b || canonicalizeStyleValue(a) === canonicalizeStyleValue(b);
154
+ }
package/dist/diff.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { loadStyleMap, isUnder } from './capture.js';
4
4
  import { isMapFile } from './map-store.js';
5
+ import { styleValuesEqual } from './canonicalize.js';
5
6
  function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
6
7
  const changed = [];
7
8
  for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
@@ -9,7 +10,10 @@ function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
9
10
  continue;
10
11
  const before = propsA[prop] ?? fallbackA[prop] ?? unsetA;
11
12
  const after = propsB[prop] ?? fallbackB[prop] ?? unsetB;
12
- if (before !== after)
13
+ // Compare by CANONICAL value, so an identical value serialized differently by a
14
+ // browser/build-tool version (`rgba(8, 18, 32, 0.62)` vs `#0812209e`, comma-spacing in
15
+ // a font list) is not reported as a change. The report still shows the real strings.
16
+ if (!styleValuesEqual(before, after))
13
17
  changed.push({ prop, before, after });
14
18
  }
15
19
  return changed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.14.0",
3
+ "version": "3.16.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",