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 +31 -0
- package/bin/styleproof-init.mjs +40 -1
- package/dist/canonicalize.d.ts +8 -0
- package/dist/canonicalize.js +154 -0
- package/dist/diff.js +5 -1
- package/package.json +1 -1
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
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
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",
|