chore: update css rules, add hct plugin
This commit is contained in:
@@ -91,18 +91,28 @@ scoped-путям (`kit/**`, `preview/**`).
|
|||||||
|
|
||||||
- `pnpm --dir web lint:css` — stylelint по всем `src/**/*.css` (`old.css` и
|
- `pnpm --dir web lint:css` — stylelint по всем `src/**/*.css` (`old.css` и
|
||||||
`node_modules/build/.svelte-kit/static` игнорируются).
|
`node_modules/build/.svelte-kit/static` игнорируются).
|
||||||
- `pnpm --dir web exec node scripts/check-token-parity.mjs` — каждый цветовой
|
- `pnpm --dir web exec node scripts/check-tokens.mjs` — токен-аудит по
|
||||||
|
preview.css (лёгкий оркестратор поверх `web/scripts/token-audit/*`);
|
||||||
|
проверяет три вещи. Каждый цветовой
|
||||||
токен из `:root` обязан иметь пару в `[data-theme="dark"]` и наоборот.
|
токен из `:root` обязан иметь пару в `[data-theme="dark"]` и наоборот.
|
||||||
Производные токены (значение содержит `var()`, напр. `oklch(from var(--...))`)
|
Производные токены (значение содержит `var()`, напр. `hct(from var(--...))`)
|
||||||
из пары исключены — они наследуют тему автоматически. Дополнительно выводит
|
из пары исключены — они наследуют тему автоматически. Выводит **варнинг** о
|
||||||
**варнинг** о неиспользуемых токенах preview.css (определены, но нигде не
|
неиспользуемых токенах preview.css (определены, но нигде не используются) —
|
||||||
используются) — выход не меняется (only parity = exit 1).
|
выход он не меняет. Провалом (exit 1) считаются только parity и hct-авторство.
|
||||||
|
- Тот же скрипт проверяет **hct-only для цветов**: ЛЮБОЕ цветовое значение в
|
||||||
|
preview.css обязано быть `hct(...)` — и литерал, и производное
|
||||||
|
`hct(from var(...) h c t)` (вся палитра считается через hct-каналы; эммит в
|
||||||
|
sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`). Исключения:
|
||||||
|
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
|
||||||
|
`color-mix(...)` (единственный легальный способ смешать два токена).
|
||||||
|
`oklch()/rgb()/#hex` в `--color-*` запрещены.
|
||||||
|
|
||||||
Токены-префиксы (целевой словарь дизайна):
|
Токены-префиксы (целевой словарь дизайна):
|
||||||
|
|
||||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные
|
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные
|
||||||
две переменные, которым разрешено быть hex/rgb, остальные цвета — только
|
две переменные, которым разрешено быть hex/rgb, остальные цвета — только
|
||||||
`oklch()` и только в preview.css.
|
`hct()` (эммит в sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`)
|
||||||
|
и только в preview.css.
|
||||||
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
||||||
- Брейкпоинты: `--bp-mobile` (640px), `--bp-tablet` (800px), `--bp-desktop`
|
- Брейкпоинты: `--bp-mobile` (640px), `--bp-tablet` (800px), `--bp-desktop`
|
||||||
(1100px) — mobile-first.
|
(1100px) — mobile-first.
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:css": "stylelint \"./src/**/*.css\"",
|
"lint:css": "stylelint \"./src/**/*.css\"",
|
||||||
"lint:all": "node scripts/lint-all.mjs",
|
"lint:all": "node scripts/lint-all.mjs",
|
||||||
"lint:tokens": "node scripts/check-token-parity.mjs",
|
"lint:tokens": "node scripts/check-tokens.mjs",
|
||||||
"refs-cdp-audit": "node scripts/audit-cdp.mjs",
|
"refs-cdp-audit": "node scripts/audit-cdp.mjs",
|
||||||
"refs-cdp-audit:large": "node scripts/audit-cdp.mjs --viewport 2560x1440",
|
"refs-cdp-audit:large": "node scripts/audit-cdp.mjs --viewport 2560x1440",
|
||||||
"refs-cdp-audit:responsive": "node scripts/audit-cdp-responsive.mjs",
|
"refs-cdp-audit:responsive": "node scripts/audit-cdp-responsive.mjs",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@panmdaa/colors": "^0.4.2",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
|||||||
Generated
+9
@@ -21,6 +21,9 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@10.9.0(supports-color@10.2.2))
|
version: 10.0.1(eslint@10.9.0(supports-color@10.2.2))
|
||||||
|
'@panmdaa/colors':
|
||||||
|
specifier: ^0.4.2
|
||||||
|
version: 0.4.2
|
||||||
'@sveltejs/adapter-static':
|
'@sveltejs/adapter-static':
|
||||||
specifier: ^3.0.10
|
specifier: ^3.0.10
|
||||||
version: 3.0.10(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1))(svelte@5.56.9(@typescript-eslint/types@8.67.0))(typescript@6.0.3)(vite@8.2.1))
|
version: 3.0.10(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1))(svelte@5.56.9(@typescript-eslint/types@8.67.0))(typescript@6.0.3)(vite@8.2.1))
|
||||||
@@ -255,6 +258,10 @@ packages:
|
|||||||
'@oxc-project/types@0.144.0':
|
'@oxc-project/types@0.144.0':
|
||||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||||
|
|
||||||
|
'@panmdaa/colors@0.4.2':
|
||||||
|
resolution: {integrity: sha512-6dLYuqV3u7mFYK8gCMsMU1SKiL/H52WygYMSoDQz/iAMs1l8+EKxFdsApPAGSV+VeCUzYT3sIDYKr1WiXsilDw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
@@ -1702,6 +1709,8 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/types@0.144.0': {}
|
'@oxc-project/types@0.144.0': {}
|
||||||
|
|
||||||
|
'@panmdaa/colors@0.4.2': {}
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.4':
|
'@rolldown/binding-android-arm64@1.2.4':
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import postcssHct from "./scripts/postcss-hct.mjs";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [postcssHct],
|
||||||
|
};
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
// 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;
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Design-token audit, one command: theme parity, hct-only color authorship and
|
||||||
|
// unused-token warnings over src/preview.css.
|
||||||
|
//
|
||||||
|
// Usage: pnpm --dir web exec node scripts/check-tokens.mjs
|
||||||
|
// Exit code 1 when a failing check (parity or color authorship) reports.
|
||||||
|
|
||||||
|
import { FILE, parsePreview } from "./token-audit/helpers.mjs";
|
||||||
|
import { checkParity } from "./token-audit/parity.mjs";
|
||||||
|
import { checkColorAuthorship } from "./token-audit/colors.mjs";
|
||||||
|
import { checkUnused } from "./token-audit/unused.mjs";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { root, light, dark } = await parsePreview();
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
const parityErrors = checkParity({ light, dark });
|
||||||
|
if (parityErrors.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`Token parity violations in ${FILE}:\n${parityErrors.join("\n")}`,
|
||||||
|
);
|
||||||
|
failed = true;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Token parity: OK — all color tokens have both light/dark forms.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorErrors = checkColorAuthorship(root);
|
||||||
|
if (colorErrors.length > 0) {
|
||||||
|
console.log(`Non-hct color values in ${FILE}:\n${colorErrors.join("\n")}`);
|
||||||
|
failed = true;
|
||||||
|
} else {
|
||||||
|
console.log("All colors are authored as hct().");
|
||||||
|
}
|
||||||
|
|
||||||
|
const unused = checkUnused(root);
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+29
-17
@@ -4,30 +4,42 @@ import { fileURLToPath } from "node:url";
|
|||||||
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
|
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
|
||||||
const node = process.execPath;
|
const node = process.execPath;
|
||||||
|
|
||||||
// (eslint, [args]) — every lint layer of the redesign, in one command.
|
// Every lint layer of the redesign, in one command. Each step runs even if a
|
||||||
// Each step runs even if a previous one fails; exit code is nonzero if any.
|
// previous one fails; the exit code is nonzero if any did.
|
||||||
|
//
|
||||||
|
// Stream wiring: children run with stdio ["ignore", 1, 1] — both stdout and
|
||||||
|
// stderr are bound to OUR stdout file descriptor (shell `2>&1`). It is real FD
|
||||||
|
// inheritance, no pipes or buffering, so `> css-lint.txt` catches every byte.
|
||||||
|
// Color decisions are then the tool's own against the actual destination:
|
||||||
|
// a terminal gets colors, a redirected file gets plain text.
|
||||||
|
//
|
||||||
|
// One quirk: stylelint prints its report to stderr and paints it even when the
|
||||||
|
// destination is not a TTY. So for a non-TTY destination we pass its official
|
||||||
|
// --no-color explicitly.
|
||||||
|
const colorFlag = process.stdout.isTTY ? [] : ["--no-color"];
|
||||||
|
|
||||||
|
const bins = {
|
||||||
|
eslint: fileURLToPath(
|
||||||
|
new URL("../node_modules/eslint/bin/eslint.js", import.meta.url),
|
||||||
|
),
|
||||||
|
stylelint: fileURLToPath(
|
||||||
|
new URL("../node_modules/stylelint/bin/stylelint.mjs", import.meta.url),
|
||||||
|
),
|
||||||
|
checkTokens: fileURLToPath(new URL("check-tokens.mjs", import.meta.url)),
|
||||||
|
};
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
name: "ESLint (svelte) + design-tokens rules",
|
name: "ESLint (svelte) + design-tokens rules",
|
||||||
args: [
|
args: [bins.eslint, ...colorFlag, "."],
|
||||||
fileURLToPath(
|
|
||||||
new URL("../node_modules/eslint/bin/eslint.js", import.meta.url),
|
|
||||||
),
|
|
||||||
".",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Stylelint (css) — design-token style",
|
name: "Stylelint (css) — design-token style",
|
||||||
args: [
|
args: [bins.stylelint, ...colorFlag, "./src/**/*.css"],
|
||||||
fileURLToPath(
|
|
||||||
new URL("../node_modules/stylelint/bin/stylelint.mjs", import.meta.url),
|
|
||||||
),
|
|
||||||
"./src/**/*.css",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Token audit (parity + unused)",
|
name: "Token audit (parity + colors + unused)",
|
||||||
args: [fileURLToPath(new URL("check-token-parity.mjs", import.meta.url))],
|
args: [bins.checkTokens],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -36,7 +48,7 @@ for (const [index, step] of steps.entries()) {
|
|||||||
console.log(`\n[${index + 1}/${steps.length}] ${step.name}`);
|
console.log(`\n[${index + 1}/${steps.length}] ${step.name}`);
|
||||||
const result = spawnSync(node, step.args, {
|
const result = spawnSync(node, step.args, {
|
||||||
cwd: WEB_DIR,
|
cwd: WEB_DIR,
|
||||||
stdio: "inherit",
|
stdio: ["ignore", 1, 1],
|
||||||
});
|
});
|
||||||
if (result.status !== 0) failed = true;
|
if (result.status !== 0) failed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct() and
|
||||||
|
// emits plain sRGB hex colors.
|
||||||
|
//
|
||||||
|
// Supported syntax (Material Design 3 HCT; hue 0–360, chroma 0–~120, tone 0–100):
|
||||||
|
//
|
||||||
|
// --color-success: hct(155 40 55); // literal
|
||||||
|
// --color-muted: hct(from var(--brand-main) h 6 97); // take hue from seed
|
||||||
|
// --brand-alt: hct(from var(--brand-main) h c calc(t + 36)); // channel math
|
||||||
|
//
|
||||||
|
// `from` seeds are resolved from sibling custom properties in the same
|
||||||
|
// stylesheet (:root tokens plus the current rule's own tokens). Seeds may be
|
||||||
|
// #hex, oklch(), rgb() or another hct(). Anything the plugin cannot resolve is
|
||||||
|
// left as-is. The emitter is sRGB hex; HCT stays the single source of truth.
|
||||||
|
|
||||||
|
import { Color } from "@panmdaa/colors";
|
||||||
|
|
||||||
|
const HCT_RE = /\bhct\(/;
|
||||||
|
const VAR_RE = /^var\(\s*(--[\w-]+)\s*\)$/;
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapHue(degrees) {
|
||||||
|
return ((degrees % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- postcss plugin entry -------------------------------------------------
|
||||||
|
|
||||||
|
const postcssHct = {
|
||||||
|
postcssPlugin: "postcss-hct",
|
||||||
|
Once(root, { result }) {
|
||||||
|
// Fallback environment: tokens from :root (highest-level definitions).
|
||||||
|
const rootEnv = collectTokensFromSelector(root, ":root");
|
||||||
|
|
||||||
|
root.walkRules((rule) => {
|
||||||
|
const env = new Map(rootEnv);
|
||||||
|
const decls = [];
|
||||||
|
rule.nodes?.forEach((node) => {
|
||||||
|
if (node.type === "decl" && node.prop.startsWith("--")) {
|
||||||
|
env.set(node.prop, node.value);
|
||||||
|
decls.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const decl of decls) {
|
||||||
|
if (HCT_RE.test(decl.value)) {
|
||||||
|
const next = replaceHctValues(decl, decl.value, env, result);
|
||||||
|
if (next !== decl.value) {
|
||||||
|
decl.value = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectTokensFromSelector(root, selector) {
|
||||||
|
const tokens = new Map();
|
||||||
|
root.walkRules((rule) => {
|
||||||
|
if (
|
||||||
|
typeof rule.selector === "string" &&
|
||||||
|
rule.selector
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.includes(selector)
|
||||||
|
) {
|
||||||
|
rule.walkDecls((decl) => {
|
||||||
|
if (decl.prop.startsWith("--")) tokens.set(decl.prop, decl.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- value scanning -------------------------------------------------------
|
||||||
|
|
||||||
|
function findBalancedClosing(text, openParenIndex) {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = openParenIndex; i < text.length; i++) {
|
||||||
|
if (text[i] === "(") depth++;
|
||||||
|
else if (text[i] === ")") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceHctValues(node, value, env, result) {
|
||||||
|
let out = "";
|
||||||
|
let cursor = 0;
|
||||||
|
for (;;) {
|
||||||
|
const start = value.indexOf("hct(", cursor);
|
||||||
|
if (start === -1) {
|
||||||
|
out += value.slice(cursor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const end = findBalancedClosing(value, start + 3);
|
||||||
|
if (end === -1) {
|
||||||
|
out += value.slice(cursor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out += value.slice(cursor, start);
|
||||||
|
const inner = value.slice(start + 4, end);
|
||||||
|
const hct = resolveHctBody(inner, env, new Set());
|
||||||
|
if (hct) {
|
||||||
|
out += Color.fromHct(hct.h, hct.c, hct.t).toHexColor();
|
||||||
|
} else {
|
||||||
|
result.warn(`postcss-hct: could not resolve hct(${inner})`, { node });
|
||||||
|
out += value.slice(start, end + 1);
|
||||||
|
}
|
||||||
|
cursor = end + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hct() body parsing ---------------------------------------------------
|
||||||
|
|
||||||
|
// Resolve the body of one hct(...) call to { h, c, t } or null.
|
||||||
|
function resolveHctBody(inner, env, seen) {
|
||||||
|
const text = inner.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
if (text.startsWith("from ")) {
|
||||||
|
const rest = text.slice(5).trimStart();
|
||||||
|
if (!rest) return null;
|
||||||
|
|
||||||
|
const scan = readSourceToken(rest);
|
||||||
|
if (!scan) return null;
|
||||||
|
const { source, remainder } = scan;
|
||||||
|
|
||||||
|
const seed = resolveSeed(source, env, seen);
|
||||||
|
if (!seed) return null;
|
||||||
|
|
||||||
|
const parts = splitTopLevel(remainder, " ");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const h = safeNumber(evalComponent(parts[0], seed));
|
||||||
|
const c = safeNumber(evalComponent(parts[1], seed));
|
||||||
|
const t = safeNumber(evalComponent(parts[2], seed));
|
||||||
|
if (h === null || c === null || t === null) return null;
|
||||||
|
return {
|
||||||
|
h: wrapHue(h),
|
||||||
|
c: Math.max(0, c),
|
||||||
|
t: clamp(t, 0, 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Literal: hct(h c t)
|
||||||
|
const parts = splitTopLevel(text, " ");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const nums = parts.map((p) => Number.parseFloat(p));
|
||||||
|
if (nums.some((n) => !Number.isFinite(n))) return null;
|
||||||
|
return {
|
||||||
|
h: wrapHue(nums[0]),
|
||||||
|
c: Math.max(0, nums[1]),
|
||||||
|
t: clamp(nums[2], 0, 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNumber(value) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read "var(--x)", "hct(...)", a color function or a bare token off the front.
|
||||||
|
function readSourceToken(text) {
|
||||||
|
const first = text[0];
|
||||||
|
if (first === "v") {
|
||||||
|
const match = text.match(/^var\(\s*--[\w-]+\s*\)/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
source: match[0],
|
||||||
|
remainder: text.slice(match[0].length).trimStart(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (first === "(" || first === "#" || /[a-z]/i.test(first)) {
|
||||||
|
const match = text.match(/^[a-z]+\(/i);
|
||||||
|
if (match) {
|
||||||
|
const end = findBalancedClosing(text, text.indexOf("("));
|
||||||
|
if (end === -1) return null;
|
||||||
|
return {
|
||||||
|
source: text.slice(0, end + 1),
|
||||||
|
remainder: text.slice(end + 1).trimStart(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bare = text.match(/^(#[\w.]+|-?[\d.]+)/i);
|
||||||
|
if (!bare) return null;
|
||||||
|
return {
|
||||||
|
source: bare[1],
|
||||||
|
remainder: text.slice(bare[1].length).trimStart(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on a delimiter, ignoring delimiters inside balanced parentheses.
|
||||||
|
function splitTopLevel(text, delimiter) {
|
||||||
|
const parts = [];
|
||||||
|
let depth = 0;
|
||||||
|
let current = "";
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (ch === "(") depth++;
|
||||||
|
else if (ch === ")") depth--;
|
||||||
|
if (ch === delimiter && depth === 0) {
|
||||||
|
if (current.trim()) parts.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.trim()) parts.push(current.trim());
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- seed resolution ------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveSeed(source, env, seen) {
|
||||||
|
if (VAR_RE.test(source.trim())) {
|
||||||
|
const name = source.trim().match(VAR_RE)[1];
|
||||||
|
const raw = env.get(name);
|
||||||
|
if (raw === undefined) return null;
|
||||||
|
if (seen.has(name)) return null; // circular reference
|
||||||
|
seen.add(name);
|
||||||
|
return resolveRawValue(raw, env, seen);
|
||||||
|
}
|
||||||
|
return parseColorValue(source.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRawValue(raw, env, seen) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text.startsWith("hct(") && text.endsWith(")")) {
|
||||||
|
const inner = text.slice(4, -1);
|
||||||
|
return resolveHctBody(inner, env, seen);
|
||||||
|
}
|
||||||
|
return parseColorValue(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a concrete color into HCT channels, or null.
|
||||||
|
function parseColorValue(text) {
|
||||||
|
const value = text.trim();
|
||||||
|
if (/^#[0-9a-f]{3}$/i.test(value)) {
|
||||||
|
return channelsFromHex(expandShortHex(value));
|
||||||
|
}
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(value)) {
|
||||||
|
return channelsFromHex(value);
|
||||||
|
}
|
||||||
|
// Split on whitespace; parseFloat drops "%"/"deg" units.
|
||||||
|
const oklch = value.match(/^oklch\(([^)]+)\)/i);
|
||||||
|
if (oklch) {
|
||||||
|
const channels = oklch[1]
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((ch) => Number.parseFloat(ch));
|
||||||
|
if (channels.length !== 3 || channels.some((n) => !Number.isFinite(n)))
|
||||||
|
return null;
|
||||||
|
const [L, C, h] = channels;
|
||||||
|
const hex = oklchToHex(L > 1 ? L / 100 : L, C, h);
|
||||||
|
if (hex) return channelsFromHex(hex);
|
||||||
|
}
|
||||||
|
const rgb = value.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
||||||
|
if (rgb) {
|
||||||
|
const [r, g, b] = rgb.slice(1).map(Number);
|
||||||
|
return channelsFromHex(hexFromRgb(r, g, b));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelsFromHex(hex) {
|
||||||
|
try {
|
||||||
|
const color = Color.from(hex);
|
||||||
|
return { h: color.hue, c: color.chroma, t: color.tone };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandShortHex(hex) {
|
||||||
|
return (
|
||||||
|
"#" +
|
||||||
|
hex
|
||||||
|
.slice(1)
|
||||||
|
.split("")
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oklchToHex(L, C, hDeg) {
|
||||||
|
const hr = (hDeg * Math.PI) / 180;
|
||||||
|
const a = C * Math.cos(hr);
|
||||||
|
const b = C * Math.sin(hr);
|
||||||
|
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
||||||
|
const l = l_ ** 3;
|
||||||
|
const m = m_ ** 3;
|
||||||
|
const s = s_ ** 3;
|
||||||
|
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
||||||
|
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
||||||
|
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
||||||
|
return hexFromRgb(r * 255, g * 255, bl * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexFromRgb(r, g, b) {
|
||||||
|
const toByte = (v) =>
|
||||||
|
clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0");
|
||||||
|
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- channel arithmetic ---------------------------------------------------
|
||||||
|
|
||||||
|
// Evaluate one hct() component: a channel ref (h/c/t), a number, or a calc()
|
||||||
|
// expression over the seed channels.
|
||||||
|
function evalComponent(text, seed) {
|
||||||
|
const value = text.trim();
|
||||||
|
if (value === "h" || value === "c" || value === "t") {
|
||||||
|
return seed[value];
|
||||||
|
}
|
||||||
|
if (/^[-+]?[\d.]/.test(value)) {
|
||||||
|
// unit-bearing number like "36deg"
|
||||||
|
return Number.parseFloat(value);
|
||||||
|
}
|
||||||
|
return evaluateExpr(value, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tiny math evaluator for calc()/min()/max()/clamp() ---------------------
|
||||||
|
|
||||||
|
const MATH_FUNCS = new Set(["calc", "min", "max", "clamp"]);
|
||||||
|
|
||||||
|
// Lex and evaluate a calc() expression. Supported: numbers, + - * /,
|
||||||
|
// parentheses, channel refs (h/c/t) and the calc/min/max/clamp functions.
|
||||||
|
function evaluateExpr(text, seed) {
|
||||||
|
return new ExprParser(text, seed).parse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeExpression(text) {
|
||||||
|
const tokens = [];
|
||||||
|
const re = /(\d+(?:\.\d+)?|\.\d+|[+\-*/(),]|[a-z]+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(text)) !== null) {
|
||||||
|
tokens.push(match[1]);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExprParser {
|
||||||
|
constructor(text, seed) {
|
||||||
|
this.tokens = tokenizeExpression(text);
|
||||||
|
this.pos = 0;
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
peek() {
|
||||||
|
return this.tokens[this.pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
return this.tokens[this.pos++];
|
||||||
|
}
|
||||||
|
|
||||||
|
parse() {
|
||||||
|
const value = this.parseAdditive();
|
||||||
|
return this.pos === this.tokens.length ? value : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseAdditive() {
|
||||||
|
let value = this.parseMulDiv();
|
||||||
|
for (;;) {
|
||||||
|
const op = this.peek();
|
||||||
|
if (op !== "+" && op !== "-") break;
|
||||||
|
this.pos++;
|
||||||
|
const rhs = this.parseMulDiv();
|
||||||
|
value = op === "+" ? value + rhs : value - rhs;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseMulDiv() {
|
||||||
|
let value = this.parseAtom();
|
||||||
|
for (;;) {
|
||||||
|
const op = this.peek();
|
||||||
|
if (op !== "*" && op !== "/") break;
|
||||||
|
this.pos++;
|
||||||
|
const rhs = this.parseAtom();
|
||||||
|
value = op === "*" ? value * rhs : value / rhs;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseAtom() {
|
||||||
|
const token = this.next();
|
||||||
|
if (token === undefined) return NaN;
|
||||||
|
if (token === "h" || token === "c" || token === "t") {
|
||||||
|
return this.seed[token];
|
||||||
|
}
|
||||||
|
if (/^\d/.test(token)) return Number.parseFloat(token);
|
||||||
|
if (token === "-") return -this.parseAtom();
|
||||||
|
if (token === "+") return this.parseAtom();
|
||||||
|
if (token === "(") {
|
||||||
|
const value = this.parseAdditive();
|
||||||
|
if (this.next() !== ")") return NaN;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (MATH_FUNCS.has(token)) {
|
||||||
|
return this.parseFunction(token);
|
||||||
|
}
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseFunction(name) {
|
||||||
|
if (this.next() !== "(") return NaN;
|
||||||
|
const args = [this.parseAdditive()];
|
||||||
|
while (this.peek() === ",") {
|
||||||
|
this.pos++;
|
||||||
|
args.push(this.parseAdditive());
|
||||||
|
}
|
||||||
|
if (this.next() !== ")") return NaN;
|
||||||
|
if (name === "calc") return args[0];
|
||||||
|
if (name === "min") return Math.min(...args);
|
||||||
|
if (name === "max") return Math.max(...args);
|
||||||
|
if (name === "clamp") {
|
||||||
|
const [min, value, max] = args;
|
||||||
|
return clamp(value, min, max);
|
||||||
|
}
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default postcssHct;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Color authorship: every color value in preview.css must be authored as hct()
|
||||||
|
// — literals AND derived forms (hct(from var(...) h c t) with channel math) —
|
||||||
|
// so the whole palette is computed through HCT channels and output as sRGB by
|
||||||
|
// the postcss-hct plugin. Exceptions: the seed tokens --brand-main/--brand-alt
|
||||||
|
// (any format allowed) and color-mix(...) (the only sanctioned way to blend
|
||||||
|
// two tokens).
|
||||||
|
|
||||||
|
import { isColorValue } from "./helpers.mjs";
|
||||||
|
|
||||||
|
const BRAND_RE = /^--brand-(main|alt)$/;
|
||||||
|
const HCT_RE = /^hct\s*\(/i;
|
||||||
|
const COLOR_MIX_RE = /^color-mix\s*\(/i;
|
||||||
|
|
||||||
|
export function checkColorAuthorship(root) {
|
||||||
|
const errors = [];
|
||||||
|
root.walkDecls((decl) => {
|
||||||
|
if (!decl.prop.startsWith("--")) return;
|
||||||
|
if (BRAND_RE.test(decl.prop)) return;
|
||||||
|
if (!isColorValue(decl.value)) return;
|
||||||
|
if (HCT_RE.test(decl.value) || COLOR_MIX_RE.test(decl.value)) return;
|
||||||
|
const where = decl.parent?.selector || decl.parent?.name || "";
|
||||||
|
errors.push(
|
||||||
|
` ${decl.prop} (${where}): ${decl.value} — colors must be hct(); only --brand-main/--brand-alt may use other formats`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Shared plumbing for the design-token audits: reading preview.css, scanning
|
||||||
|
// for var() usages across src, and the color/form predicates the checks use.
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
export 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.
|
||||||
|
export 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|hct|color-mix)\s*\(/i;
|
||||||
|
|
||||||
|
export const isColorValue = (value) => COLOR_RE.test(value);
|
||||||
|
|
||||||
|
// A token is "derived" when its value references var(...) — it adapts to the
|
||||||
|
// theme automatically, so it must NOT have a hardcoded dark twin.
|
||||||
|
export const isDerived = (value) => value.includes("var(");
|
||||||
|
|
||||||
|
// Collect { token: value } custom properties defined inside a given selector.
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse preview.css once and hand out the postcss root plus both theme maps.
|
||||||
|
export async function parsePreview() {
|
||||||
|
const css = await readFile(FILE, "utf8");
|
||||||
|
const root = postcss.parse(css);
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
light: collectTokens(root, ":root"),
|
||||||
|
dark: collectTokens(root, '[data-theme="dark"]'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Theme parity: every color token in `:root` must also exist in
|
||||||
|
// `[data-theme="dark"]` and vice versa. A color defined in only one theme
|
||||||
|
// silently breaks dark mode. Derived tokens (values containing var()) adapt
|
||||||
|
// to the theme automatically and are exempt.
|
||||||
|
|
||||||
|
import { isColorValue, isDerived } from "./helpers.mjs";
|
||||||
|
|
||||||
|
export function checkParity({ light, dark }) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// Every colorful light token must have a dark-mate (derived 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Unused tokens: defined in preview.css but never referenced via var()
|
||||||
|
// anywhere in src. A dead token is not the single source of truth — it's dust.
|
||||||
|
// Reported as a warning; it does not fail the run.
|
||||||
|
|
||||||
|
import { collectUsedTokens } from "./helpers.mjs";
|
||||||
|
|
||||||
|
export function checkUnused(root) {
|
||||||
|
const defined = new Set();
|
||||||
|
root.walkDecls((decl) => {
|
||||||
|
if (decl.prop.startsWith("--")) defined.add(decl.prop);
|
||||||
|
});
|
||||||
|
const used = collectUsedTokens();
|
||||||
|
return [...defined].filter((token) => !used.has(token));
|
||||||
|
}
|
||||||
@@ -21,12 +21,12 @@ export default {
|
|||||||
// leading "--", so the regex must NOT start with --.
|
// leading "--", so the regex must NOT start with --.
|
||||||
// Regex: either an exact brand token or a prefixed token:
|
// Regex: either an exact brand token or a prefixed token:
|
||||||
// ^(brand-main|brand-alt)$ — brand exceptions
|
// ^(brand-main|brand-alt)$ — brand exceptions
|
||||||
// |^(color|space|text|radius|bp|font)- — prefixed
|
// |^(color|space|size|text|radius|bp|font)- — prefixed
|
||||||
"custom-property-pattern": [
|
"custom-property-pattern": [
|
||||||
"^(brand-main|brand-alt)$|^(color|space|text|radius|bp|font)-",
|
"^(brand-main|brand-alt)$|^(color|space|size|text|radius|bp|font)-",
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'"%s" must be prefixed: --color-*, --space-*, --text-*, --radius-*, --bp-*, --font-*; brand: only --brand-main/--brand-alt',
|
'"%s" must be prefixed: --color-*, --space-*, --size-*, --text-*, --radius-*, --bp-*, --font-*; brand: only --brand-main/--brand-alt',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Rule: !important is banned in CSS files. Overriding a look must happen
|
// Rule: !important is banned in CSS files. Overriding a look must happen
|
||||||
|
|||||||
Reference in New Issue
Block a user