From 98a4bacd23522934ea5f0a0b551c884a027c4192 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 2 Sep 2026 06:56:41 +0500 Subject: [PATCH] chore: update css rules, add hct plugin --- AGENTS.md | 22 +- web/package.json | 3 +- web/pnpm-lock.yaml | 9 + web/postcss.config.js | 5 + web/scripts/check-token-parity.mjs | 125 -------- web/scripts/check-tokens.mjs | 51 ++++ web/scripts/lint-all.mjs | 46 +-- web/scripts/postcss-hct.mjs | 430 ++++++++++++++++++++++++++++ web/scripts/token-audit/colors.mjs | 27 ++ web/scripts/token-audit/helpers.mjs | 77 +++++ web/scripts/token-audit/parity.mjs | 27 ++ web/scripts/token-audit/unused.mjs | 14 + web/stylelint.config.js | 6 +- 13 files changed, 690 insertions(+), 152 deletions(-) create mode 100644 web/postcss.config.js delete mode 100644 web/scripts/check-token-parity.mjs create mode 100644 web/scripts/check-tokens.mjs create mode 100644 web/scripts/postcss-hct.mjs create mode 100644 web/scripts/token-audit/colors.mjs create mode 100644 web/scripts/token-audit/helpers.mjs create mode 100644 web/scripts/token-audit/parity.mjs create mode 100644 web/scripts/token-audit/unused.mjs diff --git a/AGENTS.md b/AGENTS.md index 35202ed..a8caaa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,18 +91,28 @@ scoped-путям (`kit/**`, `preview/**`). - `pnpm --dir web lint:css` — stylelint по всем `src/**/*.css` (`old.css` и `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"]` и наоборот. - Производные токены (значение содержит `var()`, напр. `oklch(from var(--...))`) - из пары исключены — они наследуют тему автоматически. Дополнительно выводит - **варнинг** о неиспользуемых токенах preview.css (определены, но нигде не - используются) — выход не меняется (only parity = exit 1). + Производные токены (значение содержит `var()`, напр. `hct(from var(--...))`) + из пары исключены — они наследуют тему автоматически. Выводит **варнинг** о + неиспользуемых токенах preview.css (определены, но нигде не используются) — + выход он не меняет. Провалом (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` — единственные две переменные, которым разрешено быть hex/rgb, остальные цвета — только - `oklch()` и только в preview.css. + `hct()` (эммит в sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`) + и только в preview.css. - Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`. - Брейкпоинты: `--bp-mobile` (640px), `--bp-tablet` (800px), `--bp-desktop` (1100px) — mobile-first. diff --git a/web/package.json b/web/package.json index ea00c39..94a5ea7 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,7 @@ "lint": "eslint .", "lint:css": "stylelint \"./src/**/*.css\"", "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:large": "node scripts/audit-cdp.mjs --viewport 2560x1440", "refs-cdp-audit:responsive": "node scripts/audit-cdp-responsive.mjs", @@ -24,6 +24,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@panmdaa/colors": "^0.4.2", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 279d619..826dc8b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@eslint/js': specifier: ^10.0.1 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': 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)) @@ -255,6 +258,10 @@ packages: '@oxc-project/types@0.144.0': 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': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1702,6 +1709,8 @@ snapshots: '@oxc-project/types@0.144.0': {} + '@panmdaa/colors@0.4.2': {} + '@polka/url@1.0.0-next.29': {} '@rolldown/binding-android-arm64@1.2.4': diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..b5e58e5 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,5 @@ +import postcssHct from "./scripts/postcss-hct.mjs"; + +export default { + plugins: [postcssHct], +}; diff --git a/web/scripts/check-token-parity.mjs b/web/scripts/check-token-parity.mjs deleted file mode 100644 index 6ca0f70..0000000 --- a/web/scripts/check-token-parity.mjs +++ /dev/null @@ -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; -}); diff --git a/web/scripts/check-tokens.mjs b/web/scripts/check-tokens.mjs new file mode 100644 index 0000000..a580a39 --- /dev/null +++ b/web/scripts/check-tokens.mjs @@ -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; +}); diff --git a/web/scripts/lint-all.mjs b/web/scripts/lint-all.mjs index d9bbf3b..f22df68 100644 --- a/web/scripts/lint-all.mjs +++ b/web/scripts/lint-all.mjs @@ -4,30 +4,42 @@ 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. +// Every lint layer of the redesign, in one command. Each step runs even if a +// 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 = [ { name: "ESLint (svelte) + design-tokens rules", - args: [ - fileURLToPath( - new URL("../node_modules/eslint/bin/eslint.js", import.meta.url), - ), - ".", - ], + args: [bins.eslint, ...colorFlag, "."], }, { name: "Stylelint (css) — design-token style", - args: [ - fileURLToPath( - new URL("../node_modules/stylelint/bin/stylelint.mjs", import.meta.url), - ), - "./src/**/*.css", - ], + args: [bins.stylelint, ...colorFlag, "./src/**/*.css"], }, { - name: "Token audit (parity + unused)", - args: [fileURLToPath(new URL("check-token-parity.mjs", import.meta.url))], + name: "Token audit (parity + colors + unused)", + args: [bins.checkTokens], }, ]; @@ -36,7 +48,7 @@ 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", + stdio: ["ignore", 1, 1], }); if (result.status !== 0) failed = true; } diff --git a/web/scripts/postcss-hct.mjs b/web/scripts/postcss-hct.mjs new file mode 100644 index 0000000..0c057aa --- /dev/null +++ b/web/scripts/postcss-hct.mjs @@ -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; diff --git a/web/scripts/token-audit/colors.mjs b/web/scripts/token-audit/colors.mjs new file mode 100644 index 0000000..e00f189 --- /dev/null +++ b/web/scripts/token-audit/colors.mjs @@ -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; +} diff --git a/web/scripts/token-audit/helpers.mjs b/web/scripts/token-audit/helpers.mjs new file mode 100644 index 0000000..84031d9 --- /dev/null +++ b/web/scripts/token-audit/helpers.mjs @@ -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"]'), + }; +} diff --git a/web/scripts/token-audit/parity.mjs b/web/scripts/token-audit/parity.mjs new file mode 100644 index 0000000..42d47a4 --- /dev/null +++ b/web/scripts/token-audit/parity.mjs @@ -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; +} diff --git a/web/scripts/token-audit/unused.mjs b/web/scripts/token-audit/unused.mjs new file mode 100644 index 0000000..3632f15 --- /dev/null +++ b/web/scripts/token-audit/unused.mjs @@ -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)); +} diff --git a/web/stylelint.config.js b/web/stylelint.config.js index eb10260..19bd635 100644 --- a/web/stylelint.config.js +++ b/web/stylelint.config.js @@ -21,12 +21,12 @@ export default { // leading "--", so the regex must NOT start with --. // Regex: either an exact brand token or a prefixed token: // ^(brand-main|brand-alt)$ — brand exceptions - // |^(color|space|text|radius|bp|font)- — prefixed + // |^(color|space|size|text|radius|bp|font)- — prefixed "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: - '"%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