mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
chore: add design tokens\css lint rules
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import js from "@eslint/js";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import designTokens from "./eslint-plugins/index.js";
|
||||
import globals from "globals";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
@@ -79,6 +80,24 @@ export default tseslint.config(
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"design-tokens": designTokens,
|
||||
},
|
||||
},
|
||||
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
|
||||
// Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн
|
||||
// удалят, расширить glob на весь код, исключив (old)/.
|
||||
{
|
||||
files: svelteFiles,
|
||||
plugins: {
|
||||
"design-tokens": designTokens,
|
||||
},
|
||||
rules: {
|
||||
"design-tokens/no-hardcoded-in-svelte": "error",
|
||||
"design-tokens/no-category-mismatch": "error",
|
||||
"design-tokens/no-token-definition-in-svelte": "error",
|
||||
"design-tokens/no-undefined-in-svelte": "error",
|
||||
},
|
||||
},
|
||||
// Полные recommended-наборы — только на новый код.
|
||||
...[
|
||||
|
||||
+7
-1
@@ -11,8 +11,11 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run",
|
||||
"format": "prettier --write .",
|
||||
"format": "prettier --write . --log-level warn",
|
||||
"lint": "eslint .",
|
||||
"lint:css": "stylelint \"./src/**/*.css\"",
|
||||
"lint:all": "node scripts/lint-all.mjs",
|
||||
"lint:tokens": "node scripts/check-token-parity.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",
|
||||
@@ -30,8 +33,11 @@
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.11.0",
|
||||
"playwright": "^1.62.1",
|
||||
"postcss": "^8.5.26",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"svelte-eslint-parser": "^1.8.1",
|
||||
|
||||
Generated
+863
-50
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,15 @@ for (let i = 0; i < process.argv.length; i++) {
|
||||
}
|
||||
if (!viewportSeen) args.push("--viewport", VIEWPORTS.join(","));
|
||||
|
||||
const r = spawnSync(process.execPath, ["scripts/audit-cdp.mjs", ...args.slice(2)], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
const r = spawnSync(
|
||||
process.execPath,
|
||||
["scripts/audit-cdp.mjs", ...args.slice(2)],
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
if (r.error) {
|
||||
console.error(r.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(r.status ?? 1);
|
||||
process.exit(r.status ?? 1);
|
||||
|
||||
+36
-30
@@ -460,9 +460,10 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
|
||||
function expandFont(raw) {
|
||||
const v = raw.trim();
|
||||
// Locate the size token: a length/% literal or a clamp()/min()/max().
|
||||
const sz = /(\d+(?:\.\d+)?(?:px|em|rem|%|pt|vh|vw)|clamp\([^)]*\)|min\([^)]*\)|max\([^)]*\))/.exec(
|
||||
v,
|
||||
);
|
||||
const sz =
|
||||
/(\d+(?:\.\d+)?(?:px|em|rem|%|pt|vh|vw)|clamp\([^)]*\)|min\([^)]*\)|max\([^)]*\))/.exec(
|
||||
v,
|
||||
);
|
||||
if (!sz) return {};
|
||||
const size = sz[0];
|
||||
// Optional leading font-style/variant/weight/stretch prefix, then the
|
||||
@@ -550,7 +551,9 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
|
||||
// carrying var() was enumerated as EMPTY longhands, so it must be
|
||||
// re-parsed here to survive (e.g. border-radius: var(--radius)).
|
||||
if (map.has(name)) continue;
|
||||
for (const [ln, v] of Object.entries(expandShorthand(name, value, cs) || {}))
|
||||
for (const [ln, v] of Object.entries(
|
||||
expandShorthand(name, value, cs) || {},
|
||||
))
|
||||
add(ln, v, important);
|
||||
}
|
||||
return [...map.values()];
|
||||
@@ -693,32 +696,32 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
|
||||
el.id === "svelte-announcer"
|
||||
)
|
||||
return null;
|
||||
// Computed style drives var() resolution and the recorded computed values;
|
||||
// computed once here and reused by the cascade matcher below.
|
||||
const cs = getComputedStyle(el);
|
||||
const specMap = new Map(inheritedEntries(inherited));
|
||||
for (const [name, d] of resolve(matchedDeclarations(el, rules, cs))) {
|
||||
const cur = specMap.get(name);
|
||||
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
|
||||
}
|
||||
// Box shorthands (margin/padding/gap): record the ORIGINAL declared value
|
||||
// when one rule cleanly set every side (`consistent` cascade), so the report
|
||||
// can show "20px 20px 20px 20px" vs a single "20px" the way the CSS spells
|
||||
// it, not just the collapsed rendering.
|
||||
const groups = {};
|
||||
for (const g of BOX_GROUPS) {
|
||||
const declared = specMap.get(g.name)?.value;
|
||||
if (!declared) continue;
|
||||
const expanded = expandShorthand(g.name, declared, cs);
|
||||
const consistent = g.keys.every(
|
||||
(k) => (expanded[k] ?? "") === (specMap.get(k)?.value ?? ""),
|
||||
);
|
||||
groups[g.name] = { declared, consistent };
|
||||
}
|
||||
// Computed style drives var() resolution and the recorded computed values;
|
||||
// computed once here and reused by the cascade matcher below.
|
||||
const cs = getComputedStyle(el);
|
||||
const specMap = new Map(inheritedEntries(inherited));
|
||||
for (const [name, d] of resolve(matchedDeclarations(el, rules, cs))) {
|
||||
const cur = specMap.get(name);
|
||||
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
|
||||
}
|
||||
// Box shorthands (margin/padding/gap): record the ORIGINAL declared value
|
||||
// when one rule cleanly set every side (`consistent` cascade), so the report
|
||||
// can show "20px 20px 20px 20px" vs a single "20px" the way the CSS spells
|
||||
// it, not just the collapsed rendering.
|
||||
const groups = {};
|
||||
for (const g of BOX_GROUPS) {
|
||||
const declared = specMap.get(g.name)?.value;
|
||||
if (!declared) continue;
|
||||
const expanded = expandShorthand(g.name, declared, cs);
|
||||
const consistent = g.keys.every(
|
||||
(k) => (expanded[k] ?? "") === (specMap.get(k)?.value ?? ""),
|
||||
);
|
||||
groups[g.name] = { declared, consistent };
|
||||
}
|
||||
|
||||
const styles = {};
|
||||
const raw = {};
|
||||
const computed = {};
|
||||
const styles = {};
|
||||
const raw = {};
|
||||
const computed = {};
|
||||
for (const p of PROPS) {
|
||||
const c = cs.getPropertyValue(p);
|
||||
computed[p] = c;
|
||||
@@ -1083,7 +1086,10 @@ function borderOf(a, b, n) {
|
||||
function borderLine(a, b) {
|
||||
const keys = [...BORDER_KEYS].filter((k) => isRealDiff(a, b, k));
|
||||
if (!keys.length) return null;
|
||||
return { line: `border: ${borderOf(a, b, a)} → ${borderOf(a, b, b)}`, keys };
|
||||
return {
|
||||
line: `border: ${borderOf(a, b, a)} → ${borderOf(a, b, b)}`,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
// CSS box shorthands reported as one line instead of one line per longhand
|
||||
|
||||
+83
-62
@@ -183,65 +183,64 @@ const keyOf = (n) =>
|
||||
n.children.length === 0 ? `${normTag(n.tag)}|${n.text}` : normTag(n.tag);
|
||||
|
||||
const snapshot = (page) =>
|
||||
page.evaluate(
|
||||
(styleFields) => {
|
||||
const sig = (el) => {
|
||||
const s = getComputedStyle(el);
|
||||
const o = {};
|
||||
for (const f of styleFields) o[f] = s[f];
|
||||
return o;
|
||||
page.evaluate((styleFields) => {
|
||||
const sig = (el) => {
|
||||
const s = getComputedStyle(el);
|
||||
const o = {};
|
||||
for (const f of styleFields) o[f] = s[f];
|
||||
return o;
|
||||
};
|
||||
const rectOf = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(r.x),
|
||||
y: Math.round(r.y),
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
};
|
||||
const rectOf = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(r.x),
|
||||
y: Math.round(r.y),
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
};
|
||||
};
|
||||
const readTokens = () => {
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
const out = {};
|
||||
for (const k of s) if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
|
||||
return out;
|
||||
};
|
||||
const tokensLight = readTokens();
|
||||
const prev = document.documentElement.getAttribute("data-theme");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
const tokensDark = readTokens();
|
||||
if (prev) document.documentElement.setAttribute("data-theme", prev);
|
||||
else document.documentElement.removeAttribute("data-theme");
|
||||
};
|
||||
const readTokens = () => {
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
const out = {};
|
||||
for (const k of s)
|
||||
if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
|
||||
return out;
|
||||
};
|
||||
const tokensLight = readTokens();
|
||||
const prev = document.documentElement.getAttribute("data-theme");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
const tokensDark = readTokens();
|
||||
if (prev) document.documentElement.setAttribute("data-theme", prev);
|
||||
else document.documentElement.removeAttribute("data-theme");
|
||||
|
||||
const walk = (el) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (["script", "style", "noscript", "template"].includes(tag)) return null;
|
||||
if (el.id === "svelte-announcer") return null;
|
||||
if (el.hasAttribute("hidden")) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden") return null;
|
||||
let ownText = "";
|
||||
for (const c of el.childNodes)
|
||||
if (c.nodeType === 3) ownText += c.textContent;
|
||||
ownText = ownText.replace(/\s+/g, " ").trim();
|
||||
const node = {
|
||||
tag,
|
||||
text: ownText,
|
||||
style: sig(el),
|
||||
rect: rectOf(el),
|
||||
children: [],
|
||||
};
|
||||
if (tag === "svg") return node;
|
||||
for (const child of el.children) {
|
||||
const cn = walk(child);
|
||||
if (cn) node.children.push(cn);
|
||||
}
|
||||
return node;
|
||||
const walk = (el) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (["script", "style", "noscript", "template"].includes(tag))
|
||||
return null;
|
||||
if (el.id === "svelte-announcer") return null;
|
||||
if (el.hasAttribute("hidden")) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden") return null;
|
||||
let ownText = "";
|
||||
for (const c of el.childNodes)
|
||||
if (c.nodeType === 3) ownText += c.textContent;
|
||||
ownText = ownText.replace(/\s+/g, " ").trim();
|
||||
const node = {
|
||||
tag,
|
||||
text: ownText,
|
||||
style: sig(el),
|
||||
rect: rectOf(el),
|
||||
children: [],
|
||||
};
|
||||
return { tokensLight, tokensDark, tree: walk(document.body) };
|
||||
},
|
||||
STYLE_FIELDS,
|
||||
);
|
||||
if (tag === "svg") return node;
|
||||
for (const child of el.children) {
|
||||
const cn = walk(child);
|
||||
if (cn) node.children.push(cn);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return { tokensLight, tokensDark, tree: walk(document.body) };
|
||||
}, STYLE_FIELDS);
|
||||
|
||||
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
|
||||
|
||||
@@ -284,12 +283,26 @@ function compareStyle(ours, ref, path, out) {
|
||||
const aNode = ours.children[i];
|
||||
const cands = bByKey.get(keyOf(aNode));
|
||||
let bIdx = -1;
|
||||
if (cands) for (const ci of cands) if (!used.has(ci)) { bIdx = ci; break; }
|
||||
if (cands)
|
||||
for (const ci of cands)
|
||||
if (!used.has(ci)) {
|
||||
bIdx = ci;
|
||||
break;
|
||||
}
|
||||
if (bIdx >= 0) {
|
||||
used.add(bIdx);
|
||||
compareStyle(aNode, ref.children[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
|
||||
compareStyle(
|
||||
aNode,
|
||||
ref.children[bIdx],
|
||||
`${path} > ${aNode.tag}:${i + 1}`,
|
||||
out,
|
||||
);
|
||||
} else {
|
||||
out.push({ path: `${path} > ${aNode.tag}:${i + 1}`, type: "added", tag: aNode.tag });
|
||||
out.push({
|
||||
path: `${path} > ${aNode.tag}:${i + 1}`,
|
||||
type: "added",
|
||||
tag: aNode.tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < ref.children.length; j++) {
|
||||
@@ -425,7 +438,9 @@ function toMarkdown(reports) {
|
||||
: "_расхождений стилей нет_";
|
||||
const struct = r.deltas
|
||||
.filter((d) => d.type !== "style")
|
||||
.map((d) => `- \`${d.path}\` — **${d.type}**${d.tag ? ` (${d.tag})` : ""}`)
|
||||
.map(
|
||||
(d) => `- \`${d.path}\` — **${d.type}**${d.tag ? ` (${d.tag})` : ""}`,
|
||||
)
|
||||
.join("\n");
|
||||
return (
|
||||
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
|
||||
@@ -480,7 +495,11 @@ async function main() {
|
||||
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(WEB, "audit/audit-report.json"),
|
||||
JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2),
|
||||
JSON.stringify(
|
||||
{ generatedAt: new Date().toISOString(), reports },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
writeFileSync(resolve(WEB, "audit/audit-report.md"), toMarkdown(reports));
|
||||
console.log(`report: web/audit/audit-report.md`);
|
||||
@@ -489,7 +508,9 @@ async function main() {
|
||||
if (server) {
|
||||
try {
|
||||
if (process.platform === "win32")
|
||||
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], { stdio: "ignore" });
|
||||
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
else server.kill("SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
+26
-11
@@ -143,7 +143,8 @@ const snapshot = (page) =>
|
||||
page.evaluate(() => {
|
||||
const walk = (el) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (["script", "style", "noscript", "template"].includes(tag)) return null;
|
||||
if (["script", "style", "noscript", "template"].includes(tag))
|
||||
return null;
|
||||
// SvelteKit ін'єктить #svelte-announcer (aria-live) прямо в <body>;
|
||||
// у рефа (Next.js) його немає — це фреймворк-шум, не збіг дизайну.
|
||||
if (el.id === "svelte-announcer") return null;
|
||||
@@ -189,9 +190,7 @@ const normTag = (tag) => (STRUCT_TAGS.has(tag) ? "box" : tag);
|
||||
// текст — иначе малейшее отличие текста в обёртке каскадом роняет всё поддерево.
|
||||
// Листья (нет дочерних элементов) матчим по тегу + тексту.
|
||||
const keyOf = (n) =>
|
||||
n.children.length === 0
|
||||
? `${normTag(n.tag)}|${n.ownText}`
|
||||
: normTag(n.tag);
|
||||
n.children.length === 0 ? `${normTag(n.tag)}|${n.ownText}` : normTag(n.tag);
|
||||
|
||||
function subtreeCount(node) {
|
||||
let c = 1;
|
||||
@@ -233,7 +232,12 @@ function matchChildren(ac, bc, path, out) {
|
||||
const k = keyOf(aNode);
|
||||
const cands = bByKey.get(k);
|
||||
let bIdx = -1;
|
||||
if (cands) for (const ci of cands) if (!used.has(ci)) { bIdx = ci; break; }
|
||||
if (cands)
|
||||
for (const ci of cands)
|
||||
if (!used.has(ci)) {
|
||||
bIdx = ci;
|
||||
break;
|
||||
}
|
||||
if (bIdx >= 0) {
|
||||
used.add(bIdx);
|
||||
diffNodes(aNode, bc[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
|
||||
@@ -254,7 +258,14 @@ function diff(ours, ref, path, out) {
|
||||
}
|
||||
|
||||
function tally(deltas) {
|
||||
const c = { added: 0, removed: 0, tagMismatch: 0, textMismatch: 0, nodesAdded: 0, nodesRemoved: 0 };
|
||||
const c = {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
tagMismatch: 0,
|
||||
textMismatch: 0,
|
||||
nodesAdded: 0,
|
||||
nodesRemoved: 0,
|
||||
};
|
||||
for (const d of deltas) {
|
||||
if (d.type in c) c[d.type]++;
|
||||
if (d.type === "added") c.nodesAdded += d.nodes ?? 1;
|
||||
@@ -317,10 +328,10 @@ function toMarkdown(reports) {
|
||||
return `- \`${d.path}\` **text**: ours \`${d.ours}\` → ref \`${d.ref}\``;
|
||||
if (d.type === "tagMismatch")
|
||||
return `- \`${d.path}\` **tag**: ours \`${d.ours}\` → ref \`${d.ref}\``;
|
||||
if (d.type === "added")
|
||||
return `- \`${d.path}\` **added** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
|
||||
if (d.type === "removed")
|
||||
return `- \`${d.path}\` **removed** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
|
||||
if (d.type === "added")
|
||||
return `- \`${d.path}\` **added** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
|
||||
if (d.type === "removed")
|
||||
return `- \`${d.path}\` **removed** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
@@ -376,7 +387,11 @@ async function main() {
|
||||
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(WEB, "audit/dom-report.json"),
|
||||
JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2),
|
||||
JSON.stringify(
|
||||
{ generatedAt: new Date().toISOString(), reports },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
writeFileSync(resolve(WEB, "audit/dom-report.md"), toMarkdown(reports));
|
||||
console.log(`report: web/audit/dom-report.md`);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Token parity check: every color token defined in `:root` (light theme) of the
|
||||
// design CSS must also be defined in `[data-theme="dark"]`, and vice versa. A
|
||||
// color that exists only in one theme silently breaks dark mode.
|
||||
//
|
||||
// Usage: pnpm --dir web exec node scripts/check-token-parity.mjs
|
||||
// Exit code 1 (and a report) when tokens are missing.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import postcss from "postcss";
|
||||
|
||||
const FILE = new URL("../src/preview.css", import.meta.url);
|
||||
const SRC_DIR = fileURLToPath(new URL("../src/", import.meta.url));
|
||||
|
||||
// A var(--x) REFERENCE anywhere in the app (primary argument only).
|
||||
const USAGE_RE = /var\(\s*(--[\w-]+)/g;
|
||||
|
||||
const SCAN_EXTS = new Set([".svelte", ".css", ".ts", ".js", ".mjs"]);
|
||||
|
||||
// Recursively list source files; no glob dependency needed.
|
||||
function listFiles(dir) {
|
||||
const files = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
files.push(...listFiles(full));
|
||||
} else if (SCAN_EXTS.has(name.slice(name.lastIndexOf(".")))) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Collect every token referenced via var() across all source files.
|
||||
function collectUsedTokens() {
|
||||
const used = new Set();
|
||||
for (const file of listFiles(SRC_DIR)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const match of content.matchAll(USAGE_RE)) {
|
||||
used.add(match[1]);
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
// A token is "colorful" when its value is a color literal — those must have a
|
||||
// dark-mate. Non-color tokens (fonts, radii, durations) are exempt.
|
||||
const COLOR_RE =
|
||||
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(/i;
|
||||
|
||||
const isColorValue = (value) => COLOR_RE.test(value);
|
||||
|
||||
// A token is "derived" when its value references var(...) — it adapts to the
|
||||
// theme automatically (e.g. --brand-alt: oklch(from var(--brand-main) ...)),
|
||||
// so it must NOT have a hardcoded dark twin.
|
||||
const isDerived = (value) => value.includes("var(");
|
||||
|
||||
// Collect { token: value } custom properties defined inside a given selector.
|
||||
function collectTokens(root, selector) {
|
||||
const map = new Map();
|
||||
root.walkRules((rule) => {
|
||||
if (rule.selector === selector) {
|
||||
rule.walkDecls((decl) => {
|
||||
if (decl.prop.startsWith("--")) map.set(decl.prop, decl.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const css = await readFile(FILE, "utf8");
|
||||
const root = postcss.parse(css);
|
||||
|
||||
const light = collectTokens(root, ":root");
|
||||
const dark = collectTokens(root, '[data-theme="dark"]');
|
||||
|
||||
const errors = [];
|
||||
|
||||
// Every colorful light token must have a dark-mate (derived tokens excluded).
|
||||
for (const [token, value] of light) {
|
||||
if (!isColorValue(value) || isDerived(value)) continue;
|
||||
if (!dark.has(token)) {
|
||||
errors.push(` ${token} in :root has no [data-theme="dark"] override`);
|
||||
}
|
||||
}
|
||||
|
||||
// Every dark token must exist in :root (a dark-only token is orphaned).
|
||||
for (const [token] of dark) {
|
||||
if (!light.has(token)) {
|
||||
errors.push(` ${token} in [data-theme="dark"] is missing in :root`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`Token parity violations in ${FILE}:\n${errors.join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
"Token parity: OK — all color tokens have both light/dark forms.",
|
||||
);
|
||||
}
|
||||
|
||||
// Unused design tokens: defined in preview.css but never used anywhere in
|
||||
// src via var(). A dead token is not the single source of truth — it's dust.
|
||||
const defined = new Set();
|
||||
root.walkDecls((decl) => {
|
||||
if (decl.prop.startsWith("--")) defined.add(decl.prop);
|
||||
});
|
||||
const used = collectUsedTokens();
|
||||
const unused = [...defined].filter((token) => !used.has(token));
|
||||
if (unused.length > 0) {
|
||||
console.log("\nUnused tokens (defined in preview.css, never used):");
|
||||
for (const token of unused) console.log(` ${token}`);
|
||||
} else {
|
||||
console.log("All preview.css tokens are used somewhere.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.log(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
|
||||
const node = process.execPath;
|
||||
|
||||
// (eslint, [args]) — every lint layer of the redesign, in one command.
|
||||
// Each step runs even if a previous one fails; exit code is nonzero if any.
|
||||
const steps = [
|
||||
{
|
||||
name: "ESLint (svelte) + design-tokens rules",
|
||||
args: [
|
||||
fileURLToPath(
|
||||
new URL("../node_modules/eslint/bin/eslint.js", import.meta.url),
|
||||
),
|
||||
".",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Stylelint (css) — design-token style",
|
||||
args: [
|
||||
fileURLToPath(
|
||||
new URL("../node_modules/stylelint/bin/stylelint.mjs", import.meta.url),
|
||||
),
|
||||
"./src/**/*.css",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Token audit (parity + unused)",
|
||||
args: [fileURLToPath(new URL("check-token-parity.mjs", import.meta.url))],
|
||||
},
|
||||
];
|
||||
|
||||
let failed = false;
|
||||
for (const [index, step] of steps.entries()) {
|
||||
console.log(`\n[${index + 1}/${steps.length}] ${step.name}`);
|
||||
const result = spawnSync(node, step.args, {
|
||||
cwd: WEB_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) failed = true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
failed
|
||||
? "\nlint-all: FAILED — fix the reported problems and re-run."
|
||||
: "\nlint-all: all checks passed.",
|
||||
);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -7,7 +7,13 @@
|
||||
type PreviewToolView,
|
||||
type SegmentedField,
|
||||
} from "$lib/preview/tool-views";
|
||||
import { Copy, Download, Check, RotateCcw, ChevronDown } from "@lucide/svelte";
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Check,
|
||||
RotateCcw,
|
||||
ChevronDown,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
tool: ToolEntry;
|
||||
@@ -31,7 +37,8 @@
|
||||
const grad = $derived.by(() => {
|
||||
if (!view || view.preview !== "gradient") return null;
|
||||
const typeSel = num(form["type"]);
|
||||
const type = (view.fields.find((f) => f.id === "type") as SegmentedField).options[typeSel];
|
||||
const type = (view.fields.find((f) => f.id === "type") as SegmentedField)
|
||||
.options[typeSel];
|
||||
const cp = pair(form["stops"]);
|
||||
const dir = num(form["dir"]);
|
||||
const op = num(form["op"]);
|
||||
@@ -39,14 +46,24 @@
|
||||
type === "Radial"
|
||||
? `radial-gradient(circle, ${cp.from} 0%, ${cp.to} 100%)`
|
||||
: `linear-gradient(${dir}deg, ${cp.from} 0%, ${cp.to} 100%)`;
|
||||
return { type, cp, dir, op, css, code: `background: ${css};\nopacity: ${op / 100};` };
|
||||
return {
|
||||
type,
|
||||
cp,
|
||||
dir,
|
||||
op,
|
||||
css,
|
||||
code: `background: ${css};\nopacity: ${op / 100};`,
|
||||
};
|
||||
});
|
||||
|
||||
const DIRS = [0, 45, 90, 135, 180, 225, 270, 315];
|
||||
const DIR_GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
|
||||
const SUBJECT_BG = "linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
|
||||
const SUBJECT_CLIP = "polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)";
|
||||
const CHECKER = "repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px";
|
||||
const SUBJECT_BG =
|
||||
"linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
|
||||
const SUBJECT_CLIP =
|
||||
"polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)";
|
||||
const CHECKER =
|
||||
"repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px";
|
||||
</script>
|
||||
|
||||
{#if view}
|
||||
@@ -77,7 +94,10 @@
|
||||
<label>{f.label}</label>
|
||||
<div class="segmented wide">
|
||||
{#each f.options as opt, i}
|
||||
<button class={i === sel ? "selected" : ""} onclick={() => (form[f.id] = i)}>
|
||||
<button
|
||||
class={i === sel ? "selected" : ""}
|
||||
onclick={() => (form[f.id] = i)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -92,7 +112,8 @@
|
||||
<span class="swatch" style="background:{cp.from}"></span>
|
||||
<input
|
||||
value={cp.from}
|
||||
oninput={(e) => (form[f.id] = { from: e.currentTarget.value, to: cp.to })}
|
||||
oninput={(e) =>
|
||||
(form[f.id] = { from: e.currentTarget.value, to: cp.to })}
|
||||
/>
|
||||
</div>
|
||||
<span class="stop-arrow">→</span>
|
||||
@@ -100,7 +121,11 @@
|
||||
<span class="swatch" style="background:{cp.to}"></span>
|
||||
<input
|
||||
value={cp.to}
|
||||
oninput={(e) => (form[f.id] = { from: cp.from, to: e.currentTarget.value })}
|
||||
oninput={(e) =>
|
||||
(form[f.id] = {
|
||||
from: cp.from,
|
||||
to: e.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +134,10 @@
|
||||
{:else if f.kind === "slider" || f.kind === "direction"}
|
||||
{@const v = num(form[f.id])}
|
||||
<div class="setting-group">
|
||||
<label>{f.label} <output>{v}{f.space ? " " : ""}{f.suffix}</output></label>
|
||||
<label
|
||||
>{f.label}
|
||||
<output>{v}{f.space ? " " : ""}{f.suffix}</output></label
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={f.min}
|
||||
@@ -120,7 +148,10 @@
|
||||
{#if f.kind === "direction"}
|
||||
<div class="direction-grid">
|
||||
{#each DIRS as a, i}
|
||||
<button class={a === v ? "selected" : ""} onclick={() => (form[f.id] = a)}>
|
||||
<button
|
||||
class={a === v ? "selected" : ""}
|
||||
onclick={() => (form[f.id] = a)}
|
||||
>
|
||||
{a}° {DIR_GLYPHS[i]}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -138,7 +169,10 @@
|
||||
<label>{f.label}</label>
|
||||
<div class="color-field">
|
||||
<span class="swatch" style="background:{v}"></span>
|
||||
<input value={v} oninput={(e) => (form[f.id] = e.currentTarget.value)} />
|
||||
<input
|
||||
value={v}
|
||||
oninput={(e) => (form[f.id] = e.currentTarget.value)}
|
||||
/>
|
||||
{#if f.native}
|
||||
<input
|
||||
class="native-color"
|
||||
@@ -160,7 +194,8 @@
|
||||
<div class="setting-group toggle-group">
|
||||
<label>
|
||||
<span>{f.label}</span>
|
||||
<b class="toggle" class:on onclick={() => (form[f.id] = !on)}></b>
|
||||
<b class="toggle" class:on onclick={() => (form[f.id] = !on)}
|
||||
></b>
|
||||
</label>
|
||||
<p>{f.note}</p>
|
||||
</div>
|
||||
@@ -182,12 +217,14 @@
|
||||
<span class="label">{view.toolbarLabel}</span>
|
||||
<strong>{view.fileName}</strong>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
|
||||
<button class="download-btn"
|
||||
><Download size={15} /> {view.downloadLabel} <ChevronDown size={14} /></button
|
||||
>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
|
||||
<button class="download-btn"
|
||||
><Download size={15} />
|
||||
{view.downloadLabel}
|
||||
<ChevronDown size={14} /></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="large-canvas">
|
||||
<div
|
||||
@@ -201,7 +238,9 @@
|
||||
<div class="code-block">
|
||||
<div>
|
||||
<span class="label">GENERATED CSS</span>
|
||||
<button class="icon-btn" aria-label="Copy CSS"><Copy size={14} /></button>
|
||||
<button class="icon-btn" aria-label="Copy CSS"
|
||||
><Copy size={14} /></button
|
||||
>
|
||||
</div>
|
||||
<pre>{grad?.code}</pre>
|
||||
</div>
|
||||
@@ -215,12 +254,16 @@
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<span class="processed"><Check size={14} /> processed</span>
|
||||
<button class="download-btn"><Download size={15} /> Download result</button>
|
||||
<button class="download-btn"
|
||||
><Download size={15} /> Download result</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comparison-grid">
|
||||
<div class="image-card">
|
||||
<div class="image-label"><span>SOURCE</span><b>original.png</b></div>
|
||||
<div class="image-label">
|
||||
<span>SOURCE</span><b>original.png</b>
|
||||
</div>
|
||||
<div class="remover-canvas source-canvas">
|
||||
<div
|
||||
class="subject subject-source"
|
||||
@@ -232,7 +275,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-card">
|
||||
<div class="image-label"><span>RESULT</span><b>removed-bg.png</b></div>
|
||||
<div class="image-label">
|
||||
<span>RESULT</span><b>removed-bg.png</b>
|
||||
</div>
|
||||
<div class="remover-canvas" style="background:{CHECKER}">
|
||||
<div
|
||||
class="subject"
|
||||
|
||||
@@ -27,17 +27,20 @@ const REF: {
|
||||
{
|
||||
id: "jpg-to-png",
|
||||
title: "Convert JPG to PNG",
|
||||
description: "Re-encode JPEG files as lossless PNG while preserving transparency.",
|
||||
description:
|
||||
"Re-encode JPEG files as lossless PNG while preserving transparency.",
|
||||
},
|
||||
{
|
||||
id: "webp-to-png",
|
||||
title: "Convert WebP to PNG",
|
||||
description: "Turn WebP images into a universal PNG format for any workflow.",
|
||||
description:
|
||||
"Turn WebP images into a universal PNG format for any workflow.",
|
||||
},
|
||||
{
|
||||
id: "png-to-base64",
|
||||
title: "PNG to Base64",
|
||||
description: "Encode an image as a base64 string for embedding in code or styles.",
|
||||
description:
|
||||
"Encode an image as a base64 string for embedding in code or styles.",
|
||||
},
|
||||
{
|
||||
id: "png-to-data-uri",
|
||||
@@ -47,7 +50,8 @@ const REF: {
|
||||
{
|
||||
id: "convert-png-to-jpg",
|
||||
title: "Convert PNG to JPG",
|
||||
description: "Composite transparency over a selected backdrop and export JPEG.",
|
||||
description:
|
||||
"Composite transparency over a selected backdrop and export JPEG.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -58,12 +62,14 @@ const REF: {
|
||||
{
|
||||
id: "remove-background-png",
|
||||
title: "Remove background PNG",
|
||||
description: "Remove a solid background by color, tolerance, or edge-connected regions.",
|
||||
description:
|
||||
"Remove a solid background by color, tolerance, or edge-connected regions.",
|
||||
},
|
||||
{
|
||||
id: "extract-alpha-mask-png",
|
||||
title: "Extract alpha mask",
|
||||
description: "Turn the alpha channel into a clean black-and-white mask.",
|
||||
description:
|
||||
"Turn the alpha channel into a clean black-and-white mask.",
|
||||
},
|
||||
{
|
||||
id: "round-corners-png",
|
||||
@@ -73,12 +79,14 @@ const REF: {
|
||||
{
|
||||
id: "add-stroke-png",
|
||||
title: "Outline PNG",
|
||||
description: "Add a colored ring around opaque content with adjustable thickness.",
|
||||
description:
|
||||
"Add a colored ring around opaque content with adjustable thickness.",
|
||||
},
|
||||
{
|
||||
id: "change-png-opacity",
|
||||
title: "Change PNG opacity",
|
||||
description: "Multiply the alpha channel while keeping the original colors unchanged.",
|
||||
description:
|
||||
"Multiply the alpha channel while keeping the original colors unchanged.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,7 +97,8 @@ const REF: {
|
||||
{
|
||||
id: "linear-gradient-png",
|
||||
title: "Create gradient PNG",
|
||||
description: "Generate a smooth transition between two colors with direction controls.",
|
||||
description:
|
||||
"Generate a smooth transition between two colors with direction controls.",
|
||||
},
|
||||
{
|
||||
id: "grayscale-png",
|
||||
@@ -99,17 +108,20 @@ const REF: {
|
||||
{
|
||||
id: "invert-colors-png",
|
||||
title: "Invert colors PNG",
|
||||
description: "Invert every color channel while leaving alpha untouched.",
|
||||
description:
|
||||
"Invert every color channel while leaving alpha untouched.",
|
||||
},
|
||||
{
|
||||
id: "adjust-brightness-contrast-png",
|
||||
title: "Brightness & contrast",
|
||||
description: "Adjust brightness and contrast across a controlled range.",
|
||||
description:
|
||||
"Adjust brightness and contrast across a controlled range.",
|
||||
},
|
||||
{
|
||||
id: "temperature-png",
|
||||
title: "Temperature PNG",
|
||||
description: "Make an image warmer or cooler with a single precise control.",
|
||||
description:
|
||||
"Make an image warmer or cooler with a single precise control.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -120,12 +132,14 @@ const REF: {
|
||||
{
|
||||
id: "resize-png",
|
||||
title: "Resize PNG",
|
||||
description: "Scale an image with bilinear interpolation and optional aspect lock.",
|
||||
description:
|
||||
"Scale an image with bilinear interpolation and optional aspect lock.",
|
||||
},
|
||||
{
|
||||
id: "crop-png",
|
||||
title: "Crop PNG",
|
||||
description: "Cut a rectangular area with exact coordinates and dimensions.",
|
||||
description:
|
||||
"Cut a rectangular area with exact coordinates and dimensions.",
|
||||
},
|
||||
{
|
||||
id: "rotate-png",
|
||||
@@ -140,7 +154,8 @@ const REF: {
|
||||
{
|
||||
id: "add-padding-png",
|
||||
title: "Add padding to PNG",
|
||||
description: "Expand the canvas on all sides by a chosen number of pixels.",
|
||||
description:
|
||||
"Expand the canvas on all sides by a chosen number of pixels.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -151,7 +166,8 @@ const REF: {
|
||||
{
|
||||
id: "blur-png",
|
||||
title: "Blur PNG",
|
||||
description: "Apply a fast Gaussian-style blur with transparent edge handling.",
|
||||
description:
|
||||
"Apply a fast Gaussian-style blur with transparent edge handling.",
|
||||
},
|
||||
{
|
||||
id: "sharpen-png",
|
||||
@@ -161,7 +177,8 @@ const REF: {
|
||||
{
|
||||
id: "vignette-png",
|
||||
title: "Vignette PNG",
|
||||
description: "Smoothly darken the image edges while preserving the center.",
|
||||
description:
|
||||
"Smoothly darken the image edges while preserving the center.",
|
||||
},
|
||||
{
|
||||
id: "jpeg-artifacts-png",
|
||||
@@ -177,7 +194,8 @@ const REF: {
|
||||
{
|
||||
id: "png-info",
|
||||
title: "PNG info",
|
||||
description: "Inspect dimensions, alpha presence, and unique color count.",
|
||||
description:
|
||||
"Inspect dimensions, alpha presence, and unique color count.",
|
||||
},
|
||||
{
|
||||
id: "png-is-grayscale",
|
||||
|
||||
@@ -42,11 +42,7 @@ export type ToggleField = {
|
||||
};
|
||||
|
||||
export type FieldDef =
|
||||
| SegmentedField
|
||||
| ColorPairField
|
||||
| SliderField
|
||||
| ColorField
|
||||
| ToggleField;
|
||||
SegmentedField | ColorPairField | SliderField | ColorField | ToggleField;
|
||||
|
||||
export interface PreviewToolView {
|
||||
title: string;
|
||||
@@ -63,7 +59,8 @@ export interface PreviewToolView {
|
||||
fields: FieldDef[];
|
||||
}
|
||||
|
||||
export type FieldValue = number | string | boolean | { from: string; to: string };
|
||||
export type FieldValue =
|
||||
number | string | boolean | { from: string; to: string };
|
||||
|
||||
function initValue(f: FieldDef): FieldValue {
|
||||
switch (f.kind) {
|
||||
@@ -114,8 +111,26 @@ const gradient: PreviewToolView = {
|
||||
from: "#1769D2",
|
||||
to: "#00A8C7",
|
||||
},
|
||||
{ id: "dir", kind: "direction", label: "DIRECTION", value: 135, min: 0, max: 360, suffix: "°", space: true },
|
||||
{ id: "op", kind: "slider", label: "OPACITY", value: 100, min: 0, max: 100, suffix: "%", space: true },
|
||||
{
|
||||
id: "dir",
|
||||
kind: "direction",
|
||||
label: "DIRECTION",
|
||||
value: 135,
|
||||
min: 0,
|
||||
max: 360,
|
||||
suffix: "°",
|
||||
space: true,
|
||||
},
|
||||
{
|
||||
id: "op",
|
||||
kind: "slider",
|
||||
label: "OPACITY",
|
||||
value: 100,
|
||||
min: 0,
|
||||
max: 100,
|
||||
suffix: "%",
|
||||
space: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Stylelint config — design-token enforcement for easy-png-tools.
|
||||
// FWHM: keeps the design system a single source of truth. Colors and sizes must
|
||||
// come from prefixed tokens; direct color values are allowed only for the two
|
||||
// brand tokens. old.css is the legacy design and is ignored (removed later).
|
||||
|
||||
export default {
|
||||
extends: ["stylelint-config-standard"],
|
||||
ignoreFiles: [
|
||||
"src/old.css",
|
||||
"**/node_modules/**",
|
||||
"**/build/**",
|
||||
"**/.svelte-kit/**",
|
||||
"**/static/**",
|
||||
],
|
||||
rules: {
|
||||
// Rule: every CSS variable must carry a system prefix so the design
|
||||
// vocabulary stays a single, greppable source of truth.
|
||||
// The two brand tokens (brand-main / brand-alt) are the only
|
||||
// exceptions that may exist as-is.
|
||||
// NOTE: stylelint tests the pattern against the property WITHOUT the
|
||||
// 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
|
||||
"custom-property-pattern": [
|
||||
"^(brand-main|brand-alt)$|^(color|space|text|radius|bp|font)-",
|
||||
{
|
||||
message:
|
||||
'"%s" must be prefixed: --color-*, --space-*, --text-*, --radius-*, --bp-*, --font-*; brand: only --brand-main/--brand-alt',
|
||||
},
|
||||
],
|
||||
// Rule: !important is banned in CSS files. Overriding a look must happen
|
||||
// through tokens/layers, not by force. (Not part of the standard set.)
|
||||
"declaration-no-important": true,
|
||||
// Grouping whitespace for the token file. The standard config puts
|
||||
// "after-custom-property" into `except`, which makes --fix DELETE blank
|
||||
// lines between consecutive custom properties — so color/size token groups
|
||||
// collapse into one wall. Moving it to `ignore` frees blank lines between
|
||||
// tokens (groups stay readable); the blank line is still REQUIRED before a
|
||||
// token that follows a regular declaration.
|
||||
"custom-property-empty-line-before": [
|
||||
"always",
|
||||
{
|
||||
except: ["first-nested"],
|
||||
ignore: [
|
||||
"after-custom-property",
|
||||
"after-comment",
|
||||
"inside-single-line-block",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// Applies to every CSS file EXCEPT old.css (already in ignoreFiles) —
|
||||
// the legacy design is exempt and will be removed later.
|
||||
files: ["src/**/*.css"],
|
||||
rules: {
|
||||
// Rule: only --brand-main and --brand-alt may set a color
|
||||
// directly (hex/rgb/hsl/named). Every other color token must be oklch().
|
||||
// The key matches ANY custom property EXCEPT the two brand exceptions
|
||||
// (negative lookahead (?!...)).
|
||||
"declaration-property-value-disallowed-list": {
|
||||
"/^--(?!brand-main$|brand-alt$).*/": [
|
||||
"/#[0-9a-fA-F]{3,8}\\b/",
|
||||
"/\\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch)(?:\\()/",
|
||||
"/^\\s*(red|green|blue|white|black|gray|grey|transparent|navy|teal|cyan|amber)\\s*;?$/",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user