ci: verify colour values in token migrations

This commit is contained in:
Miguel Angel Simon Sierra
2026-09-21 19:46:57 -04:00
parent bf6cc7e32b
commit 7cc1f4b796
5 changed files with 372 additions and 0 deletions
+14
View File
@@ -26,6 +26,20 @@ concurrency:
cancel-in-progress: true
jobs:
token-parity:
name: Token value parity
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: node --test scripts/check-token-parity.test.mjs
- run: node scripts/check-token-parity.mjs origin/main
changes:
name: Detect changes
runs-on: ubuntu-latest
+272
View File
@@ -0,0 +1,272 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const THEME = "packages/studio/src/styles/theme.css";
const BASELINE = "scripts/token-parity-baseline.json";
const ALLOWLIST = "scripts/token-parity-allowlist.json";
const COLOR = /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/gi;
const TOKEN = /var\(\s*(--[\w-]+)(?:\s*,\s*([^()]*|(?:rgba?|hsla?)\([^)]*\)))?\s*\)/g;
function hexChannels(text) {
let hex = text.slice(1);
if ([3, 4].includes(hex.length)) hex = [...hex].map((c) => c + c).join("");
if (![6, 8].includes(hex.length)) throw new Error(`Unsupported colour: ${text}`);
const channels = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
return [...channels, hex.length === 8 ? parseInt(hex.slice(6), 16) / 255 : 1];
}
function hueDegrees(hue) {
const n = parseFloat(hue);
if (hue.endsWith("turn")) return n * 360;
if (hue.endsWith("grad")) return n * 0.9;
if (hue.endsWith("rad")) return (n * 180) / Math.PI;
return n;
}
function hslChannels(parts) {
const h = (((hueDegrees(parts[0]) % 360) + 360) % 360) / 30;
const saturation = parseFloat(parts[1]) / 100;
const lightness = parseFloat(parts[2]) / 100;
const amplitude = saturation * Math.min(lightness, 1 - lightness);
return [0, 8, 4].map(
(n) =>
255 *
(lightness - amplitude * Math.max(-1, Math.min(((n + h) % 12) - 3, 9 - ((n + h) % 12), 1))),
);
}
function functionalChannels(text) {
const parts = text
.slice(text.indexOf("(") + 1, -1)
.split(/[\s,/]+/)
.filter(Boolean);
const alpha = parts[3] ?? "1";
const opacity = parseFloat(alpha) / (alpha.endsWith("%") ? 100 : 1);
const channels = text.startsWith("rgb")
? parts.slice(0, 3).map((p) => parseFloat(p) * (p.endsWith("%") ? 2.55 : 1))
: hslChannels(parts);
return [...channels, opacity];
}
export function normalizeColor(value) {
const text = value.toLowerCase().trim();
const channels = text.startsWith("#") ? hexChannels(text) : functionalChannels(text);
if (channels.length !== 4 || channels.some((n) => !Number.isFinite(n)))
throw new Error(`Unsupported colour: ${value}`);
return channels
.map((n, i) => Number(Math.max(0, Math.min(i === 3 ? 1 : 255, n)).toFixed(12)))
.join(",");
}
function canonical(line) {
return line
.replace(
/\b([\w:-]+)-(white|black)(?:\/(\d+(?:\.\d+)?))?\b/g,
(_, prefix, color, alpha = "100") => {
const n = color === "white" ? 255 : 0;
return `${prefix}-[rgba(${n},${n},${n},${Number(alpha) / 100})]`;
},
)
.replace(COLOR, (color) => `COLOR(${normalizeColor(color)})`)
.replace(/\s+/g, "");
}
export function tokenValues(css) {
const values = new Map();
for (const [, name, value] of css
.replace(/\/\*[\s\S]*?\*\//g, "")
.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
if (values.has(name)) throw new Error(`Ambiguous token default: ${name}`);
values.set(name, value.trim());
}
return values;
}
function expandTokens(line, values, seen = []) {
return line.replace(TOKEN, (reference, name, fallback) => {
if (!values.has(name)) return fallback ?? reference;
if (seen.includes(name)) throw new Error(`Cyclic token alias: ${name}`);
return expandTokens(values.get(name), values, [...seen, name]);
});
}
function quotedEnd(line, start) {
let end = start + 1;
while (end < line.length) {
if (line[end] === line[start] && line[end - 1] !== "\\") return end + 1;
end++;
}
return end;
}
function scalarEnd(line, start) {
let depth = 0;
for (let end = start; end < line.length; end++) {
if (depth === 0 && /[,;}]/.test(line[end])) return end;
depth += Number(line[end] === "(") - Number(line[end] === ")");
}
return line.length;
}
function valueEnd(line, start) {
return /["'`]/.test(line[start]) ? quotedEnd(line, start) : scalarEnd(line, start);
}
function propertySlots(line) {
const result = [];
const properties = /([\w-]+)["']?\s*[:=]\s*/g;
for (let match = properties.exec(line); match; match = properties.exec(line)) {
if (line[properties.lastIndex] === "{") continue;
const end = valueEnd(line, properties.lastIndex);
const value = line.slice(properties.lastIndex, end).replace(/^["'`]|["'`]$/g, "");
result.push({ key: match[1], value });
properties.lastIndex = end;
}
return result;
}
function classSlots(line) {
const result = [];
const classes = [
...line.matchAll(
/([\w:-]+)-(white|black)(?:\/(\d+(?:\.\d+)?))?\b|([\w:-]+)-\[(var\([^)]*\))\]/g,
),
];
for (const match of classes) {
const key = match[1] ?? match[4];
result.push({ key: `class:${key}`, value: match[0] });
}
return result;
}
function slots(line) {
const classes = classSlots(line);
if (classes.length) return classes;
const properties = propertySlots(line);
return properties.length ? properties : [{ key: "line", value: line }];
}
function matchReplacement(slot, oldSlots, values) {
const names = [...slot.value.matchAll(TOKEN)].map((match) => match[1]);
if (!names.length) return undefined;
const candidates = oldSlots.filter(
(old) =>
old.key === slot.key &&
/#[\da-f]{3,8}\b|\b(?:rgba?|hsla?)\(|-(?:white|black)(?:\/\d+)?\b/i.test(old.value),
);
if (!candidates.length) return undefined;
const expanded = canonical(expandTokens(slot.value, values));
const exact = candidates.find((old) => canonical(old.value) === expanded);
return { before: exact ?? candidates[0], equal: Boolean(exact), names };
}
function compareSlot(file, line, slot, oldSlots, values, allowlist) {
const match = matchReplacement(slot, oldSlots, values);
if (!match) return [];
oldSlots.splice(oldSlots.indexOf(match.before), 1);
if (match.equal) return [];
const entry = { file, before: match.before.line.trim(), after: line.trim() };
if (allowedReplacement(entry, allowlist)) return [];
return [{ ...entry, tokens: match.names, message: "token default changes the replaced value" }];
}
function replacementIssues(file, removed, added, values, allowlist) {
const oldSlots = removed.flatMap((line) => slots(line).map((slot) => ({ ...slot, line })));
const issues = [];
for (const line of added) {
for (const slot of slots(line)) {
issues.push(...compareSlot(file, line, slot, oldSlots, values, allowlist));
}
}
return issues;
}
function allowedReplacement(entry, allowlist) {
return allowlist.some((rule) => {
if (typeof rule.reason !== "string") return false;
return [
rule.file === entry.file,
rule.before === entry.before,
rule.after === entry.after,
rule.reason.trim(),
].every(Boolean);
});
}
function diffLineKind(line) {
if (line.startsWith("+++ b/")) return "file";
if (/^(?:--- |\+\+\+ )/.test(line)) return "skip";
return { "-": "removed", "+": "added" }[line[0]] ?? "context";
}
function finishesBlock(kind, block) {
if (["file", "context"].includes(kind)) return true;
return kind === "removed" && block.added.length > 0;
}
function recordLine(block, kind, line) {
if (kind === "file") block.file = line.slice(6);
if (["added", "removed"].includes(kind)) block[kind].push(line.slice(1));
}
function diffBlocks(diff) {
const blocks = [];
let block = { file: "", removed: [], added: [] };
for (const line of diff.split("\n")) {
const kind = diffLineKind(line);
if (finishesBlock(kind, block)) {
blocks.push(block);
block = { file: block.file, removed: [], added: [] };
}
recordLine(block, kind, line);
}
return [...blocks, block];
}
export function findTokenParityIssues(diff, css, allowlist = []) {
const values = tokenValues(css);
return diffBlocks(diff).flatMap(({ file, removed, added }) =>
replacementIssues(file, removed, added, values, allowlist),
);
}
function baselineError(file, count, counts, previous) {
const allowed = previous.files[file] ?? 0;
const current = counts[file] ?? 0;
if (![count <= allowed, count === current].every(Boolean))
return [{ file, message: "baseline must only shrink and bank improvements" }];
return [];
}
export function parityVerdict(issues, baseline, previous = baseline) {
const counts = issues.reduce((counts, issue) => {
counts[issue.file] = (counts[issue.file] ?? 0) + 1;
return counts;
}, {});
const errors = issues.filter((issue) => counts[issue.file] > (baseline.files[issue.file] ?? 0));
errors.push(
...Object.entries(baseline.files).flatMap(([file, count]) =>
baselineError(file, count, counts, previous),
),
);
if (baseline.total !== Object.values(baseline.files).reduce((a, b) => a + b, 0))
throw new Error("Incorrect baseline total");
return errors;
}
function baseRef(base) {
if (!base || base.startsWith("-"))
throw new Error("Usage: node scripts/check-token-parity.mjs <base-ref>");
return base;
}
function main() {
const base = baseRef(process.argv[2]);
const git = (args) =>
execFileSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
const diff = git(["diff", "--no-ext-diff", "--no-color", "--unified=3", `${base}...HEAD`, "--"]);
const baseline = JSON.parse(readFileSync(BASELINE, "utf8"));
const previous = git(["ls-tree", "--name-only", base, "--", BASELINE]).trim()
? JSON.parse(git(["show", `${base}:${BASELINE}`]))
: baseline;
const issues = findTokenParityIssues(
diff,
readFileSync(THEME, "utf8"),
JSON.parse(readFileSync(ALLOWLIST, "utf8")),
);
const errors = parityVerdict(issues, baseline, previous);
if (errors.length) {
console.error(JSON.stringify(errors, null, 2));
process.exitCode = 1;
} else console.log(`Token value parity verified: ${issues.length} baselined migrations.`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { findTokenParityIssues, normalizeColor, parityVerdict } from "./check-token-parity.mjs";
const diff = (old, next) =>
`--- a/packages/a.ts\n+++ b/packages/a.ts\n@@ -1 +1 @@\n-${old}\n+${next}\n`;
const empty = { total: 0, files: {} };
test("planted wrong token fails and matching token passes", () => {
const change = diff('color: "#fff"', 'color: "var(--text)"');
assert.equal(parityVerdict(findTokenParityIssues(change, "--text: #0f0;"), empty).length, 1);
assert.deepEqual(findTokenParityIssues(change, "--text: #ffffff;"), []);
});
test("normalizes colour notation and alpha precision", () => {
assert.equal(normalizeColor("#fff"), normalizeColor("rgb(255 255 255)"));
assert.equal(normalizeColor("#ffffff33"), normalizeColor("rgba(255,255,255,0.20)"));
assert.equal(normalizeColor("hsl(120 100% 50% / 50%)"), normalizeColor("rgba(0,255,0,0.5)"));
});
test("checks Tailwind slash opacity swaps", () => {
const change = diff('className="border-white/20"', 'className="border-[var(--border)]"');
assert.equal(findTokenParityIssues(change, "--border: rgba(60,230,172,0.2);").length, 1);
assert.deepEqual(findTokenParityIssues(change, "--border: rgba(255,255,255,0.2);"), []);
});
test("compares a whole shorthand rather than its colour alone", () => {
const change = diff('boxShadow: "0 1px 2px rgba(0,0,0,0.85)"', 'boxShadow: "var(--shadow)"');
assert.deepEqual(findTokenParityIssues(change, "--shadow: 0 1px 2px rgba(0,0,0,0.85);"), []);
assert.equal(findTokenParityIssues(change, "--shadow: 0 4px 2px rgba(0,0,0,0.85);").length, 1);
});
test("aliases resolve and undocumented tokens fail", () => {
const change = diff('color: "#fff"', 'color: "var(--alias)"');
assert.deepEqual(findTokenParityIssues(change, "--alias: var(--white); --white: #fff;"), []);
assert.equal(findTokenParityIssues(change, "").length, 1);
assert.throws(() => findTokenParityIssues(change, "--alias: var(--alias);"), /Cyclic/);
});
test("allowlist requires an exact replacement and a reason", () => {
const before = 'color: "#fff"',
after = 'color: "var(--text)"';
const rule = { file: "packages/a.ts", before, after, reason: "Intentional contrast change" };
assert.deepEqual(findTokenParityIssues(diff(before, after), "--text: #000;", [rule]), []);
assert.equal(
findTokenParityIssues(diff(before, after), "--text: #000;", [{ ...rule, reason: "" }]).length,
1,
);
});
test("baseline cannot be raised to hide a migration", () => {
const issues = findTokenParityIssues(diff('color: "#fff"', 'color: "var(--x)"'), "--x: #000;");
assert.equal(parityVerdict(issues, { total: 1, files: { "packages/a.ts": 1 } }, empty).length, 1);
});
test("reordered properties still compare the colour slot", () => {
const change =
'--- a/packages/a.ts\n+++ b/packages/a.ts\n@@ -1,2 +1,2 @@\n-color: "#fff"\n-width: 1\n+width: 2\n+color: "var(--x)"';
assert.equal(findTokenParityIssues(change, "--x: #000;").length, 1);
});
test("unrelated variable changes beside a colour are not migrations", () => {
assert.deepEqual(
findTokenParityIssues(
diff('color: "#fff", width: 1', 'color: "#fff", width: "var(--size)"'),
"--size: 1px;",
),
[],
);
});
test("defined tokens override fallbacks and fractional alpha stays distinct", () => {
assert.equal(
findTokenParityIssues(diff('color: "#fff"', 'color: "var(--x, #fff)"'), "--x: #000;").length,
1,
);
assert.notEqual(normalizeColor("rgba(0,0,0,.100)"), normalizeColor("rgba(0,0,0,.101)"));
});
test("duplicate additions cannot hide a second wrong replacement", () => {
const change =
'--- a/packages/a.ts\n+++ b/packages/a.ts\n@@ -1,2 +1,2 @@\n-color: "#fff"\n-color: "#000"\n+color: "var(--x)"\n+color: "var(--x)"';
assert.equal(findTokenParityIssues(change, "--x: #f00;").length, 2);
});
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
{ "total": 0, "files": {} }