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
+61 -2
View File
@@ -27,6 +27,13 @@
реальные маршруты), на C19 при переименовании `kit/``ui/` — и компонентный
glob. Не оставлять устаревшие пути в `eslint.config.js`.
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через `let`).
- **Единый запуск всей дизайн-проверки: `pnpm --dir web lint:all`**
(`web/scripts/lint-all.mjs`) — гоняет по очереди: ESLint (все правила,
включая design-tokens), stylelint (`lint:css`) и токен-аудит
(`lint:tokens`). Каждый шаг выполняется, даже если предыдущий упал; выход
ненулевой, если хоть один не прошёл. Текущие «ожидаемые долги» (hardcoded
размеры в kit, не-Prefix токены в preview.css и т.п.) — техдолг: чинить
только по заведённым tasks, не игнорировать правилом.
## Правила кода
@@ -70,6 +77,58 @@
используется.
- Все повторяющиеся визуальные элементы — отдельные компоненты в
`web/src/lib/components/kit/`, даже «просто div с двумя стилями».
- Токены нового дизайна живут в `design2.css`; `refs/` и `refs-html/`
одноразовый источник, не поддерживаемый (правки дизайна — сразу в код).
### Линтинг дизайн-токенов («единая точка правды» дизайна)
Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и z-index
обязаны приходить из CSS-переменных. Стек: **stylelint** прогоняется по всем
CSS-файлам (`web/stylelint.config.js`), **ESLint** лезет в `<style>`-блоки
svelte-компонентов через локальный плагин `web/eslint-plugins/design-tokens/`
(постпрефес postcss AST от svelte-eslint-parser). Правила применяются к тем же
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` — каждый цветовой
токен из `:root` обязан иметь пару в `[data-theme="dark"]` и наоборот.
Производные токены (значение содержит `var()`, напр. `oklch(from var(--...))`)
из пары исключены — они наследуют тему автоматически. Дополнительно выводит
**варнинг** о неиспользуемых токенах preview.css (определены, но нигде не
используются) — выход не меняется (only parity = exit 1).
Токены-префиксы (целевой словарь дизайна):
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные
две переменные, которым разрешено быть hex/rgb, остальные цвета — только
`oklch()` и только в preview.css.
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
- Брейкпоинты: `--bp-mobile` (640px), `--bp-tablet` (800px), `--bp-desktop`
(1100px) — mobile-first.
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
- Правило `custom-property-pattern` в stylelint тестирует паттерн **без** `--`
(`--x``x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
`ignore`, не в `except`): пустые строки между подряд идущими токенами
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
токены в preview.css отдельными блоками.
Правила плагина (компоненты, `eslint.config.js`):
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
color-токен (`--color-*`/`--brand-*`) и наоборот.
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
производные от токенов (`var()`, `calc()`, unitless-числа).
- `design-tokens/no-undefined-in-svelte``var(--x)` в `<style>` должен быть
определён в `src/preview.css` (словарь токенов) или локально в компоненте.
Файл читается один раз и кэшируется (не `glob`-зависим).
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
preview.css) — известный техдолг, его НЕ чинить и не игнорировать правилами.
- Коммиты мелкие (< ~500 строк), атомарные
+36 -36
View File
@@ -8,51 +8,51 @@ importers:
packageManagerDependencies:
'@pnpm/exe':
specifier: ^11.20.0
version: 11.22.0
version: 11.25.0
pnpm:
specifier: ^11.20.0
version: 11.22.0
version: 11.25.0
packages:
'@pnpm/exe@11.22.0':
resolution: {integrity: sha512-B1SGeKm+v9pX9YkzMmrnO2FbgBd8TDwzZ3jSj6J6ThxdyGvI4TsOfMeHARW4Wb25visPpmMDfIXrU76EZdJM4g==}
'@pnpm/exe@11.25.0':
resolution: {integrity: sha512-X19R2uC+VAJ4UJQE9c/PCdOXbDaWnZtad7WUrTHBFQuMVaK9MAHbyO/WgahQCuRtTmJkLbxcWKKCk+JeTYFm/g==}
hasBin: true
'@pnpm/linux-arm64@11.22.0':
resolution: {integrity: sha512-xzzn3jYG9QaiFZPaHcWM3yX4Fm9UGz5E42mzpbvUEvXYf5O9fwNolenOhGLpRl2qS5u35fjZSvDZbWlOwHMcug==}
'@pnpm/linux-arm64@11.25.0':
resolution: {integrity: sha512-ra8akqhzsbcOhKSJ9fFV8H+Oc9uGQAcp/XmIBEdT+8hPPoZCit3+RNCHmg8bLoNvpSLtRvZE0WFCMj7WyzxeBA==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.22.0':
resolution: {integrity: sha512-isvaPctGinbsM2hsTtRsMarN8Sr5QhXDTNmn8Xv9Lp1PjincCvH2RBbhpo+xYIOxzgls1dtQSdgoORUbfRVALQ==}
'@pnpm/linux-x64@11.25.0':
resolution: {integrity: sha512-pQl/L10diKQCbF73viRrtVU8qVWMTudUCSG1uZ+EWBNKtU9Sbxe6Q378diXDb1uTwSgUVMQoypxlC1MTzh7qvQ==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.22.0':
resolution: {integrity: sha512-i4J+AQWW0T3JBdXaLsvxu8ZmMG1HLS6JZQESdOR9uBv8Zumb4yg8GYT83eWRLacr6ngVdhEBiC3W4eOG64MFbA==}
'@pnpm/linuxstatic-arm64@11.25.0':
resolution: {integrity: sha512-cYcrbB/xN1N6/5VUlFuHblb1gNyJgZv1CF5Pk1T0sHJxQMY4DFJV3OqHVAgahxyUfSTaQcVLvH1H2JFvd/+sgw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.22.0':
resolution: {integrity: sha512-QYzk8jhSuSbVthW/OxEOhU3f3zhjpw4HqgedrlwbVNb7btCWn5del2Hb5PsYYka2PbmKq5EtF5IzYN8Yp1CfxQ==}
'@pnpm/linuxstatic-x64@11.25.0':
resolution: {integrity: sha512-HXDtaeQod16DDMUUcVLIMxvEc1jKn7IYsNPwbwAPs/Je/d3umRPJi3Ejjdn6e9qpXN6Oon+yvSsJr7B9oDt6KA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.22.0':
resolution: {integrity: sha512-Io8Axk5kutPgMuAfOg1QGj3J0/KpLvH5iiPcmp7up8D4q7BNWT02ndQo3RyGWqrlkf5nwspM41GFpWTShrZ4Aw==}
'@pnpm/macos-arm64@11.25.0':
resolution: {integrity: sha512-m/eAgEqKhiSexGxWPHNXNnSRI0hudymg6K7LbrU2EoDs9IgJ28OKEz9mGxMzhkYBweEquR4k5teMNes/aToy9w==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.22.0':
resolution: {integrity: sha512-QgaRuKGQKov7xW2utPCgDT83fn/PU5cD6HFgib48oTslz7wv26E89d4jMCxL4GRmEL2YCfkYuj5ITj1oVWg5WQ==}
'@pnpm/win-arm64@11.25.0':
resolution: {integrity: sha512-oAeECbtZ+eJziaBmPUkwJM8Dx7KVQ5FO367AXTjD2HGfB5ob1Bcu5hoUF5RBTgDBiP9fRQ1u13uAEpqar/6NLA==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.22.0':
resolution: {integrity: sha512-iWYsiSwpgxqur+TwsnSoPdOQaEfd4ygB84mb5tJUOgim6PHr1xhKJBndaQzH+At/WkLxLdYN+K8dYc4M7naezg==}
'@pnpm/win-x64@11.25.0':
resolution: {integrity: sha512-8/n+wCc0a8TRYTMFqti93Vh0cscdJ25xfMyYSDdXrLUh2ccxSFuS+SE7cNPjd/nOXoFAJvdW0kwfbyRPLa16/w==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.22.0:
resolution: {integrity: sha512-H/hwxMYTPf2I+yr8Rt0T1H8JyXlLQ4xv20fKmMrzvBY4HuC+k6CRuOOCTPAfiJ9G19niCRD7C+GrD7W6qA3WIQ==}
pnpm@11.25.0:
resolution: {integrity: sha512-XN6SW08HX3Jetx+64YpC/+eEUkeJ8ZthxzHLhyHsKKruFg4BqNWvT+2ypCzb8wDv4j2zVrDUoXtNY+EfirfJVg==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.22.0':
'@pnpm/exe@11.25.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.22.0
'@pnpm/linux-x64': 11.22.0
'@pnpm/linuxstatic-arm64': 11.22.0
'@pnpm/linuxstatic-x64': 11.22.0
'@pnpm/macos-arm64': 11.22.0
'@pnpm/win-arm64': 11.22.0
'@pnpm/win-x64': 11.22.0
'@pnpm/linux-arm64': 11.25.0
'@pnpm/linux-x64': 11.25.0
'@pnpm/linuxstatic-arm64': 11.25.0
'@pnpm/linuxstatic-x64': 11.25.0
'@pnpm/macos-arm64': 11.25.0
'@pnpm/win-arm64': 11.25.0
'@pnpm/win-x64': 11.25.0
'@pnpm/linux-arm64@11.22.0':
'@pnpm/linux-arm64@11.25.0':
optional: true
'@pnpm/linux-x64@11.22.0':
'@pnpm/linux-x64@11.25.0':
optional: true
'@pnpm/linuxstatic-arm64@11.22.0':
'@pnpm/linuxstatic-arm64@11.25.0':
optional: true
'@pnpm/linuxstatic-x64@11.22.0':
'@pnpm/linuxstatic-x64@11.25.0':
optional: true
'@pnpm/macos-arm64@11.22.0':
'@pnpm/macos-arm64@11.25.0':
optional: true
'@pnpm/win-arm64@11.22.0':
'@pnpm/win-arm64@11.25.0':
optional: true
'@pnpm/win-x64@11.22.0':
'@pnpm/win-x64@11.25.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.22.0: {}
pnpm@11.25.0: {}
---
lockfileVersion: '9.0'
+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,
},
};
+19
View File
@@ -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
View File
@@ -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",
+863 -50
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -16,9 +16,13 @@ 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);
+36 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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`);
+125
View File
@@ -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;
});
+49
View File
@@ -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);
+68 -23
View File
@@ -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"
+36 -18
View File
@@ -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",
+23 -8
View File
@@ -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,
},
],
};
+73
View File
@@ -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*;?$/",
],
},
},
},
],
};