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
+50
View File
@@ -0,0 +1,50 @@
// Shared constants for the design-tokens ESLint plugin.
// Single source for property lists and regexes so each rule cooks from the
// same vocabulary.
// =====================================================================
// Properties that accept a COLOR.
// =====================================================================
export const COLOR_PROPS =
/^(color|background|background-color|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
// =====================================================================
// Properties that accept a SIZE (px/rem/em).
// z-index is handled separately (it's an integer, not a length).
// =====================================================================
export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|border-spacing)$/;
// =====================================================================
// Properties that carry a DURATION (ms/s) — transitions/animations.
// =====================================================================
export const DURATION_PROPS =
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
// =====================================================================
// A hardcoded COLOR literal: hex / color functions / named colors.
// Regex legend:
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
// - names: common CSS color names on a word boundary
// =====================================================================
export const COLOR_LITERAL =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
// =====================================================================
// A hardcoded SIZE literal (px/rem/em), fractional allowed: 0.5rem.
// =====================================================================
export const FORBIDDEN_SIZE_TOKEN = /\d+(?:\.\d+)?(?:px|rem|em)\b/g;
// =====================================================================
// A hardcoded DURATION literal (ms/s): 200ms, 0.3s.
// =====================================================================
export const FORBIDDEN_DURATION_TOKEN = /\d+(?:\.\d+)?(?:ms|s)\b/g;
// =====================================================================
// Token PREFIXES by category (tests run against the var() name, e.g.
// --color-bg, --brand-main, --space-1, --z-header).
// =====================================================================
export const COLOR_TOKEN = /^--(?:color|brand)-/;
export const SIZE_TOKEN = /^--(?:space|size|text|radius|bp|z)-/;
export const DURATION_TOKEN = /^--(?:duration|ease|motion)-/;
@@ -0,0 +1,81 @@
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
// A size property (padding, gap, font-size ...) must use a SIZE token
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
// (color, background, border-color ...) must use a COLOR token
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
// is a sign the wrong token is being reused.
import { COLOR_PROPS, COLOR_TOKEN, SIZE_PROPS, SIZE_TOKEN } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Collect the token names referenced by var() in a value.
const collectVars = (value) =>
Array.from(value.matchAll(/var\(\s*(--[\w-]+)/g), (m) => m[1]);
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban using a color token in size properties and a size token in color properties; each property must use tokens of its own category.",
category: "Design tokens",
recommended: true,
},
messages: {
categoryMismatch:
"Token '{{token}}' in '{{prop}}' belongs to the {{category}} category; expected a {{expected}} token.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
const isSizeProp = SIZE_PROPS.test(prop);
const isColorProp = COLOR_PROPS.test(prop);
if (!isSizeProp && !isColorProp) return;
for (const token of collectVars(value)) {
// A size property that holds a COLOR token.
if (isSizeProp && COLOR_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "color",
expected: "size",
});
}
// A color property that holds a SIZE token (z-radius/space/text...).
if (isColorProp && SIZE_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "size",
expected: "color",
});
}
}
});
},
};
},
};
@@ -0,0 +1,156 @@
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
// Everything visual in a Svelte <style> block must come from design tokens
// (var(--...)); direct literals are banned. color-mix() is banned too — its
// result must be tokenized in the design CSS file.
import {
COLOR_LITERAL,
COLOR_PROPS,
DURATION_PROPS,
FORBIDDEN_DURATION_TOKEN,
FORBIDDEN_SIZE_TOKEN,
SIZE_PROPS,
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban hardcoded colors, sizes, durations and z-index in Svelte <style> blocks; use CSS variables (design tokens).",
category: "Design tokens",
recommended: true,
},
messages: {
hardcodedColor:
"Hardcoded color '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedSize:
"Hardcoded size '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedDuration:
"Hardcoded duration '{{value}}' in '{{prop}}'. Use a var(--duration-...) token.",
hardcodedZIndex:
"Hardcoded z-index '{{value}}'. Use a var(--z-...) token.",
hardcodedBreakpoint:
"Hardcoded breakpoint '{{value}}' in @media. Use a var(--bp-...) token.",
colorMix:
"color-mix() in component ({{value}}). Tokenize the result in the design CSS file.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// =====================================================================
// 3) NO HARDCODED BREAKPOINTS IN @media
// @media conditions must use --bp-* tokens, not raw lengths.
// Regex: (max|min)-width followed by a NON-var(() number.
// =====================================================================
const BREAKPOINT_RE =
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
// Sub-rule 6: color-mix() is banned anywhere in a component style.
const COLOR_MIX_RE = /color-mix\s*\(/gi;
// --- @media breakpoints --- //
root.walkAtRules("media", (atRule) => {
const params = atRule.params ?? "";
let m;
BREAKPOINT_RE.lastIndex = 0;
while ((m = BREAKPOINT_RE.exec(params)) !== null) {
report(atRule, "hardcodedBreakpoint", { value: m[1] });
}
});
// --- declarations --- //
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
// Sub-rule 6: color-mix in any property.
if (COLOR_MIX_RE.test(value)) {
report(decl, "colorMix", { value: value.trim() });
}
// Sub-rule 4: durations in transition/animation props.
if (DURATION_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const m = withoutVars.match(FORBIDDEN_DURATION_TOKEN);
if (m) {
report(decl, "hardcodedDuration", { prop, value: m[0] });
}
}
// Sub-rule 7: z-index must come from a var(--z-...).
if (prop === "z-index") {
const withoutVars = stripVars(value).trim();
if (/^-?\d+$/.test(withoutVars)) {
report(decl, "hardcodedZIndex", { value: withoutVars });
}
}
// Only inspect known layout properties from here on.
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
// Sub-rule 2: sizes.
// Fire only on a real remaining px/rem/em token — percentages
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
// Only legal absolute length is a single 1px border line.
if (SIZE_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const match = withoutVars.match(FORBIDDEN_SIZE_TOKEN);
if (match) {
const shown = match[0];
if (shown !== "1px" && shown !== "0px") {
report(decl, "hardcodedSize", { prop, value: shown });
}
}
}
// Sub-rule 1: colors.
// Remaining value must be only allowed keywords
// (currentColor/transparent/inherit/none/0); otherwise a color
// literal is reported.
if (COLOR_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const withoutKeywords = withoutVars
.replace(
/\b(?:currentcolor|transparent|inherit|none|initial|unset|revert)\b/gi,
"",
)
.trim();
if (withoutKeywords !== "" && withoutKeywords !== "0") {
const match = value.match(COLOR_LITERAL);
if (match) {
report(decl, "hardcodedColor", {
prop,
value: match[0].trim(),
});
}
}
}
});
},
};
},
};
@@ -0,0 +1,82 @@
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
// A custom property may be DEFINED inside a component's <style> block, but its
// value must not introduce a design primitive: a raw color literal (hex/rgb/
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
// (colors/sizes) a single source of truth in preview.css, while still allowing
// local DERIVED variables built from tokens: var(--...), calc(), unitless
// ratios (--ratio: 1.5) — those are legitimate component-local state.
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban defining a custom property whose value introduces a raw color or size primitive in a Svelte <style> block; primitives belong in the design CSS file. Derived var()/calc()/unitless values are allowed.",
category: "Design tokens",
recommended: true,
},
messages: {
tokenPrimitive:
"Custom property '{{prop}}' defines a primitive '{{value}}' in a component. Move it to preview.css or derive it from tokens via var()/calc().",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// Only inspect custom property DEFINITIONS (--foo: value).
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
if (!prop.startsWith("--")) return;
const value = decl.value ?? "";
if (value.trim() === "") return;
const primitive = stripVars(value);
// A color literal in the definition → primitive.
const colorHit = primitive.match(COLOR_LITERAL);
if (colorHit) {
report(decl, "tokenPrimitive", {
prop,
value: colorHit[0].trim(),
});
return;
}
// An absolute size (px/rem/em) in the definition → primitive.
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
if (sizeHit) {
report(decl, "tokenPrimitive", {
prop,
value: sizeHit[0],
});
}
});
},
};
},
};
@@ -0,0 +1,91 @@
// Rule: NO UNDEFINED CSS-TOKEN USAGE IN COMPONENTS.
// A var(--...) referenced in a Svelte <style> block must exist in the design
// token file (src/preview.css) or be a local override defined in the same
// component. Catches typos and drop-in tokens that were never added to the
// "single source of truth".
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// A custom property DEFINITION: "--x:" followed by a colon.
const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
// A var() REFERENCE (primary argument only — fallbacks are optional overrides).
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
const definedCache = new Map();
function getDefinedTokens(cwd) {
const file = resolve(cwd, "src/preview.css");
if (!existsSync(file)) return null;
if (definedCache.has(file)) return definedCache.get(file);
const content = readFileSync(file, "utf-8");
const tokens = new Set();
for (const match of content.matchAll(DEFINED_RE)) {
tokens.add(match[0]);
}
definedCache.set(file, tokens);
return tokens;
}
export default {
meta: {
type: "problem",
docs: {
description:
"Disallow referencing a CSS variable that is not defined in preview.css (design token file) and not defined locally in the component.",
category: "Design tokens",
recommended: true,
},
messages: {
undefinedToken:
"CSS variable '{{token}}' is not defined in preview.css and not locally in this component.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
const globalTokens = getDefinedTokens(context.cwd);
if (!globalTokens) return {};
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// Custom properties defined INSIDE this component count as defined.
const localTokens = new Set();
root.walkDecls((decl) => {
if (decl.prop?.startsWith("--")) localTokens.add(decl.prop);
});
const checkValue = (node, text) => {
if (!text) return;
let match;
VAR_REF_RE.lastIndex = 0;
while ((match = VAR_REF_RE.exec(text)) !== null) {
const token = match[1];
if (globalTokens.has(token) || localTokens.has(token)) continue;
report(node, "undefinedToken", { token });
}
};
root.walkDecls((decl) => checkValue(decl, decl.value ?? ""));
root.walkAtRules((atRule) => checkValue(atRule, atRule.params ?? ""));
},
};
},
};
@@ -0,0 +1,19 @@
// Shared helpers to talk to the postcss AST of Svelte <style> blocks that
// svelte-eslint-parser exposes. All rules use the same plumbing.
// Returns the postcss Root of the <style> block, or null when the file is not
// a Svelte component (or its style failed to parse).
export function getStyleRoot(context) {
const parserServices = context.sourceCode?.parserServices;
if (!parserServices || typeof parserServices.getStyleContext !== "function") {
return null;
}
const styleContext = parserServices.getStyleContext();
return styleContext?.status === "success" ? styleContext.sourceAst : null;
}
// Returns a converter from a postcss node to an ESLint loc, or null if the
// parser doesn't expose it.
export function getStyleNodeLoc(context) {
return context.sourceCode?.parserServices?.styleNodeLoc ?? null;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Local ESLint plugin "design-tokens".
*
* Goal: a single source of truth for design. In Svelte components, hardcoded
* colors and sizes are banned; everything must come from CSS variables (tokens
* in preview.css). Rules inspect the postcss AST of Svelte <style> blocks
* exposed by svelte-eslint-parser.
*/
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
import noTokenDefinitionInSvelte from "./design-tokens/no-token-definition-in-svelte.js";
import noUndefinedInSvelte from "./design-tokens/no-undefined-in-svelte.js";
export default {
meta: {
name: "design-tokens",
version: "0.2.0",
},
rules: {
"no-hardcoded-in-svelte": noHardcodedInSvelte,
"no-category-mismatch": noCategoryMismatch,
"no-token-definition-in-svelte": noTokenDefinitionInSvelte,
"no-undefined-in-svelte": noUndefinedInSvelte,
},
};