From 30df0add9b92b968cf6321608626920c0b2c19f1 Mon Sep 17 00:00:00 2001 From: StyleProof Test Date: Sun, 5 Jul 2026 12:54:42 +0100 Subject: [PATCH] =?UTF-8?q?feat(diff):=20compare=20computed=20values=20can?= =?UTF-8?q?onically=20=E2=80=94=20a=20re-serialization=20isn't=20a=20chang?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding a real re-baseline showed StyleProof reporting a flood of "changes" that weren't: a newer Chromium serialized identical values differently — rgba(8, 18, 32, 0.62) became #0812209e, a font list's comma spacing shifted — and every one read as a diff, drowning the review and blowing the report up to hundreds of MB. A green flickered red purely across browser versions. Compare by a canonical form instead: colours parse to one rgba() (hex / rgb / rgba / hsl / hsla, short+long hex, comma + modern space syntax), and comma/whitespace runs normalize outside quotes. Captures become serialization-independent. Safety bar — only PROVABLY-equal values collapse: an unparseable value (or anything inside a quoted string) is left byte-for-byte, so a real change (#ff0000 → #ff0001, 0.5 → 0.6 alpha, a different font) always still surfaces. The report shows the real captured strings; only the equality test is canonical. Custom props stay ignored; content/text diffs stay exact. Tests: test/canonicalize.test.mjs — equal spellings collapse, real changes never do, quoted/unparseable left untouched, and diffStyleMaps yields zero findings for a serialization-only difference. Full suite 255/255 (no existing diff test regressed). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 19 +++++ package.json | 2 +- src/canonicalize.ts | 158 +++++++++++++++++++++++++++++++++++++ src/diff.ts | 6 +- test/canonicalize.test.mjs | 68 ++++++++++++++++ 5 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 src/canonicalize.ts create mode 100644 test/canonicalize.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d2ef4..b7792e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [3.15.0] - 2026-07-05 + +### Changed + +- **Computed values are compared canonically, so a re-serialization isn't a change.** A + browser or build-tool version can serialize an _identical_ value differently — a Chromium + bump rewrites `rgba(8, 18, 32, 0.62)` as `#0812209e`, a Tailwind migration reformats a + font list's comma spacing — and StyleProof reported every one of those as a difference, + drowning a re-baseline in changes that aren't changes (and blowing up the report). The + diff now compares by a canonical form: colours are parsed to one `rgba()` (across + hex / `rgb()` / `rgba()` / `hsl()` / `hsla()`, short and long hex, comma and modern space + syntax) and comma/whitespace runs are normalised outside quotes. A green stops flickering + red across browser versions — captures are serialization-independent. + + Safety: only _provably-equal_ values ever collapse. A value that can't be parsed with + confidence (or lives inside a quoted string) is left byte-for-byte, so a real change — + `#ff0000` → `#ff0001`, `0.5` → `0.6` alpha, a different font — always still surfaces. The + report shows the real captured strings; only the equality test is canonical. + ## [3.13.0] - 2026-07-05 ### Added diff --git a/package.json b/package.json index 35482c7..c64a884 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "styleproof", - "version": "3.13.0", + "version": "3.15.0", "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.", "keywords": [ "playwright", diff --git a/src/canonicalize.ts b/src/canonicalize.ts new file mode 100644 index 0000000..37886df --- /dev/null +++ b/src/canonicalize.ts @@ -0,0 +1,158 @@ +// Canonicalize a computed-style value so two spellings of the SAME value don't read as a +// change. Browsers and build tools serialize identical values differently — a Chromium +// bump rewrites `rgba(8, 18, 32, 0.62)` as `#0812209e`, a Tailwind migration reformats a +// font list's comma spacing — and StyleProof would otherwise report every one of those as +// a diff, drowning a re-baseline in changes that aren't changes. +// +// SAFETY BAR: only ever collapse values that are PROVABLY equal. A token we can't parse +// with confidence is left exactly as-is, so a real change always still diffs. Colors are +// parsed to a single rgba() form; everything else only has its comma/whitespace runs +// normalized (never inside quotes). + +type Rgba = { r: number; g: number; b: number; a: number }; + +const clamp255 = (n: number) => Math.min(255, Math.max(0, Math.round(n))); +// Round alpha so the hex round-trip matches the decimal source: 0.62 → 0x9e (158) → +// 158/255 = 0.6196… → 0.62 again. 3 dp is enough to reunite them without collapsing +// genuinely different alphas (0.62 vs 0.625 stay apart). +const roundAlpha = (n: number) => Math.round(Math.min(1, Math.max(0, n)) * 1000) / 1000; + +function fromHex(hex: string): Rgba | null { + let h = hex.slice(1); + if (h.length === 3 || h.length === 4) { + h = h + .split('') + .map((c) => c + c) + .join(''); + } + if (h.length !== 6 && h.length !== 8) return null; + if (!/^[0-9a-fA-F]+$/.test(h)) return null; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1; + return { r, g, b, a }; +} + +// Parse one channel: a plain number, or a percentage of `max`. Alpha is just `max: 1` +// (`50%` → 0.5, `0.5` → 0.5). +function channel(tok: string, max: number): number | null { + const t = tok.trim(); + if (t.endsWith('%')) { + const n = Number(t.slice(0, -1)); + return Number.isFinite(n) ? (n / 100) * max : null; + } + const n = Number(t); + return Number.isFinite(n) ? n : null; +} + +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + h = ((h % 360) + 360) % 360; + s = Math.min(1, Math.max(0, s)); + l = Math.min(1, Math.max(0, l)); + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + const [r1, g1, b1] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x]; + return { r: (r1 + m) * 255, g: (g1 + m) * 255, b: (b1 + m) * 255 }; +} + +// Split `rgb(...)`/`hsl(...)` inner args on commas or the modern space + optional `/ alpha`. +function args(inner: string): string[] { + if (inner.includes(',')) return inner.split(','); + return inner.replace('/', ' ').split(/\s+/).filter(Boolean); +} + +function parseColor(token: string): Rgba | null { + const t = token.trim(); + if (t.startsWith('#')) return fromHex(t); + const fn = t.match(/^(rgba?|hsla?)\((.*)\)$/i); + if (!fn) return null; + const kind = fn[1].toLowerCase(); + const parts = args(fn[2]); + if (parts.length < 3) return null; + const a = parts.length >= 4 ? channel(parts[3], 1) : 1; + if (a === null) return null; + if (kind.startsWith('rgb')) { + const r = channel(parts[0], 255); + const g = channel(parts[1], 255); + const b = channel(parts[2], 255); + if (r === null || g === null || b === null) return null; + return { r: clamp255(r), g: clamp255(g), b: clamp255(b), a }; + } + // hsl / hsla + const h = Number(parts[0].replace(/deg$/i, '').trim()); + const s = channel(parts[1], 1); // s/l are percentages → 0..1 + const l = channel(parts[2], 1); + if (!Number.isFinite(h) || s === null || l === null) return null; + const { r, g, b } = hslToRgb(h, s, l); + return { r: clamp255(r), g: clamp255(g), b: clamp255(b), a }; +} + +const COLOR_TOKEN = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/gi; + +/** + * Canonicalize a computed-style value: rewrite every parseable color to one `rgba(...)` + * form and normalize comma/whitespace runs outside quotes. Unparseable colors and quoted + * strings are left untouched, so only provably-equal values ever collapse. + */ +export function canonicalizeStyleValue(value: string): string { + // Colours first — but never inside a quoted string (a content: value could hold "#fff"), + // so operate only on the unquoted segments. + const segments = splitQuoted(value); + const canon = segments + .map((seg) => (seg.quoted ? seg.text : seg.text.replace(COLOR_TOKEN, (m) => canonColor(m)))) + .join(''); + // Normalize comma spacing and collapse whitespace runs — again, only outside quotes. + return splitQuoted(canon) + .map((seg) => (seg.quoted ? seg.text : seg.text.replace(/\s*,\s*/g, ', ').replace(/\s+/g, ' '))) + .join('') + .trim(); +} + +function canonColor(token: string): string { + const c = parseColor(token); + if (!c) return token; + return `rgba(${c.r}, ${c.g}, ${c.b}, ${roundAlpha(c.a)})`; +} + +type Seg = { text: string; quoted: boolean }; +function splitQuoted(value: string): Seg[] { + const segs: Seg[] = []; + let i = 0; + let buf = ''; + while (i < value.length) { + const ch = value[i]; + if (ch === '"' || ch === "'") { + if (buf) { + segs.push({ text: buf, quoted: false }); + buf = ''; + } + const end = value.indexOf(ch, i + 1); + const stop = end === -1 ? value.length : end + 1; + segs.push({ text: value.slice(i, stop), quoted: true }); + i = stop; + continue; + } + buf += ch; + i++; + } + if (buf) segs.push({ text: buf, quoted: false }); + return segs; +} + +/** Two computed-style values are equal if they canonicalize to the same string. */ +export function styleValuesEqual(a: string, b: string): boolean { + return a === b || canonicalizeStyleValue(a) === canonicalizeStyleValue(b); +} diff --git a/src/diff.ts b/src/diff.ts index d808f3d..5c49706 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadStyleMap, isUnder, type StyleMap } from './capture.js'; import { isMapFile } from './map-store.js'; +import { styleValuesEqual } from './canonicalize.js'; /** * Structured diff between two style maps. Custom properties (--*) are @@ -53,7 +54,10 @@ function diffProps( if (prop.startsWith('--')) continue; const before = propsA[prop] ?? fallbackA[prop] ?? unsetA; const after = propsB[prop] ?? fallbackB[prop] ?? unsetB; - if (before !== after) changed.push({ prop, before, after }); + // Compare by CANONICAL value, so an identical value serialized differently by a + // browser/build-tool version (`rgba(8, 18, 32, 0.62)` vs `#0812209e`, comma-spacing in + // a font list) is not reported as a change. The report still shows the real strings. + if (!styleValuesEqual(before, after)) changed.push({ prop, before, after }); } return changed; } diff --git a/test/canonicalize.test.mjs b/test/canonicalize.test.mjs new file mode 100644 index 0000000..cdc3c93 --- /dev/null +++ b/test/canonicalize.test.mjs @@ -0,0 +1,68 @@ +// Canonicalization: an identical computed value serialized differently by a browser or +// build-tool version must NOT read as a change — but a real value change always must. +// This is the fix for re-baseline noise (a Chromium bump rewrites rgba()→#hex; a Tailwind +// migration reformats font-list spacing) drowning a diff in changes that aren't changes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { canonicalizeStyleValue, styleValuesEqual } from '../dist/canonicalize.js'; +import { diffStyleMaps } from '../dist/diff.js'; + +test('equal values in different spellings collapse (no false diff)', () => { + for (const [a, b] of [ + ['rgba(8, 18, 32, 0.62)', '#0812209e'], // the Chromium re-serialization that started this + ['rgb(255, 0, 0)', '#ff0000'], + ['rgb(255, 0, 0)', '#f00'], + ['rgba(255,0,0,1)', 'rgb(255, 0, 0)'], // spacing + alpha=1 dropped + ['hsl(0, 100%, 50%)', 'rgb(255, 0, 0)'], // hsl vs rgb, same red + ['hsla(120, 100%, 50%, 0.5)', 'rgba(0, 255, 0, 0.5)'], + ['rgb(8 18 32 / 0.62)', 'rgba(8, 18, 32, 0.62)'], // modern space syntax + ['"Share Tech Mono",ui-monospace,monospace', '"Share Tech Mono", ui-monospace, monospace'], + ['1px solid red', '1px solid red'], // whitespace runs + ['0 1px 2px rgba(0,0,0,.5)', '0 1px 2px rgba(0, 0, 0, 0.5)'], // color inside a shadow + ]) { + assert.ok( + styleValuesEqual(a, b), + `${a} should equal ${b} (canon: ${canonicalizeStyleValue(a)} vs ${canonicalizeStyleValue(b)})`, + ); + } +}); + +test('a REAL value change is never collapsed (source of truth preserved)', () => { + for (const [a, b] of [ + ['rgb(255, 0, 0)', 'rgb(255, 0, 1)'], // one channel off by 1 + ['#ff0000', '#ff0001'], + ['rgba(0, 0, 0, 0.5)', 'rgba(0, 0, 0, 0.6)'], // alpha differs + ['10px', '11px'], + ['red', 'blue'], // named + ['"Arial", sans-serif', '"Helvetica", sans-serif'], // different quoted family + ['bold', 'normal'], + ]) { + assert.ok(!styleValuesEqual(a, b), `${a} must NOT equal ${b}`); + } +}); + +test('unparseable colours and quoted strings are left untouched', () => { + // A "#fff" inside content: is text, not a colour — not rewritten. + assert.equal(canonicalizeStyleValue('"#fff"'), '"#fff"'); + // An unparseable colour function is returned as-is (never guessed). + assert.equal(canonicalizeStyleValue('rgb(var(--x))'), 'rgb(var(--x))'); +}); + +// The user-visible regression: a serialization-only difference produces ZERO findings. +test('diffStyleMaps ignores a serialization-only computed-style difference', () => { + const el = (color, font) => ({ + defaults: {}, + states: {}, + elements: { + body: { tag: 'body', cls: '', rect: [0, 0, 8, 8], style: { color, 'font-family': font } }, + }, + }); + const base = el('rgba(8, 18, 32, 0.62)', '"Share Tech Mono",ui-monospace'); + const head = el('#0812209e', '"Share Tech Mono", ui-monospace'); + const findings = diffStyleMaps(base, head); + assert.equal(findings.length, 0, `serialization-only diff should yield no findings, got ${JSON.stringify(findings)}`); + + // ...but a real colour change still surfaces. + const realChange = diffStyleMaps(base, el('#0912209e', '"Share Tech Mono",ui-monospace')); + assert.ok(realChange.length > 0, 'a real colour change must still be a finding'); +});