chore: update css rules, add hct plugin

This commit is contained in:
2026-09-02 09:51:14 +05:00
parent 1310c51543
commit 98a4bacd23
13 changed files with 690 additions and 152 deletions
+27
View File
@@ -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;
}
+77
View File
@@ -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"]'),
};
}
+27
View File
@@ -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;
}
+14
View File
@@ -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));
}