mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
chore: add design tokens\css lint rules
This commit is contained in:
@@ -27,6 +27,13 @@
|
|||||||
реальные маршруты), на C19 при переименовании `kit/`→`ui/` — и компонентный
|
реальные маршруты), на C19 при переименовании `kit/`→`ui/` — и компонентный
|
||||||
glob. Не оставлять устаревшие пути в `eslint.config.js`.
|
glob. Не оставлять устаревшие пути в `eslint.config.js`.
|
||||||
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через `let`).
|
- Для `*.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 с двумя стилями».
|
`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 строк), атомарные
|
- Коммиты мелкие (< ~500 строк), атомарные
|
||||||
|
|||||||
Generated
+36
-36
@@ -8,51 +8,51 @@ importers:
|
|||||||
packageManagerDependencies:
|
packageManagerDependencies:
|
||||||
'@pnpm/exe':
|
'@pnpm/exe':
|
||||||
specifier: ^11.20.0
|
specifier: ^11.20.0
|
||||||
version: 11.22.0
|
version: 11.25.0
|
||||||
pnpm:
|
pnpm:
|
||||||
specifier: ^11.20.0
|
specifier: ^11.20.0
|
||||||
version: 11.22.0
|
version: 11.25.0
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@pnpm/exe@11.22.0':
|
'@pnpm/exe@11.25.0':
|
||||||
resolution: {integrity: sha512-B1SGeKm+v9pX9YkzMmrnO2FbgBd8TDwzZ3jSj6J6ThxdyGvI4TsOfMeHARW4Wb25visPpmMDfIXrU76EZdJM4g==}
|
resolution: {integrity: sha512-X19R2uC+VAJ4UJQE9c/PCdOXbDaWnZtad7WUrTHBFQuMVaK9MAHbyO/WgahQCuRtTmJkLbxcWKKCk+JeTYFm/g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.22.0':
|
'@pnpm/linux-arm64@11.25.0':
|
||||||
resolution: {integrity: sha512-xzzn3jYG9QaiFZPaHcWM3yX4Fm9UGz5E42mzpbvUEvXYf5O9fwNolenOhGLpRl2qS5u35fjZSvDZbWlOwHMcug==}
|
resolution: {integrity: sha512-ra8akqhzsbcOhKSJ9fFV8H+Oc9uGQAcp/XmIBEdT+8hPPoZCit3+RNCHmg8bLoNvpSLtRvZE0WFCMj7WyzxeBA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.22.0':
|
'@pnpm/linux-x64@11.25.0':
|
||||||
resolution: {integrity: sha512-isvaPctGinbsM2hsTtRsMarN8Sr5QhXDTNmn8Xv9Lp1PjincCvH2RBbhpo+xYIOxzgls1dtQSdgoORUbfRVALQ==}
|
resolution: {integrity: sha512-pQl/L10diKQCbF73viRrtVU8qVWMTudUCSG1uZ+EWBNKtU9Sbxe6Q378diXDb1uTwSgUVMQoypxlC1MTzh7qvQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.22.0':
|
'@pnpm/linuxstatic-arm64@11.25.0':
|
||||||
resolution: {integrity: sha512-i4J+AQWW0T3JBdXaLsvxu8ZmMG1HLS6JZQESdOR9uBv8Zumb4yg8GYT83eWRLacr6ngVdhEBiC3W4eOG64MFbA==}
|
resolution: {integrity: sha512-cYcrbB/xN1N6/5VUlFuHblb1gNyJgZv1CF5Pk1T0sHJxQMY4DFJV3OqHVAgahxyUfSTaQcVLvH1H2JFvd/+sgw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.22.0':
|
'@pnpm/linuxstatic-x64@11.25.0':
|
||||||
resolution: {integrity: sha512-QYzk8jhSuSbVthW/OxEOhU3f3zhjpw4HqgedrlwbVNb7btCWn5del2Hb5PsYYka2PbmKq5EtF5IzYN8Yp1CfxQ==}
|
resolution: {integrity: sha512-HXDtaeQod16DDMUUcVLIMxvEc1jKn7IYsNPwbwAPs/Je/d3umRPJi3Ejjdn6e9qpXN6Oon+yvSsJr7B9oDt6KA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.22.0':
|
'@pnpm/macos-arm64@11.25.0':
|
||||||
resolution: {integrity: sha512-Io8Axk5kutPgMuAfOg1QGj3J0/KpLvH5iiPcmp7up8D4q7BNWT02ndQo3RyGWqrlkf5nwspM41GFpWTShrZ4Aw==}
|
resolution: {integrity: sha512-m/eAgEqKhiSexGxWPHNXNnSRI0hudymg6K7LbrU2EoDs9IgJ28OKEz9mGxMzhkYBweEquR4k5teMNes/aToy9w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.22.0':
|
'@pnpm/win-arm64@11.25.0':
|
||||||
resolution: {integrity: sha512-QgaRuKGQKov7xW2utPCgDT83fn/PU5cD6HFgib48oTslz7wv26E89d4jMCxL4GRmEL2YCfkYuj5ITj1oVWg5WQ==}
|
resolution: {integrity: sha512-oAeECbtZ+eJziaBmPUkwJM8Dx7KVQ5FO367AXTjD2HGfB5ob1Bcu5hoUF5RBTgDBiP9fRQ1u13uAEpqar/6NLA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@pnpm/win-x64@11.22.0':
|
'@pnpm/win-x64@11.25.0':
|
||||||
resolution: {integrity: sha512-iWYsiSwpgxqur+TwsnSoPdOQaEfd4ygB84mb5tJUOgim6PHr1xhKJBndaQzH+At/WkLxLdYN+K8dYc4M7naezg==}
|
resolution: {integrity: sha512-8/n+wCc0a8TRYTMFqti93Vh0cscdJ25xfMyYSDdXrLUh2ccxSFuS+SE7cNPjd/nOXoFAJvdW0kwfbyRPLa16/w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -116,45 +116,45 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
pnpm@11.22.0:
|
pnpm@11.25.0:
|
||||||
resolution: {integrity: sha512-H/hwxMYTPf2I+yr8Rt0T1H8JyXlLQ4xv20fKmMrzvBY4HuC+k6CRuOOCTPAfiJ9G19niCRD7C+GrD7W6qA3WIQ==}
|
resolution: {integrity: sha512-XN6SW08HX3Jetx+64YpC/+eEUkeJ8ZthxzHLhyHsKKruFg4BqNWvT+2ypCzb8wDv4j2zVrDUoXtNY+EfirfJVg==}
|
||||||
engines: {node: '>=22.13'}
|
engines: {node: '>=22.13'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@pnpm/exe@11.22.0':
|
'@pnpm/exe@11.25.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reflink/reflink': 0.1.19
|
'@reflink/reflink': 0.1.19
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@pnpm/linux-arm64': 11.22.0
|
'@pnpm/linux-arm64': 11.25.0
|
||||||
'@pnpm/linux-x64': 11.22.0
|
'@pnpm/linux-x64': 11.25.0
|
||||||
'@pnpm/linuxstatic-arm64': 11.22.0
|
'@pnpm/linuxstatic-arm64': 11.25.0
|
||||||
'@pnpm/linuxstatic-x64': 11.22.0
|
'@pnpm/linuxstatic-x64': 11.25.0
|
||||||
'@pnpm/macos-arm64': 11.22.0
|
'@pnpm/macos-arm64': 11.25.0
|
||||||
'@pnpm/win-arm64': 11.22.0
|
'@pnpm/win-arm64': 11.25.0
|
||||||
'@pnpm/win-x64': 11.22.0
|
'@pnpm/win-x64': 11.25.0
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.22.0':
|
'@pnpm/linux-arm64@11.25.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.22.0':
|
'@pnpm/linux-x64@11.25.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.22.0':
|
'@pnpm/linuxstatic-arm64@11.25.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.22.0':
|
'@pnpm/linuxstatic-x64@11.25.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.22.0':
|
'@pnpm/macos-arm64@11.25.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.22.0':
|
'@pnpm/win-arm64@11.25.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-x64@11.22.0':
|
'@pnpm/win-x64@11.25.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||||
@@ -194,7 +194,7 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
pnpm@11.22.0: {}
|
pnpm@11.25.0: {}
|
||||||
|
|
||||||
---
|
---
|
||||||
lockfileVersion: '9.0'
|
lockfileVersion: '9.0'
|
||||||
|
|||||||
@@ -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 js from "@eslint/js";
|
||||||
import prettier from "eslint-config-prettier";
|
import prettier from "eslint-config-prettier";
|
||||||
import svelte from "eslint-plugin-svelte";
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import designTokens from "./eslint-plugins/index.js";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import svelteParser from "svelte-eslint-parser";
|
import svelteParser from "svelte-eslint-parser";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
@@ -79,6 +80,24 @@ export default tseslint.config(
|
|||||||
parser: tseslint.parser,
|
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-наборы — только на новый код.
|
// Полные recommended-наборы — только на новый код.
|
||||||
...[
|
...[
|
||||||
|
|||||||
+7
-1
@@ -11,8 +11,11 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write . --log-level warn",
|
||||||
"lint": "eslint .",
|
"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": "node scripts/audit-cdp.mjs",
|
||||||
"refs-cdp-audit:large": "node scripts/audit-cdp.mjs --viewport 2560x1440",
|
"refs-cdp-audit:large": "node scripts/audit-cdp.mjs --viewport 2560x1440",
|
||||||
"refs-cdp-audit:responsive": "node scripts/audit-cdp-responsive.mjs",
|
"refs-cdp-audit:responsive": "node scripts/audit-cdp-responsive.mjs",
|
||||||
@@ -30,8 +33,11 @@
|
|||||||
"eslint-plugin-svelte": "^3.23.0",
|
"eslint-plugin-svelte": "^3.23.0",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.11.0",
|
||||||
"playwright": "^1.62.1",
|
"playwright": "^1.62.1",
|
||||||
|
"postcss": "^8.5.26",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-svelte": "^4.1.1",
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
|
"stylelint": "^17.14.1",
|
||||||
|
"stylelint-config-standard": "^40.0.0",
|
||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
"svelte-eslint-parser": "^1.8.1",
|
"svelte-eslint-parser": "^1.8.1",
|
||||||
|
|||||||
Generated
+863
-50
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,13 @@ for (let i = 0; i < process.argv.length; i++) {
|
|||||||
}
|
}
|
||||||
if (!viewportSeen) args.push("--viewport", VIEWPORTS.join(","));
|
if (!viewportSeen) args.push("--viewport", VIEWPORTS.join(","));
|
||||||
|
|
||||||
const r = spawnSync(process.execPath, ["scripts/audit-cdp.mjs", ...args.slice(2)], {
|
const r = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
["scripts/audit-cdp.mjs", ...args.slice(2)],
|
||||||
|
{
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
console.error(r.error.message);
|
console.error(r.error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -460,7 +460,8 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
|
|||||||
function expandFont(raw) {
|
function expandFont(raw) {
|
||||||
const v = raw.trim();
|
const v = raw.trim();
|
||||||
// Locate the size token: a length/% literal or a clamp()/min()/max().
|
// 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(
|
const sz =
|
||||||
|
/(\d+(?:\.\d+)?(?:px|em|rem|%|pt|vh|vw)|clamp\([^)]*\)|min\([^)]*\)|max\([^)]*\))/.exec(
|
||||||
v,
|
v,
|
||||||
);
|
);
|
||||||
if (!sz) return {};
|
if (!sz) return {};
|
||||||
@@ -550,7 +551,9 @@ function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) {
|
|||||||
// carrying var() was enumerated as EMPTY longhands, so it must be
|
// carrying var() was enumerated as EMPTY longhands, so it must be
|
||||||
// re-parsed here to survive (e.g. border-radius: var(--radius)).
|
// re-parsed here to survive (e.g. border-radius: var(--radius)).
|
||||||
if (map.has(name)) continue;
|
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);
|
add(ln, v, important);
|
||||||
}
|
}
|
||||||
return [...map.values()];
|
return [...map.values()];
|
||||||
@@ -1083,7 +1086,10 @@ function borderOf(a, b, n) {
|
|||||||
function borderLine(a, b) {
|
function borderLine(a, b) {
|
||||||
const keys = [...BORDER_KEYS].filter((k) => isRealDiff(a, b, k));
|
const keys = [...BORDER_KEYS].filter((k) => isRealDiff(a, b, k));
|
||||||
if (!keys.length) return null;
|
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
|
// CSS box shorthands reported as one line instead of one line per longhand
|
||||||
|
|||||||
+34
-13
@@ -183,8 +183,7 @@ const keyOf = (n) =>
|
|||||||
n.children.length === 0 ? `${normTag(n.tag)}|${n.text}` : normTag(n.tag);
|
n.children.length === 0 ? `${normTag(n.tag)}|${n.text}` : normTag(n.tag);
|
||||||
|
|
||||||
const snapshot = (page) =>
|
const snapshot = (page) =>
|
||||||
page.evaluate(
|
page.evaluate((styleFields) => {
|
||||||
(styleFields) => {
|
|
||||||
const sig = (el) => {
|
const sig = (el) => {
|
||||||
const s = getComputedStyle(el);
|
const s = getComputedStyle(el);
|
||||||
const o = {};
|
const o = {};
|
||||||
@@ -203,7 +202,8 @@ const snapshot = (page) =>
|
|||||||
const readTokens = () => {
|
const readTokens = () => {
|
||||||
const s = getComputedStyle(document.documentElement);
|
const s = getComputedStyle(document.documentElement);
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const k of s) if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
|
for (const k of s)
|
||||||
|
if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
const tokensLight = readTokens();
|
const tokensLight = readTokens();
|
||||||
@@ -215,7 +215,8 @@ const snapshot = (page) =>
|
|||||||
|
|
||||||
const walk = (el) => {
|
const walk = (el) => {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
if (["script", "style", "noscript", "template"].includes(tag)) return null;
|
if (["script", "style", "noscript", "template"].includes(tag))
|
||||||
|
return null;
|
||||||
if (el.id === "svelte-announcer") return null;
|
if (el.id === "svelte-announcer") return null;
|
||||||
if (el.hasAttribute("hidden")) return null;
|
if (el.hasAttribute("hidden")) return null;
|
||||||
const cs = getComputedStyle(el);
|
const cs = getComputedStyle(el);
|
||||||
@@ -239,9 +240,7 @@ const snapshot = (page) =>
|
|||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
return { tokensLight, tokensDark, tree: walk(document.body) };
|
return { tokensLight, tokensDark, tree: walk(document.body) };
|
||||||
},
|
}, STYLE_FIELDS);
|
||||||
STYLE_FIELDS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
|
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
|
||||||
|
|
||||||
@@ -284,12 +283,26 @@ function compareStyle(ours, ref, path, out) {
|
|||||||
const aNode = ours.children[i];
|
const aNode = ours.children[i];
|
||||||
const cands = bByKey.get(keyOf(aNode));
|
const cands = bByKey.get(keyOf(aNode));
|
||||||
let bIdx = -1;
|
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) {
|
if (bIdx >= 0) {
|
||||||
used.add(bIdx);
|
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 {
|
} 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++) {
|
for (let j = 0; j < ref.children.length; j++) {
|
||||||
@@ -425,7 +438,9 @@ function toMarkdown(reports) {
|
|||||||
: "_расхождений стилей нет_";
|
: "_расхождений стилей нет_";
|
||||||
const struct = r.deltas
|
const struct = r.deltas
|
||||||
.filter((d) => d.type !== "style")
|
.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");
|
.join("\n");
|
||||||
return (
|
return (
|
||||||
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
|
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
|
||||||
@@ -480,7 +495,11 @@ async function main() {
|
|||||||
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
resolve(WEB, "audit/audit-report.json"),
|
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));
|
writeFileSync(resolve(WEB, "audit/audit-report.md"), toMarkdown(reports));
|
||||||
console.log(`report: web/audit/audit-report.md`);
|
console.log(`report: web/audit/audit-report.md`);
|
||||||
@@ -489,7 +508,9 @@ async function main() {
|
|||||||
if (server) {
|
if (server) {
|
||||||
try {
|
try {
|
||||||
if (process.platform === "win32")
|
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");
|
else server.kill("SIGTERM");
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ const snapshot = (page) =>
|
|||||||
page.evaluate(() => {
|
page.evaluate(() => {
|
||||||
const walk = (el) => {
|
const walk = (el) => {
|
||||||
const tag = el.tagName.toLowerCase();
|
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>;
|
// SvelteKit ін'єктить #svelte-announcer (aria-live) прямо в <body>;
|
||||||
// у рефа (Next.js) його немає — це фреймворк-шум, не збіг дизайну.
|
// у рефа (Next.js) його немає — це фреймворк-шум, не збіг дизайну.
|
||||||
if (el.id === "svelte-announcer") return null;
|
if (el.id === "svelte-announcer") return null;
|
||||||
@@ -189,9 +190,7 @@ const normTag = (tag) => (STRUCT_TAGS.has(tag) ? "box" : tag);
|
|||||||
// текст — иначе малейшее отличие текста в обёртке каскадом роняет всё поддерево.
|
// текст — иначе малейшее отличие текста в обёртке каскадом роняет всё поддерево.
|
||||||
// Листья (нет дочерних элементов) матчим по тегу + тексту.
|
// Листья (нет дочерних элементов) матчим по тегу + тексту.
|
||||||
const keyOf = (n) =>
|
const keyOf = (n) =>
|
||||||
n.children.length === 0
|
n.children.length === 0 ? `${normTag(n.tag)}|${n.ownText}` : normTag(n.tag);
|
||||||
? `${normTag(n.tag)}|${n.ownText}`
|
|
||||||
: normTag(n.tag);
|
|
||||||
|
|
||||||
function subtreeCount(node) {
|
function subtreeCount(node) {
|
||||||
let c = 1;
|
let c = 1;
|
||||||
@@ -233,7 +232,12 @@ function matchChildren(ac, bc, path, out) {
|
|||||||
const k = keyOf(aNode);
|
const k = keyOf(aNode);
|
||||||
const cands = bByKey.get(k);
|
const cands = bByKey.get(k);
|
||||||
let bIdx = -1;
|
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) {
|
if (bIdx >= 0) {
|
||||||
used.add(bIdx);
|
used.add(bIdx);
|
||||||
diffNodes(aNode, bc[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
|
diffNodes(aNode, bc[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
|
||||||
@@ -254,7 +258,14 @@ function diff(ours, ref, path, out) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tally(deltas) {
|
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) {
|
for (const d of deltas) {
|
||||||
if (d.type in c) c[d.type]++;
|
if (d.type in c) c[d.type]++;
|
||||||
if (d.type === "added") c.nodesAdded += d.nodes ?? 1;
|
if (d.type === "added") c.nodesAdded += d.nodes ?? 1;
|
||||||
@@ -376,7 +387,11 @@ async function main() {
|
|||||||
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
resolve(WEB, "audit/dom-report.json"),
|
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));
|
writeFileSync(resolve(WEB, "audit/dom-report.md"), toMarkdown(reports));
|
||||||
console.log(`report: web/audit/dom-report.md`);
|
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 PreviewToolView,
|
||||||
type SegmentedField,
|
type SegmentedField,
|
||||||
} from "$lib/preview/tool-views";
|
} 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 {
|
interface Props {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
@@ -31,7 +37,8 @@
|
|||||||
const grad = $derived.by(() => {
|
const grad = $derived.by(() => {
|
||||||
if (!view || view.preview !== "gradient") return null;
|
if (!view || view.preview !== "gradient") return null;
|
||||||
const typeSel = num(form["type"]);
|
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 cp = pair(form["stops"]);
|
||||||
const dir = num(form["dir"]);
|
const dir = num(form["dir"]);
|
||||||
const op = num(form["op"]);
|
const op = num(form["op"]);
|
||||||
@@ -39,14 +46,24 @@
|
|||||||
type === "Radial"
|
type === "Radial"
|
||||||
? `radial-gradient(circle, ${cp.from} 0%, ${cp.to} 100%)`
|
? `radial-gradient(circle, ${cp.from} 0%, ${cp.to} 100%)`
|
||||||
: `linear-gradient(${dir}deg, ${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 DIRS = [0, 45, 90, 135, 180, 225, 270, 315];
|
||||||
const DIR_GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
|
const DIR_GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
|
||||||
const SUBJECT_BG = "linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
|
const SUBJECT_BG =
|
||||||
const SUBJECT_CLIP = "polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)";
|
"linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
|
||||||
const CHECKER = "repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px";
|
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>
|
</script>
|
||||||
|
|
||||||
{#if view}
|
{#if view}
|
||||||
@@ -77,7 +94,10 @@
|
|||||||
<label>{f.label}</label>
|
<label>{f.label}</label>
|
||||||
<div class="segmented wide">
|
<div class="segmented wide">
|
||||||
{#each f.options as opt, i}
|
{#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}
|
{opt}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -92,7 +112,8 @@
|
|||||||
<span class="swatch" style="background:{cp.from}"></span>
|
<span class="swatch" style="background:{cp.from}"></span>
|
||||||
<input
|
<input
|
||||||
value={cp.from}
|
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>
|
</div>
|
||||||
<span class="stop-arrow">→</span>
|
<span class="stop-arrow">→</span>
|
||||||
@@ -100,7 +121,11 @@
|
|||||||
<span class="swatch" style="background:{cp.to}"></span>
|
<span class="swatch" style="background:{cp.to}"></span>
|
||||||
<input
|
<input
|
||||||
value={cp.to}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,7 +134,10 @@
|
|||||||
{:else if f.kind === "slider" || f.kind === "direction"}
|
{:else if f.kind === "slider" || f.kind === "direction"}
|
||||||
{@const v = num(form[f.id])}
|
{@const v = num(form[f.id])}
|
||||||
<div class="setting-group">
|
<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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={f.min}
|
min={f.min}
|
||||||
@@ -120,7 +148,10 @@
|
|||||||
{#if f.kind === "direction"}
|
{#if f.kind === "direction"}
|
||||||
<div class="direction-grid">
|
<div class="direction-grid">
|
||||||
{#each DIRS as a, i}
|
{#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]}
|
{a}° {DIR_GLYPHS[i]}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -138,7 +169,10 @@
|
|||||||
<label>{f.label}</label>
|
<label>{f.label}</label>
|
||||||
<div class="color-field">
|
<div class="color-field">
|
||||||
<span class="swatch" style="background:{v}"></span>
|
<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}
|
{#if f.native}
|
||||||
<input
|
<input
|
||||||
class="native-color"
|
class="native-color"
|
||||||
@@ -160,7 +194,8 @@
|
|||||||
<div class="setting-group toggle-group">
|
<div class="setting-group toggle-group">
|
||||||
<label>
|
<label>
|
||||||
<span>{f.label}</span>
|
<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>
|
</label>
|
||||||
<p>{f.note}</p>
|
<p>{f.note}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +220,9 @@
|
|||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
|
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
|
||||||
<button class="download-btn"
|
<button class="download-btn"
|
||||||
><Download size={15} /> {view.downloadLabel} <ChevronDown size={14} /></button
|
><Download size={15} />
|
||||||
|
{view.downloadLabel}
|
||||||
|
<ChevronDown size={14} /></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +238,9 @@
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">GENERATED CSS</span>
|
<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>
|
</div>
|
||||||
<pre>{grad?.code}</pre>
|
<pre>{grad?.code}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,12 +254,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<span class="processed"><Check size={14} /> processed</span>
|
<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>
|
</div>
|
||||||
<div class="comparison-grid">
|
<div class="comparison-grid">
|
||||||
<div class="image-card">
|
<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="remover-canvas source-canvas">
|
||||||
<div
|
<div
|
||||||
class="subject subject-source"
|
class="subject subject-source"
|
||||||
@@ -232,7 +275,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-card">
|
<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="remover-canvas" style="background:{CHECKER}">
|
||||||
<div
|
<div
|
||||||
class="subject"
|
class="subject"
|
||||||
|
|||||||
@@ -27,17 +27,20 @@ const REF: {
|
|||||||
{
|
{
|
||||||
id: "jpg-to-png",
|
id: "jpg-to-png",
|
||||||
title: "Convert 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",
|
id: "webp-to-png",
|
||||||
title: "Convert 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",
|
id: "png-to-base64",
|
||||||
title: "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",
|
id: "png-to-data-uri",
|
||||||
@@ -47,7 +50,8 @@ const REF: {
|
|||||||
{
|
{
|
||||||
id: "convert-png-to-jpg",
|
id: "convert-png-to-jpg",
|
||||||
title: "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",
|
id: "remove-background-png",
|
||||||
title: "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",
|
id: "extract-alpha-mask-png",
|
||||||
title: "Extract alpha mask",
|
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",
|
id: "round-corners-png",
|
||||||
@@ -73,12 +79,14 @@ const REF: {
|
|||||||
{
|
{
|
||||||
id: "add-stroke-png",
|
id: "add-stroke-png",
|
||||||
title: "Outline 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",
|
id: "change-png-opacity",
|
||||||
title: "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",
|
id: "linear-gradient-png",
|
||||||
title: "Create 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",
|
id: "grayscale-png",
|
||||||
@@ -99,17 +108,20 @@ const REF: {
|
|||||||
{
|
{
|
||||||
id: "invert-colors-png",
|
id: "invert-colors-png",
|
||||||
title: "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",
|
id: "adjust-brightness-contrast-png",
|
||||||
title: "Brightness & contrast",
|
title: "Brightness & contrast",
|
||||||
description: "Adjust brightness and contrast across a controlled range.",
|
description:
|
||||||
|
"Adjust brightness and contrast across a controlled range.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "temperature-png",
|
id: "temperature-png",
|
||||||
title: "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",
|
id: "resize-png",
|
||||||
title: "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",
|
id: "crop-png",
|
||||||
title: "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",
|
id: "rotate-png",
|
||||||
@@ -140,7 +154,8 @@ const REF: {
|
|||||||
{
|
{
|
||||||
id: "add-padding-png",
|
id: "add-padding-png",
|
||||||
title: "Add padding to 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",
|
id: "blur-png",
|
||||||
title: "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",
|
id: "sharpen-png",
|
||||||
@@ -161,7 +177,8 @@ const REF: {
|
|||||||
{
|
{
|
||||||
id: "vignette-png",
|
id: "vignette-png",
|
||||||
title: "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",
|
id: "jpeg-artifacts-png",
|
||||||
@@ -177,7 +194,8 @@ const REF: {
|
|||||||
{
|
{
|
||||||
id: "png-info",
|
id: "png-info",
|
||||||
title: "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",
|
id: "png-is-grayscale",
|
||||||
|
|||||||
@@ -42,11 +42,7 @@ export type ToggleField = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FieldDef =
|
export type FieldDef =
|
||||||
| SegmentedField
|
SegmentedField | ColorPairField | SliderField | ColorField | ToggleField;
|
||||||
| ColorPairField
|
|
||||||
| SliderField
|
|
||||||
| ColorField
|
|
||||||
| ToggleField;
|
|
||||||
|
|
||||||
export interface PreviewToolView {
|
export interface PreviewToolView {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -63,7 +59,8 @@ export interface PreviewToolView {
|
|||||||
fields: FieldDef[];
|
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 {
|
function initValue(f: FieldDef): FieldValue {
|
||||||
switch (f.kind) {
|
switch (f.kind) {
|
||||||
@@ -114,8 +111,26 @@ const gradient: PreviewToolView = {
|
|||||||
from: "#1769D2",
|
from: "#1769D2",
|
||||||
to: "#00A8C7",
|
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