styleproof 3.14.0 → 3.15.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,25 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.15.0] - 2026-07-05
11
+
12
+ ### Changed
13
+
14
+ - **Computed values are compared canonically, so a re-serialization isn't a change.** A
15
+ browser or build-tool version can serialize an _identical_ value differently — a Chromium
16
+ bump rewrites `rgba(8, 18, 32, 0.62)` as `#0812209e`, a Tailwind migration reformats a
17
+ font list's comma spacing — and StyleProof reported every one of those as a difference,
18
+ drowning a re-baseline in changes that aren't changes (and blowing up the report). The
19
+ diff now compares by a canonical form: colours are parsed to one `rgba()` (across
20
+ hex / `rgb()` / `rgba()` / `hsl()` / `hsla()`, short and long hex, comma and modern space
21
+ syntax) and comma/whitespace runs are normalised outside quotes. A green stops flickering
22
+ red across browser versions — captures are serialization-independent.
23
+
24
+ Safety: only _provably-equal_ values ever collapse. A value that can't be parsed with
25
+ confidence (or lives inside a quoted string) is left byte-for-byte, so a real change —
26
+ `#ff0000` → `#ff0001`, `0.5` → `0.6` alpha, a different font — always still surfaces. The
27
+ report shows the real captured strings; only the equality test is canonical.
28
+
10
29
  ## [3.14.0] - 2026-07-05
11
30
 
12
31
  ### Added
@@ -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.15.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",