chore: add design tokens\css lint rules

This commit is contained in:
2026-09-02 00:14:29 +05:00
parent 191ca71553
commit 1310c51543
22 changed files with 2017 additions and 245 deletions
+8 -4
View File
@@ -16,11 +16,15 @@ for (let i = 0; i < process.argv.length; i++) {
}
if (!viewportSeen) args.push("--viewport", VIEWPORTS.join(","));
const r = spawnSync(process.execPath, ["scripts/audit-cdp.mjs", ...args.slice(2)], {
stdio: "inherit",
});
const r = spawnSync(
process.execPath,
["scripts/audit-cdp.mjs", ...args.slice(2)],
{
stdio: "inherit",
},
);
if (r.error) {
console.error(r.error.message);
process.exit(1);
}
process.exit(r.status ?? 1);
process.exit(r.status ?? 1);
+36 -30
View File
@@ -460,9 +460,10 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
function expandFont(raw) {
const v = raw.trim();
// Locate the size token: a length/% literal or a clamp()/min()/max().
const sz = /(\d+(?:\.\d+)?(?:px|em|rem|%|pt|vh|vw)|clamp\([^)]*\)|min\([^)]*\)|max\([^)]*\))/.exec(
v,
);
const sz =
/(\d+(?:\.\d+)?(?:px|em|rem|%|pt|vh|vw)|clamp\([^)]*\)|min\([^)]*\)|max\([^)]*\))/.exec(
v,
);
if (!sz) return {};
const size = sz[0];
// Optional leading font-style/variant/weight/stretch prefix, then the
@@ -550,7 +551,9 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
// carrying var() was enumerated as EMPTY longhands, so it must be
// re-parsed here to survive (e.g. border-radius: var(--radius)).
if (map.has(name)) continue;
for (const [ln, v] of Object.entries(expandShorthand(name, value, cs) || {}))
for (const [ln, v] of Object.entries(
expandShorthand(name, value, cs) || {},
))
add(ln, v, important);
}
return [...map.values()];
@@ -693,32 +696,32 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
el.id === "svelte-announcer"
)
return null;
// Computed style drives var() resolution and the recorded computed values;
// computed once here and reused by the cascade matcher below.
const cs = getComputedStyle(el);
const specMap = new Map(inheritedEntries(inherited));
for (const [name, d] of resolve(matchedDeclarations(el, rules, cs))) {
const cur = specMap.get(name);
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
}
// Box shorthands (margin/padding/gap): record the ORIGINAL declared value
// when one rule cleanly set every side (`consistent` cascade), so the report
// can show "20px 20px 20px 20px" vs a single "20px" the way the CSS spells
// it, not just the collapsed rendering.
const groups = {};
for (const g of BOX_GROUPS) {
const declared = specMap.get(g.name)?.value;
if (!declared) continue;
const expanded = expandShorthand(g.name, declared, cs);
const consistent = g.keys.every(
(k) => (expanded[k] ?? "") === (specMap.get(k)?.value ?? ""),
);
groups[g.name] = { declared, consistent };
}
// Computed style drives var() resolution and the recorded computed values;
// computed once here and reused by the cascade matcher below.
const cs = getComputedStyle(el);
const specMap = new Map(inheritedEntries(inherited));
for (const [name, d] of resolve(matchedDeclarations(el, rules, cs))) {
const cur = specMap.get(name);
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
}
// Box shorthands (margin/padding/gap): record the ORIGINAL declared value
// when one rule cleanly set every side (`consistent` cascade), so the report
// can show "20px 20px 20px 20px" vs a single "20px" the way the CSS spells
// it, not just the collapsed rendering.
const groups = {};
for (const g of BOX_GROUPS) {
const declared = specMap.get(g.name)?.value;
if (!declared) continue;
const expanded = expandShorthand(g.name, declared, cs);
const consistent = g.keys.every(
(k) => (expanded[k] ?? "") === (specMap.get(k)?.value ?? ""),
);
groups[g.name] = { declared, consistent };
}
const styles = {};
const raw = {};
const computed = {};
const styles = {};
const raw = {};
const computed = {};
for (const p of PROPS) {
const c = cs.getPropertyValue(p);
computed[p] = c;
@@ -1083,7 +1086,10 @@ function borderOf(a, b, n) {
function borderLine(a, b) {
const keys = [...BORDER_KEYS].filter((k) => isRealDiff(a, b, k));
if (!keys.length) return null;
return { line: `border: ${borderOf(a, b, a)}${borderOf(a, b, b)}`, keys };
return {
line: `border: ${borderOf(a, b, a)}${borderOf(a, b, b)}`,
keys,
};
}
// CSS box shorthands reported as one line instead of one line per longhand
+83 -62
View File
@@ -183,65 +183,64 @@ const keyOf = (n) =>
n.children.length === 0 ? `${normTag(n.tag)}|${n.text}` : normTag(n.tag);
const snapshot = (page) =>
page.evaluate(
(styleFields) => {
const sig = (el) => {
const s = getComputedStyle(el);
const o = {};
for (const f of styleFields) o[f] = s[f];
return o;
page.evaluate((styleFields) => {
const sig = (el) => {
const s = getComputedStyle(el);
const o = {};
for (const f of styleFields) o[f] = s[f];
return o;
};
const rectOf = (el) => {
const r = el.getBoundingClientRect();
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
};
const rectOf = (el) => {
const r = el.getBoundingClientRect();
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
};
};
const readTokens = () => {
const s = getComputedStyle(document.documentElement);
const out = {};
for (const k of s) if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
return out;
};
const tokensLight = readTokens();
const prev = document.documentElement.getAttribute("data-theme");
document.documentElement.setAttribute("data-theme", "dark");
const tokensDark = readTokens();
if (prev) document.documentElement.setAttribute("data-theme", prev);
else document.documentElement.removeAttribute("data-theme");
};
const readTokens = () => {
const s = getComputedStyle(document.documentElement);
const out = {};
for (const k of s)
if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
return out;
};
const tokensLight = readTokens();
const prev = document.documentElement.getAttribute("data-theme");
document.documentElement.setAttribute("data-theme", "dark");
const tokensDark = readTokens();
if (prev) document.documentElement.setAttribute("data-theme", prev);
else document.documentElement.removeAttribute("data-theme");
const walk = (el) => {
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template"].includes(tag)) return null;
if (el.id === "svelte-announcer") return null;
if (el.hasAttribute("hidden")) return null;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return null;
let ownText = "";
for (const c of el.childNodes)
if (c.nodeType === 3) ownText += c.textContent;
ownText = ownText.replace(/\s+/g, " ").trim();
const node = {
tag,
text: ownText,
style: sig(el),
rect: rectOf(el),
children: [],
};
if (tag === "svg") return node;
for (const child of el.children) {
const cn = walk(child);
if (cn) node.children.push(cn);
}
return node;
const walk = (el) => {
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template"].includes(tag))
return null;
if (el.id === "svelte-announcer") return null;
if (el.hasAttribute("hidden")) return null;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return null;
let ownText = "";
for (const c of el.childNodes)
if (c.nodeType === 3) ownText += c.textContent;
ownText = ownText.replace(/\s+/g, " ").trim();
const node = {
tag,
text: ownText,
style: sig(el),
rect: rectOf(el),
children: [],
};
return { tokensLight, tokensDark, tree: walk(document.body) };
},
STYLE_FIELDS,
);
if (tag === "svg") return node;
for (const child of el.children) {
const cn = walk(child);
if (cn) node.children.push(cn);
}
return node;
};
return { tokensLight, tokensDark, tree: walk(document.body) };
}, STYLE_FIELDS);
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
@@ -284,12 +283,26 @@ function compareStyle(ours, ref, path, out) {
const aNode = ours.children[i];
const cands = bByKey.get(keyOf(aNode));
let bIdx = -1;
if (cands) for (const ci of cands) if (!used.has(ci)) { bIdx = ci; break; }
if (cands)
for (const ci of cands)
if (!used.has(ci)) {
bIdx = ci;
break;
}
if (bIdx >= 0) {
used.add(bIdx);
compareStyle(aNode, ref.children[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
compareStyle(
aNode,
ref.children[bIdx],
`${path} > ${aNode.tag}:${i + 1}`,
out,
);
} else {
out.push({ path: `${path} > ${aNode.tag}:${i + 1}`, type: "added", tag: aNode.tag });
out.push({
path: `${path} > ${aNode.tag}:${i + 1}`,
type: "added",
tag: aNode.tag,
});
}
}
for (let j = 0; j < ref.children.length; j++) {
@@ -425,7 +438,9 @@ function toMarkdown(reports) {
: "_расхождений стилей нет_";
const struct = r.deltas
.filter((d) => d.type !== "style")
.map((d) => `- \`${d.path}\` — **${d.type}**${d.tag ? ` (${d.tag})` : ""}`)
.map(
(d) => `- \`${d.path}\` — **${d.type}**${d.tag ? ` (${d.tag})` : ""}`,
)
.join("\n");
return (
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
@@ -480,7 +495,11 @@ async function main() {
mkdirSync(resolve(WEB, "audit"), { recursive: true });
writeFileSync(
resolve(WEB, "audit/audit-report.json"),
JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2),
JSON.stringify(
{ generatedAt: new Date().toISOString(), reports },
null,
2,
),
);
writeFileSync(resolve(WEB, "audit/audit-report.md"), toMarkdown(reports));
console.log(`report: web/audit/audit-report.md`);
@@ -489,7 +508,9 @@ async function main() {
if (server) {
try {
if (process.platform === "win32")
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], { stdio: "ignore" });
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
stdio: "ignore",
});
else server.kill("SIGTERM");
} catch {}
}
+26 -11
View File
@@ -143,7 +143,8 @@ const snapshot = (page) =>
page.evaluate(() => {
const walk = (el) => {
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template"].includes(tag)) return null;
if (["script", "style", "noscript", "template"].includes(tag))
return null;
// SvelteKit ін'єктить #svelte-announcer (aria-live) прямо в <body>;
// у рефа (Next.js) його немає — це фреймворк-шум, не збіг дизайну.
if (el.id === "svelte-announcer") return null;
@@ -189,9 +190,7 @@ const normTag = (tag) => (STRUCT_TAGS.has(tag) ? "box" : tag);
// текст — иначе малейшее отличие текста в обёртке каскадом роняет всё поддерево.
// Листья (нет дочерних элементов) матчим по тегу + тексту.
const keyOf = (n) =>
n.children.length === 0
? `${normTag(n.tag)}|${n.ownText}`
: normTag(n.tag);
n.children.length === 0 ? `${normTag(n.tag)}|${n.ownText}` : normTag(n.tag);
function subtreeCount(node) {
let c = 1;
@@ -233,7 +232,12 @@ function matchChildren(ac, bc, path, out) {
const k = keyOf(aNode);
const cands = bByKey.get(k);
let bIdx = -1;
if (cands) for (const ci of cands) if (!used.has(ci)) { bIdx = ci; break; }
if (cands)
for (const ci of cands)
if (!used.has(ci)) {
bIdx = ci;
break;
}
if (bIdx >= 0) {
used.add(bIdx);
diffNodes(aNode, bc[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
@@ -254,7 +258,14 @@ function diff(ours, ref, path, out) {
}
function tally(deltas) {
const c = { added: 0, removed: 0, tagMismatch: 0, textMismatch: 0, nodesAdded: 0, nodesRemoved: 0 };
const c = {
added: 0,
removed: 0,
tagMismatch: 0,
textMismatch: 0,
nodesAdded: 0,
nodesRemoved: 0,
};
for (const d of deltas) {
if (d.type in c) c[d.type]++;
if (d.type === "added") c.nodesAdded += d.nodes ?? 1;
@@ -317,10 +328,10 @@ function toMarkdown(reports) {
return `- \`${d.path}\` **text**: ours \`${d.ours}\` → ref \`${d.ref}\``;
if (d.type === "tagMismatch")
return `- \`${d.path}\` **tag**: ours \`${d.ours}\` → ref \`${d.ref}\``;
if (d.type === "added")
return `- \`${d.path}\` **added** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
if (d.type === "removed")
return `- \`${d.path}\` **removed** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
if (d.type === "added")
return `- \`${d.path}\` **added** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
if (d.type === "removed")
return `- \`${d.path}\` **removed** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
return "";
})
.join("\n")
@@ -376,7 +387,11 @@ async function main() {
mkdirSync(resolve(WEB, "audit"), { recursive: true });
writeFileSync(
resolve(WEB, "audit/dom-report.json"),
JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2),
JSON.stringify(
{ generatedAt: new Date().toISOString(), reports },
null,
2,
),
);
writeFileSync(resolve(WEB, "audit/dom-report.md"), toMarkdown(reports));
console.log(`report: web/audit/dom-report.md`);
+125
View File
@@ -0,0 +1,125 @@
// Token parity check: every color token defined in `:root` (light theme) of the
// design CSS must also be defined in `[data-theme="dark"]`, and vice versa. A
// color that exists only in one theme silently breaks dark mode.
//
// Usage: pnpm --dir web exec node scripts/check-token-parity.mjs
// Exit code 1 (and a report) when tokens are missing.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import postcss from "postcss";
const FILE = new URL("../src/preview.css", import.meta.url);
const SRC_DIR = fileURLToPath(new URL("../src/", import.meta.url));
// A var(--x) REFERENCE anywhere in the app (primary argument only).
const USAGE_RE = /var\(\s*(--[\w-]+)/g;
const SCAN_EXTS = new Set([".svelte", ".css", ".ts", ".js", ".mjs"]);
// Recursively list source files; no glob dependency needed.
function listFiles(dir) {
const files = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
files.push(...listFiles(full));
} else if (SCAN_EXTS.has(name.slice(name.lastIndexOf(".")))) {
files.push(full);
}
}
return files;
}
// Collect every token referenced via var() across all source files.
function collectUsedTokens() {
const used = new Set();
for (const file of listFiles(SRC_DIR)) {
const content = readFileSync(file, "utf8");
for (const match of content.matchAll(USAGE_RE)) {
used.add(match[1]);
}
}
return used;
}
// A token is "colorful" when its value is a color literal — those must have a
// dark-mate. Non-color tokens (fonts, radii, durations) are exempt.
const COLOR_RE =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(/i;
const isColorValue = (value) => COLOR_RE.test(value);
// A token is "derived" when its value references var(...) — it adapts to the
// theme automatically (e.g. --brand-alt: oklch(from var(--brand-main) ...)),
// so it must NOT have a hardcoded dark twin.
const isDerived = (value) => value.includes("var(");
// Collect { token: value } custom properties defined inside a given selector.
function collectTokens(root, selector) {
const map = new Map();
root.walkRules((rule) => {
if (rule.selector === selector) {
rule.walkDecls((decl) => {
if (decl.prop.startsWith("--")) map.set(decl.prop, decl.value);
});
}
});
return map;
}
async function main() {
const css = await readFile(FILE, "utf8");
const root = postcss.parse(css);
const light = collectTokens(root, ":root");
const dark = collectTokens(root, '[data-theme="dark"]');
const errors = [];
// Every colorful light token must have a dark-mate (derived tokens excluded).
for (const [token, value] of light) {
if (!isColorValue(value) || isDerived(value)) continue;
if (!dark.has(token)) {
errors.push(` ${token} in :root has no [data-theme="dark"] override`);
}
}
// Every dark token must exist in :root (a dark-only token is orphaned).
for (const [token] of dark) {
if (!light.has(token)) {
errors.push(` ${token} in [data-theme="dark"] is missing in :root`);
}
}
if (errors.length > 0) {
console.log(`Token parity violations in ${FILE}:\n${errors.join("\n")}`);
process.exitCode = 1;
} else {
console.log(
"Token parity: OK — all color tokens have both light/dark forms.",
);
}
// Unused design tokens: defined in preview.css but never used anywhere in
// src via var(). A dead token is not the single source of truth — it's dust.
const defined = new Set();
root.walkDecls((decl) => {
if (decl.prop.startsWith("--")) defined.add(decl.prop);
});
const used = collectUsedTokens();
const unused = [...defined].filter((token) => !used.has(token));
if (unused.length > 0) {
console.log("\nUnused tokens (defined in preview.css, never used):");
for (const token of unused) console.log(` ${token}`);
} else {
console.log("All preview.css tokens are used somewhere.");
}
}
main().catch((error) => {
console.log(error);
process.exitCode = 1;
});
+49
View File
@@ -0,0 +1,49 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
const node = process.execPath;
// (eslint, [args]) — every lint layer of the redesign, in one command.
// Each step runs even if a previous one fails; exit code is nonzero if any.
const steps = [
{
name: "ESLint (svelte) + design-tokens rules",
args: [
fileURLToPath(
new URL("../node_modules/eslint/bin/eslint.js", import.meta.url),
),
".",
],
},
{
name: "Stylelint (css) — design-token style",
args: [
fileURLToPath(
new URL("../node_modules/stylelint/bin/stylelint.mjs", import.meta.url),
),
"./src/**/*.css",
],
},
{
name: "Token audit (parity + unused)",
args: [fileURLToPath(new URL("check-token-parity.mjs", import.meta.url))],
},
];
let failed = false;
for (const [index, step] of steps.entries()) {
console.log(`\n[${index + 1}/${steps.length}] ${step.name}`);
const result = spawnSync(node, step.args, {
cwd: WEB_DIR,
stdio: "inherit",
});
if (result.status !== 0) failed = true;
}
console.log(
failed
? "\nlint-all: FAILED — fix the reported problems and re-run."
: "\nlint-all: all checks passed.",
);
process.exit(failed ? 1 : 0);