Compare commits

...
3 Commits
31 changed files with 690 additions and 223 deletions
+40 -128
View File
@@ -5,42 +5,21 @@
## Стек ## Стек
- **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes). - **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes).
- Менеджер пакетов: **pnpm**. Запускать команды из `web/`, либо через - Менеджер пакетов: **pnpm**. Запускать команды из `web/`, либо через обёртки в
обёртки в корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать
Не использовать npm. npm.
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, - Сборка: `pnpm --dir web build`, проверка типов:
тесты: `pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright, `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, тесты:
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт 4173). `pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
- Форматирование: два независимых прогона. Корневой prettier форматирует `web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт
только markdown `docs/` (`.prettierrc` в корне, `pnpm format:docs`, проверка — 4173).
`pnpm check:docs`). Корневой `.prettierignore` — только точечные исключения - Форматирование: `pnpm format` в корне (оба прогона) или
(node_modules, refs-html…), без `/*`-allowlist: он глушит формат-он-сейв `pnpm --dir web format` (только код web/). Проверка: `pnpm check:docs`
для всего `web/` (файлы резолвятся как ignored и дочерние конфиги не (docs) + `pnpm --dir web exec prettier --check .` (код).
применяются). Код `web/` форматируется - Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
отдельно: `pnpm --dir web format` (Prettier + `prettier-plugin-svelte`, `pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
конфиг `web/.prettierrc`, игнор `web/.prettierignore`). Проверка без записи: выполняется, даже если предыдущий упал. Текущие «ожидаемые долги» (hardcoded
`pnpm --dir web exec prettier --check .`. Оба прогона сразу: `pnpm format` размеры в kit до C19, не-Prefix токены в `app_v1.css`) — техдолг: чинить
в корне.
- Линтинг: `pnpm --dir web lint` (ESLint, flat-конфиг `web/eslint.config.js`).
Устроен инкрементально:
- На **весь код** — парсинг TS/Svelte + правило
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов).
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` + `eslint-plugin-svelte`)
навешены **только на новый код** (`src/lib/components/kit/**`,
`src/routes/preview/**`), чтобы старый код не засыпался предсуществующими
ошибками. Когда старый дизайн удалён (C19), scoped-блок убирается и
`recommended` включается на весь код.
- **Scoped-пути двигаются вместе с папками** (см. `plan-redesign.md` §10):
на C17 `src/routes/preview/**``src/routes/**` (preview переезжает на
реальные маршруты), на 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, не игнорировать правилом. только по заведённым tasks, не игнорировать правилом.
## Правила кода ## Правила кода
@@ -67,111 +46,44 @@
Не использовать инлайн-дженерик `$props<{ ... }>()` — он тяжело читается и Не использовать инлайн-дженерик `$props<{ ... }>()` — он тяжело читается и
разносит тип и деструктуризацию по разным местам. Также **не использовать разносит тип и деструктуризацию по разным местам. Также **не использовать
инлайн-импорты в типах** (`children?: import('svelte').Snippet;`) — все инлайн-импорты в типах** (`children?: import('svelte').Snippet;`) — все
`import type` поднимаются наверх файла. Это правило **включено в ESLint** `import type` поднимаются наверх файла.
(`@typescript-eslint/consistent-type-imports`, `prefer: 'type-imports'`).
> Правило «всегда `interface Props` + `let {...}: Props = $props()`» стандартным > Правило «всегда `interface Props` + `let {...}: Props = $props()`» стандартным
> ESLint-правилом не покрывается — остаётся конвенцией. Кастомное правило > ESLint-правилом не покрывается — остаётся конвенцией.
> (eslint-plugin-svelte / свой visitor) — TODO, позже.
### Дизайн: новый визуальный язык ### Дизайн: новый визуальный язык
Переезд на новый дизайн описан в `docs/plan-redesign.md`. Ключевые Описание дизайна — в `docs/plan-redesign.md`. Общие правила:
ограничения при работе с веткой `feat/redesign`:
- Глобальные стили старого и нового дизайна разнесены по layout без - Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
наследования: старое в `web/src/app.css` (группа `(old)/`), новое в `web/src/app_v1.css` (маршруты `/v1/*`).
`web/src/lib/styles/design2.css` (ветка `preview/*`). Префикс `.ds2` не
используется.
- Все повторяющиеся визуальные элементы — отдельные компоненты в - Все повторяющиеся визуальные элементы — отдельные компоненты в
`web/src/lib/components/kit/`, даже «просто div с двумя стилями». `web/src/lib/components/`, даже «просто div с двумя стилями».
### Линтинг дизайн-токенов («единая точка правды» дизайна) ### Линтинг дизайн-токенов
Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и z-index Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и z-index
обязаны приходить из CSS-переменных. Стек: **stylelint** прогоняется по всем обязаны приходить из CSS-переменных. Прогон: `pnpm --dir web lint:all`.
CSS-файлам (`web/stylelint.config.js`), **ESLint** лезет в `<style>`-блоки
svelte-компонентов через локальный плагин `web/eslint-plugins/design-tokens/`
(постпрефес postcss AST от svelte-eslint-parser). Правила применяются к тем же
scoped-путям (`kit/**`, `preview/**`).
Команды: - **Цвета**: только `hct(...)` в `app.css`, seed-токены
(`--brand-main`/`--brand-alt`) и `color-mix(...)` — исключения.
- `pnpm --dir web lint:css` — stylelint по всем `src/**/*.css` (`old.css` и
`node_modules/build/.svelte-kit/static` игнорируются).
- `pnpm --dir web exec node scripts/check-tokens.mjs` — токен-аудит по
preview.css (лёгкий оркестратор поверх `web/scripts/token-audit/*`);
проверяет три вещи. Каждый цветовой
токен из `:root` обязан иметь пару в `[data-theme="dark"]` и наоборот.
Производные токены (значение содержит `var()`, напр. `hct(from var(--...))`)
из пары исключены — они наследуют тему автоматически. Выводит **варнинг** о
неиспользуемых токенах preview.css (определены, но нигде не используются) —
выход он не меняет. Провалом (exit 1) считаются только parity и hct-авторство.
- Тот же скрипт проверяет **hct-only для цветов**: ЛЮБОЕ цветовое значение в
preview.css обязано быть `hct(...)` — и литерал, и производное
`hct(from var(...) h c t)` (вся палитра считается через hct-каналы; эммит в
sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`). Исключения:
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
`color-mix(...)` (единственный легальный способ смешать два токена).
`oklch()/rgb()/#hex` в `--color-*` запрещены. `oklch()/rgb()/#hex` в `--color-*` запрещены.
- **Размеры**: `--space-*`, `--text-*`, `--radius-*`, `--size-*`.
- **Breakpoints**: `@custom-media --bp-*` (объявления в `app.css`, используются
как `@media (--bp-*)`).
- **z-index**: `--z-*`; **длительности**: `--duration-*`, `--ease-*`.
- В `<style>` svelte-компонентов: нельзя хардкодить цвета/размеры/длительности,
нельзя использовать необъявленные `var(--x)`, нельзя путать категории
(color-токен в size-свойстве).
Токены-префиксы (целевой словарь дизайна): Детали: полный список правил плагина, токены-префиксы, настройка stylelint — см.
`web/eslint-plugins/README.md`.
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные ### Изоляция веток old ↔ new
две переменные, которым разрешено быть hex/rgb, остальные цвета — только
`hct()` (эммит в sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`)
и только в preview.css.
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в preview.css,
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
подтягиваются через `@csstools/postcss-global-data`).
- 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`): Старый (`v1/`) и новый UI полностью изолированы: ESLint-правило
`isolation/no-mixed-imports` резолвит каждый импорт до файла и запрещает
смешивание.
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/ - Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
длительностей/z-index и `color-mix()` (результат токенизировать в CSS). Изменения делать небольшими (< ~500 строк), атомарными.
Исключения размеров: `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`-зависим).
### Изоляция веток old ↔ preview
`isolation/no-mixed-imports` (`web/eslint-plugins/isolation/no-mixed-imports.js`,
включён на весь `**/*.{ts,svelte}`) — **полная взаимная изоляция** старого UI и
новой preview-ветки. В отличие от `no-restricted-imports`, правило **резолвит**
каждый импорт (и `$lib/...`, и относительные `./`/`../`) до реального файла и
классифицирует стороны по фактическому пути, поэтому относительным импортом
правило не обойти.
- Старое: `routes/(old)/**`, `lib/old/**`.
- Новое: `routes/preview/**`, `lib/registry-new/**`, `lib/preview/**`,
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`, `lib/components/kit/**`.
- Общее (разрешено обоим): `core/`, `i18n/`, `theme`, `assets/`, корневой `lib`.
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
glob-паттернов в настройке, а не код правила.
- Изоляция уже достигнута: старый `registry/` не тянет `registry-schema`
(пилоты add-border/add-stroke работают через `params`), `registry.ts` не
импортирует `ToolSchema`; preview-`list-tools` использует копию
`preview/tool-icons.ts`, а не старый `tools/tool-icons.ts`.
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
preview.css) — известный техдолг, его НЕ чинить и не игнорировать правилами.
- Коммиты мелкие (< ~500 строк), атомарные
@@ -1,8 +1,14 @@
# План C17: перенос preview → корень + (old) → /v1/ # План C17: перенос preview → корень + (old) → /v1/
> Статус: план к выполнению. Предпосылки: tech debt закрыт, backlog №14 (старые > **СТАТУС: ВЫПОЛНЕН (2026-09-11) — устарел.** Перенос завершён:
> файлы → lib/old/) выполнен. Домен НЕ блокер (см. plan-redesign.md §10, > preview-страницы на реальных маршрутах, старый дизайн на `/v1/*`,
> plan-seo.md §6). > `app.css`/`app_v1.css`, isolation-плагин на v1-путях, аудит-скрипты в
> `scripts/archive/`. C18 (правки импортов/редиректов, проверка билда) тоже
> выполнен. Следующий этап — C19–C21 (`plan-redesign.md`, §10).
>
> Оригинальный статус до выполнения: план к выполнению. Предпосылки: tech debt
> закрыт, backlog №14 (старые файлы → lib/old/) выполнен. Домен НЕ блокер (см.
> plan-redesign.md §10, plan-seo.md §6).
## Цель ## Цель
+3 -3
View File
@@ -64,9 +64,9 @@
**Вариант Б (страховка): руками, пакетами по компонентам.** Если скрипт не **Вариант Б (страховка): руками, пакетами по компонентам.** Если скрипт не
удался — чистим/заводим токены вручную, пакетами по компонентам (1–2 на задачу, удался — чистим/заводим токены вручную, пакетами по компонентам (1–2 на задачу,
дифф < ~500 строк). Отсутствующие токены (например `--space-2xl`/`--space-panel`, дифф < ~500 строк). Отсутствующие токены (например
уже дважды обходились в SchemaTextSource) добавляем в словарь `preview.css` `--space-2xl`/`--space-panel`, уже дважды обходились в SchemaTextSource)
осознанно. добавляем в словарь `preview.css` осознанно.
Гейт этапа: `pnpm --dir web lint:all` — только допустимый остаток Гейт этапа: `pnpm --dir web lint:all` — только допустимый остаток
(неиспользуемые токены — варнинг), плюс регресс-прогон тестов и svelte-check. (неиспользуемые токены — варнинг), плюс регресс-прогон тестов и svelte-check.
+7 -3
View File
@@ -26,12 +26,14 @@
паспортом, семейства с кириллицей) паспортом, семейства с кириллицей)
- [ ] Посмотреть темную тему - слишком темная??? - [ ] Посмотреть темную тему - слишком темная???
- [ ] на картинке результате при работе дергается высота надписи (высота иконки) - [ ] на картинке результате при работе дергается высота надписи (высота иконки)
- [ ] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно) - [x] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно)
[баг в хроме](https://issues.chromium.org/issues/556160936) [баг в хроме](https://issues.chromium.org/issues/556160936)
`Uncaught TypeError: Cannot read properties of undefined (reading 'startTime')` `Uncaught TypeError: Cannot read properties of undefined (reading 'startTime')`
`at et.reportAllChanges (anonymous:2:19429)` `at et.reportAllChanges (anonymous:2:19429)`
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone) - [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
- [ ] не работают генераторы картинок (create-empty-png) - [x] не работают генераторы картинок (create-empty-png)
- [ ] генераторы работают не автоматически - требуется нажать кнопку generate
(create-empty-png)
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width - - [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку 100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность - [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность
@@ -55,11 +57,13 @@
## Переезд ## Переезд
- [x] составить план переноса preview -> корень, старый корень -> old - [x] составить план переноса preview -> корень, старый корень -> old
- [x] сам перенос (C17) — `docs/archive/plan-c17.md`
- [ ] добавить i18n в preview - [ ] добавить i18n в preview
## SEO/GEO ## SEO/GEO
Общий план: `docs/plan-seo.md` (S0S4). Старт — после C17. Общий план: `docs/plan-seo.md` (S0S4). C17 выполнен; старт возможен — пока
только domain-free часть S1.
- [ ] переименование бренда + домен: чек-лист в локальном плане - [ ] переименование бренда + домен: чек-лист в локальном плане
`docs/plan-domain.local.md` (вне git, имя домена в репо не публикуется; `docs/plan-domain.local.md` (вне git, имя домена в репо не публикуется;
+32 -30
View File
@@ -1,12 +1,11 @@
# План: переезд на новый дизайн (refs) — параллельная сборка # План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: план к выполнению. Шаги 5–6 (C17–C21) остаются **следующим этапом**. > Статус: C17 **выполнен** (`docs/archive/plan-c17.md`). Остались C18C21 (шаг
> Они больше не привязаны к «верности против рефа»: > 5–6) — **следующий этап**. Они больше не привязаны к «верности против рефа»:
> `docs/archive/plan-design-fix.md` закрыт, аудит против рефа прекращён. > `docs/archive/plan-design-fix.md` закрыт, аудит против рефа прекращён. Tech
> Tech debt закрыт (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд > debt закрыт (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд старых
> старых файлов в `old/`) — **выполнено**. Покупка домена НЕ блокер C17: > файлов в `old/`) — **выполнено**. Покупка домена НЕ была блокером C17: домен
> домен нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`. > нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
> C17 можно запускать без домена (см. §10).
> >
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву), > Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере. > `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
@@ -241,11 +240,11 @@ src/routes/preview/tools/[id]/+page.svelte
маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений. маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений.
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`, - **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`,
старого `app.css` (и его токенов), неиспользуемых классов; переименование старого `app.css` (и его токенов), неиспользуемых классов; наведение порядка в
`kit/``ui/` (финальное имя) либо оставление `kit/` как основного — решается `components/` (имя каталога и подпапок — решается на фазе 1; к C17 `kit/` уже
на фазе 1. Финальный проход по брейкпоинтам 1200/1100/800/480. Критерий: разложен в `components/`). Финальный проход по брейкпоинтам 1200/1100/800/480.
`grep` по старым токенам/классам пуст, build чистый, старый дизайн нигде не Критерий: `grep` по старым токенам/классам пуст, build чистый, старый дизайн
упоминается. нигде не упоминается.
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня), Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня),
Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня). Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня).
@@ -265,9 +264,9 @@ src/routes/preview/tools/[id]/+page.svelte
## 9. Открытые вопросы ## 9. Открытые вопросы
- **Имя финального каталога компонентов:** оставить `kit/` как основное имя или - **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
переименовать в `ui/` на фазе 5 (удалив старое `ui/`). Рекомендую сразу писать разложил его в `components/` (подпапки fields/layout/ui). На C19 — только
в `kit/`, на фазе 5 переименовать в `ui/`. Решить на фазе 1. навести порядок внутри `components/`, переименование не требуется.
- **Изоляция через layout:** `app.css` вынесен из корневого layout в - **Изоляция через layout:** `app.css` вынесен из корневого layout в
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`. `(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
@@ -358,15 +357,14 @@ src/routes/preview/tools/[id]/+page.svelte
неконсистентен. (доказательство отсутствия регресса). Фиксы по результатам неконсистентен. (доказательство отсутствия регресса). Фиксы по результатам
— отдельными мелкими коммитами. — отдельными мелкими коммитами.
### Шаг 5. Сделать новый дизайн основным — СЛЕДУЮЩИЙ ЭТАП ### Шаг 5. Сделать новый дизайн основным
> Tech debt закрыт. Backlog №14 (переезд старых файлов в `lib/old/`) — **выполнено**. > C17 — **выполнен** (`docs/archive/plan-c17.md`): старый `(old)/` → `/v1/`,
> Покупка домена — НЕ блокер C17. C17 переносит маршруты и делает новый дизайн > preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки
> основным; `noindex` остаётся до покупки домена и запуска S1 (`plan-seo.md`). > импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались:
> Детальный план см. в `docs/plan-c17.md` (старый `(old)/` → `/v1/`, удаление > C19 (удаление старого, раскрытие `recommended` на весь код), C20, C21.
> отложено до C19).
- [ ] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить - [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый `(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
shell. Убрать флаг/кнопку. **Снять `noindex, nofollow`** со страницы shell. Убрать флаг/кнопку. **Снять `noindex, nofollow`** со страницы
инструмента (`preview/tools/[id]/+page.svelte`) — страницы становятся инструмента (`preview/tools/[id]/+page.svelte`) — страницы становятся
@@ -376,17 +374,21 @@ src/routes/preview/tools/[id]/+page.svelte
вместе с подключением canonical в S1. **И обновить `newCode` в вместе с подключением canonical в S1. **И обновить `newCode` в
`web/eslint.config.js`:** заменить `src/routes/preview/**` на `web/eslint.config.js`:** заменить `src/routes/preview/**` на
`src/routes/**` (после переезда все маршруты — новый дизайн), компонентный `src/routes/**` (после переезда все маршруты — новый дизайн), компонентный
glob `src/lib/components/kit/**` пока оставить. glob `src/lib/components/kit/**` пока оставить. _(Итог: `newCode` обновлён
- [ ] **[C18]** Правки импортов/редиректов после переноса, проверка билда. (`components/**`, `routes/**`), `noindex` оставлен до S1 — домен ещё не
куплен. Детали: `docs/archive/plan-c17.md`.)_
- [x] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
_(Билд собирается без ошибок, линт/свелт-чека нет — выполнен.)_
### Шаг 6. Удаление старого ### Шаг 6. Удаление старого
- [ ] **[C19]** Удалить старое `ui/`, прочие старые компоненты, старый - [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
`app.css`, неиспользуемые классы. Переименовать `kit/``ui/` (если `app_v1.css` (и его токены), неиспользуемые классы. К этому моменту `kit/`
решено на C1 — тогда в `eslint.config.js` обновить и компонентный glob). уже упразднён (C17 разложил его в `components/`), навести порядок в
**И раскрыть ESLint `recommended` на весь код** — убрать scoped-блок `components/` (согласовать подпапки, финальное имя каталога). **И раскрыть
`newCode` в `web/eslint.config.js` (см. AGENTS.md), прогнать `lint` и ESLint `recommended` на весь код** — убрать scoped-блок `newCode` в
починить всплывшие ошибки в оставшемся коде. _Примечание: доменные модули `web/eslint.config.js` (см. AGENTS.md), прогнать `lint` и починить
всплывшие ошибки в оставшемся коде. _Примечание: доменные модули
(`lib/core`, `lib/registry`, тесты и т.п.) — это не «старый дизайн», у них (`lib/core`, `lib/registry`, тесты и т.п.) — это не «старый дизайн», у них
свой линт-долг; расширение `recommended` на них может выдать много ошибок. свой линт-долг; расширение `recommended` на них может выдать много ошибок.
Решить на C19: либо чиним сразу, либо расширяем scoped только на Решить на C19: либо чиним сразу, либо расширяем scoped только на
+15 -15
View File
@@ -1,9 +1,9 @@
# План: SEO + GEO — индексация и конкуренция в image tools # План: SEO + GEO — индексация и конкуренция в image tools
> **Статус (2026-09-10):** план к выполнению. Старт — после C17 > **Статус (2026-09-11):** план к выполнению. C17 выполнен — `preview/*`
> (`plan-redesign.md`): до переезда `preview/*` на реальные маршруты страницы > переехали на реальные маршруты (в `noindex` сохраняются до запуска S1, см.
> инструментов в `noindex`, применять техминимум некуда. > `plan-redesign.md` C17). Сейчас применять можно domain-free часть S1.
> **Уточнено:** домен НЕ блокер C17 (см. §6). Задачи S1 разбиты на > **Уточнено:** домен не был блокером C17 (см. §6). Задачи S1 разбиты на
> domain-free (можно делать параллельно с покупкой домена) и domain-required > domain-free (можно делать параллельно с покупкой домена) и domain-required
> (после покупки и настройки домена на Vercel). > (после покупки и настройки домена на Vercel).
> >
@@ -136,8 +136,8 @@
### S1. Технический минимум (сразу после C17) ### S1. Технический минимум (сразу после C17)
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
и **domain-required** (после покупки и DNS-настройки). **domain-required** (после покупки и DNS-настройки).
#### S1-free: без домена (параллельно с C18–C19) #### S1-free: без домена (параллельно с C18–C19)
@@ -167,16 +167,16 @@
из `pnpm build` после `vite build` (adapter-static пишет в `build/`, из `pnpm build` после `vite build` (adapter-static пишет в `build/`,
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
опционального `BASE_PATH`. опционального `BASE_PATH`.
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты - [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до
(`/kit` до C19; после — вернуть минимальный allow-all). C19; после — вернуть минимальный allow-all).
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по - [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
актуальному маршруту), OG-теги (title/description/type/url/image), актуальному маршруту), OG-теги (title/description/type/url/image),
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`** twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
для абсолютных URL в canonical и OG:url. для абсолютных URL в canonical и OG:url.
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d. > **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d. Гейты: build
> Гейты: build зелёный; в `build/` лежит корректный `sitemap.xml` со всеми > зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
> инструментами; canonical/OG присутствуют на всех страницах; e2e зелёные. > canonical/OG присутствуют на всех страницах; e2e зелёные.
### S2. Контентный слой (параллельно C18–C19, волнами по категориям) ### S2. Контентный слой (параллельно C18–C19, волнами по категориям)
@@ -329,10 +329,10 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и - [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain: настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`. canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). - **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2
R2 (домен) — блокер только для S1-domain (canonical + sitemap + снятие (домен) — блокер только для S1-domain (canonical + sitemap + снятие
`noindex`). После индексации переименование = переезд с 301, потеря позиций `noindex`). После индексации переименование = переезд с 301, потеря позиций и
и ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17. ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17.
## 7. Что осознанно НЕ делаем ## 7. Что осознанно НЕ делаем
+7 -7
View File
@@ -1,12 +1,12 @@
# План разработки: easy-png-tools # План разработки: easy-png-tools
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — > **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
> сайт живёт, каталог переведён в типизированный `registry-new` (121/125), идёт > каталог переведён в типизированный `registry` (121/125), новый дизайн по
> редизайн на `preview/*` по `plan-redesign.md` (параллельная ветка, старый UI > `plan-redesign.md` стал основным на корневых маршрутах (C17 выполнен), старый
> на `(old)/`). Tech debt закрыт. Переезд старых файлов в `old/` выполнен. > UI — на `/v1/*` (удаление отложено до C19). Tech debt закрыт. Следующий этап:
> Следующий этап: C17 (перенос preview → корневые маршруты). Домен НЕ блокер > C18C21 (`plan-redesign.md`, §10). Домен нужен для SEO (`plan-seo.md`,
> C17, нужен для SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10 > S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
> спланированы в `docs/plan-platform.md`. > `docs/plan-platform.md`.
## 0. Решения ## 0. Решения
+2 -4
View File
@@ -4,14 +4,12 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "pnpm --dir web test",
"dev": "pnpm --dir web dev", "dev": "pnpm --dir web dev",
"build": "pnpm --dir web build", "build": "pnpm --dir web build",
"format": "pnpm format:docs && pnpm --dir web format", "format": "pnpm format:docs && pnpm --dir web format",
"format:docs": "prettier --write docs --log-level warn", "format:docs": "prettier --write docs --log-level warn",
"check:docs": "prettier --check docs", "check:docs": "prettier --check docs"
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html",
"cdp-audit": "pnpm --dir web refs-cdp-audit:large"
}, },
"keywords": [], "keywords": [],
"author": "Ku6epXBOCTuK", "author": "Ku6epXBOCTuK",
+148
View File
@@ -0,0 +1,148 @@
# ESLint-плагины и дизайн-токены: детали
Эта часть репозитория подробно описана здесь, а не в корневом `AGENTS.md`:
рутина-разработка её не требует, детали нужны только при правке самих
линт-правил или конфигов.
## Структура
- `web/eslint-plugins/` — локальные ESLint-плагины.
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
- `isolation/` — правило изоляции old/new UI.
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
токенов) и файлы для изоляционных тестов.
- `web/scripts/`:
- `lint-all.mjs` — оркестратор `lint:all`.
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
- `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css.
## Конфигурация ESLint (`web/eslint.config.js`)
Устроена инкрементально:
- На **весь код** — парсинг TS/Svelte + правило
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
`prefer: 'type-imports'`).
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
`eslint-plugin-svelte`) навешаны **только на новый код**
(`src/lib/components/**`, `src/routes/**`), чтобы старый код не засыпался
предсуществующими ошибками. Старый код (C17) — `src/lib/v1/**`,
`src/routes/v1/**`. Когда старый дизайн удалён (C19), scoped-блок убирается и
`recommended` включается на весь код.
- **Scoped-пути двигаются вместе с папками** (см. `plan-redesign.md` §10): не
оставлять устаревшие пути в `eslint.config.js` (C17 уже двигал
`routes/preview/**``routes/**` и `components/kit/**``components/**`; на
C19 при наведении порядка в `components/` paths останутся `components/**` +
`routes/**`).
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
`let`).
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
там запрещены tseslint) — при добавлении файлов в `__tests__/` /
`__fixtures__/` дописать их туда же.
- design-tokens правила применяются к тем же scoped-путям
(`src/lib/components/**`, `src/routes/**`).
- isolation-правило включено на весь `**/*.{ts,svelte}`.
## Правила плагина design-tokens
- `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/app.css` (словарь токенов) или локально в компоненте. Файл
читается один раз и кэшируется (не `glob`-зависим).
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
переменные, которым разрешено быть hex/rgb, остальные цвета — только `hct()` и
только в app.css.
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в app.css,
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
подтягиваются через `@csstools/postcss-global-data`).
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
Нюансы stylelint:
- Правило `custom-property-pattern` тестирует паттерн **без** `--` (`--x`
`x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
`ignore`, не в `except`): пустые строки между подряд идущими токенами
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
токены в app.css отдельными блоками.
## Токен-аудит (`check-tokens.mjs`)
Лёгкий оркестратор поверх `web/scripts/token-audit/*`. Проверяет:
- **Parity**: каждый цветовой токен из `:root` обязан иметь пару в
`[data-theme="dark"]` и наоборот. Производные токены (значение содержит
`var()`, напр. `hct(from var(--...))`) из пары исключены — они наследуют тему
автоматически.
- **hct-only для цветов**: ЛЮБОЕ цветовое значение в app.css обязано быть
`hct(...)` — и литерал, и производное `hct(from var(...) h c t)`. Исключения:
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
`color-mix(...)` (единственный легальный способ смешать два токена).
`oklch()/rgb()/#hex` в `--color-*` запрещены.
Выводит **варнинг** о неиспользуемых токенах app.css (определены, но нигде не
используются) — выход он не меняет. Провалом (exit 1) считаются только parity и
hct-авторство.
## Тесты кастомных линт-правил
Линт-правила покрыты юнит-тестами (Vitest, `test:rules`):
- `__tests__/design-tokens.test.ts` — три чистых правила
(`no-hardcoded-in-svelte`, `no-category-mismatch`,
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
`__fixtures__/src/app.css` (не из реального `src/app.css`).
- `__tests__/no-mixed-imports.test.ts` — isolation-правило через `Linter` API с
`cwd` на `__fixtures__`: правило резолвит импорты по реальным файлам, поэтому
цели импортов обязаны существовать на диске.
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__`
`process.cwd()` не трогается, реальные `src/` не читаются.
Запуск: `pnpm --dir web test:rules` (`vitest run eslint-plugins/__tests__`).
Полный `pnpm --dir web test` тоже их гоняет.
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
добавление/правка стабов — тоже работа с валидным кодом.
## Isolation-правило (`isolation/no-mixed-imports`)
**Полная взаимная изоляция** старого (v1) и нового (корень) UI. В отличие от
`no-restricted-imports`, правило **резолвит** каждый импорт (и `$lib/...`, и
относительные `./`/`../`) до реального файла и классифицирует стороны по
фактическому пути, поэтому относительным импортом правило не обойти.
- Старое: `routes/v1/**`, `lib/v1/**`.
- Новое: `routes/**` без `v1/**`, `lib/components/**`, `lib/registry/**`,
`lib/catalog.ts`, `lib/categories.ts`, `lib/tool-icons.ts`,
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`.
- Общее (разрешено обоим): `core/`, `i18n/`, `theme`, `assets/`, корневой `lib`.
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
glob-паттернов в настройке, а не код правила.
- Изоляция уже достигнута: старый `lib/v1/registry.ts`/`registry-helpers.ts` не
тянут `registry-schema` (пилоты add-border/add-stroke работают через
`params`), `registry.ts` не импортирует `ToolSchema`; новый `list-tools`
использует `$lib/tool-icons.ts`, а не старые `lib/v1/tools/tool-icons.ts`.
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
`app_v1.css`) — известный техдолг, его НЕ чинить и не игнорировать правилами.
@@ -0,0 +1,11 @@
:root {
--color-fg: #101010;
--space-1: calc(var(--size-1) / 2);
--z-nav: 100;
--duration-fast: 120ms;
--text-md: 0.875rem;
}
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1,2 @@
// Fixture stub for lint-rule tests: content is ignored (the test supplies the
// source code), this file only has to EXIST for import resolution.
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1,199 @@
// Tests for the design-tokens plugin rules (web/eslint-plugins/design-tokens).
//
// Strategy:
// - no-hardcoded-in-svelte, no-category-mismatch, no-token-definition-in-svelte
// are pure AST checks -> standard RuleTester with string cases.
// - no-undefined-in-svelte reads the token dictionary from <cwd>/src/app.css,
// so it runs through the Linter API with cwd pinned to the fixtures dir
// (see __tests__/helpers.ts). The fixture dictionary has exactly five
// tokens; anything else must be reported even if it exists in the REAL
// web/src/app.css.
import { RuleTester, Linter } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
import noCategoryMismatch from "../design-tokens/no-category-mismatch.js";
import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js";
import noUndefined from "../design-tokens/no-undefined-in-svelte.js";
import { verifyInFixtures, type FlatConfig } from "./helpers.js";
const parserOptions = {
parser: tseslint.parser,
};
// Vitest doesn't expose describe/it as globals (no `globals: true` in the
// config), so RuleTester would fall back to its synchronous default handler.
// Register the real ones so each case becomes a proper vitest test.
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions,
},
});
/** Wrap CSS from a <style> block into a minimal Svelte component. */
const component = (style: string): string =>
`<div class="box">x</div>\n\n<style>\n${style}\n</style>\n`;
const frame = (style: string) => ({
code: component(style),
filename: "Component.svelte",
});
describe("design-tokens/no-hardcoded-in-svelte", () => {
ruleTester.run("no-hardcoded-in-svelte", noHardcoded, {
valid: [
frame(".box { color: var(--color-fg); }"),
frame(".box { padding: var(--space-3); }"),
frame(".box { background-color: transparent; }"),
frame(".box { width: 100%; }"),
frame(".box { line-height: 1.5; }"),
frame(".box { transition: var(--duration-fast); }"),
frame(".box { z-index: var(--z-nav); }"),
frame(".box { font-size: var(--text-md); }"),
frame("<div>no style block at all</div>"),
],
invalid: [
{
...frame(".box { color: #ff0000; }"),
errors: [{ messageId: "hardcodedColor" }],
},
{
...frame(".box { padding: 16px; }"),
errors: [{ messageId: "hardcodedSize" }],
},
{
...frame(".box { transition: 200ms; }"),
errors: [{ messageId: "hardcodedDuration" }],
},
{
...frame(".box { z-index: 100; }"),
errors: [{ messageId: "hardcodedZIndex" }],
},
{
...frame(
"@media (max-width: 640px) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "hardcodedBreakpoint" }],
},
{
...frame(
"@media (min-width: var(--bp-m)) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "varInMedia" }],
},
{
...frame(
".box { color: color-mix(in srgb, var(--color-a), var(--color-b)); }",
),
errors: [{ messageId: "colorMix" }],
},
],
});
});
describe("design-tokens/no-category-mismatch", () => {
ruleTester.run("no-category-mismatch", noCategoryMismatch, {
valid: [
frame(".box { padding: var(--space-1); }"),
frame(".box { color: var(--color-fg); }"),
frame(".box { border: 1px solid var(--color-border); }"),
],
invalid: [
{
...frame(".box { padding: var(--color-fg); }"),
errors: [{ messageId: "categoryMismatch" }],
},
{
...frame(".box { color: var(--space-1); }"),
errors: [{ messageId: "categoryMismatch" }],
},
],
});
});
describe("design-tokens/no-token-definition-in-svelte", () => {
ruleTester.run("no-token-definition-in-svelte", noTokenDefinition, {
valid: [
frame(".box { --local: var(--color-fg); }"),
frame(".box { --local: calc(var(--space-1) * 2); }"),
frame(".box { --ratio: 1.5; }"),
],
invalid: [
{
...frame(".box { --local: #ff0000; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: 12px; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: oklch(0.5 0.1 240); }"),
errors: [{ messageId: "tokenPrimitive" }],
},
],
});
});
describe("design-tokens/no-undefined-in-svelte", () => {
const undefinedConfig: FlatConfig = [
{
files: ["**/*.svelte"],
plugins: {
"design-tokens": { rules: { "no-undefined-in-svelte": noUndefined } },
},
rules: { "design-tokens/no-undefined-in-svelte": "error" },
languageOptions: {
parser: svelteParser,
parserOptions,
},
},
];
it("accepts tokens defined in the fixture app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-fg); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("accepts component-local overrides", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { --local-x: var(--color-fg); color: var(--local-x); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("flags an unknown token even if it exists in the real web/src/app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-love); }"),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--color-love");
});
it("flags unknown tokens referenced inside @media params", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(
"@media (width >= var(--bp-xs)) { .box { width: var(--text-md); } }",
),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--bp-xs");
});
});
+34
View File
@@ -0,0 +1,34 @@
// Shared harness for testing the custom ESLint rules in eslint-plugins/.
//
// Two strategies, picked per rule in the test files:
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
// - Linter API + fixtures — needed when a rule reads real files. The isolation
// rule resolves imports via fs (targets must exist on disk) and
// no-undefined-in-svelte reads <cwd>/src/app.css for the token dictionary.
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
// work without touching process.cwd() or the real src/ tree.
import { Linter } from "eslint";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const FIXTURES_DIR = path.resolve(HERE, "../__fixtures__");
export const FIXTURES_SRC = path.join(FIXTURES_DIR, "src");
/** Flat-config shape the Linter.verify accepts. */
export type FlatConfig = Parameters<Linter["verify"]>[1];
/**
* Run a rule (or a config) against `code` as if it were the fixture file
* `relFile` under `__fixtures__/src`. Returns the lint messages.
*/
export function verifyInFixtures(
config: FlatConfig,
code: string,
relFile: string,
) {
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
}
@@ -0,0 +1,102 @@
// Tests for the isolation rule (web/eslint-plugins/isolation/no-mixed-imports.js).
//
// The rule RESOLVES import specifiers to real files (via $lib alias and
// relative ../ paths), so every import target must exist on disk. It therefore
// runs through the Linter API with cwd pinned to the committed fixture tree
// (__fixtures__/src) — see __tests__/helpers.ts.
//
// Side map (mirrors the defaults in no-mixed-imports.js):
// old -> routes/v1/**, lib/v1/**
// new -> routes/** (minus v1), lib/components/**, lib/registry/** + a few
// shared-> everything else: core/, i18n/, theme, ...
import { describe, expect, it } from "vitest";
import noMixedImports from "../isolation/no-mixed-imports.js";
import { verifyInFixtures, type FlatConfig } from "./helpers.js";
const isolationConfig: FlatConfig = [
{
files: ["**/*.{ts,svelte}"],
plugins: { isolation: { rules: { "no-mixed-imports": noMixedImports } } },
rules: { "isolation/no-mixed-imports": "error" },
},
];
const messagesFor = (relFile: string, code: string) =>
verifyInFixtures(isolationConfig, code, relFile);
/** Assert a single "noMixed" violation with the given sides. */
function expectViolation(
relFile: string,
code: string,
side: string,
target: string,
) {
const messages = messagesFor(relFile, code);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("noMixed");
// ESLint interpolates `data` into the message text (no `data` field on the
// returned message), so assert on the rendered message instead.
expect(messages[0].message).toContain(`"${side}" file imports "${target}"`);
}
/** Assert the import is allowed (no violations). */
function expectClean(relFile: string, code: string) {
expect(messagesFor(relFile, code)).toEqual([]);
}
describe("isolation/no-mixed-imports", () => {
it("flags an old file importing a new component", () => {
expectViolation(
"lib/v1/old.ts",
'import { c } from "../components/CheckerCanvas.svelte";',
"old",
"new",
);
});
it("flags a new route importing an old module through the $lib alias", () => {
expectViolation(
"routes/+page.svelte",
'import { o } from "$lib/v1/old";',
"new",
"old",
);
});
it("flags a new component importing old code via a relative path", () => {
expectViolation(
"lib/components/CheckerCanvas.svelte",
'import { o } from "../v1/old.ts";',
"new",
"old",
);
});
it("allows old -> shared (core via relative path)", () => {
expectClean("lib/v1/old.ts", 'import { e } from "../core/errors";');
});
it("allows old -> shared (i18n and theme via $lib)", () => {
expectClean("routes/v1/+layout.svelte", 'import { t } from "$lib/i18n/t";');
expectClean(
"routes/v1/+layout.svelte",
'import { g } from "$lib/theme.svelte";',
);
});
it("allows new -> shared and new -> new", () => {
expectClean("routes/+page.svelte", 'import { t } from "$lib/i18n/t";');
expectClean(
"routes/+page.svelte",
'import { b } from "$lib/components/ui/Button.svelte";',
);
});
it("leaves shared files unrestricted in both directions", () => {
expectClean("lib/i18n/t.ts", 'import { o } from "$lib/v1/old";');
expectClean(
"lib/core/errors.ts",
'import { c } from "$lib/components/CheckerCanvas.svelte";',
);
});
});
+2 -2
View File
@@ -1,8 +1,8 @@
/** /**
* Local ESLint plugin "isolation". * Local ESLint plugin "isolation".
* *
* Guarantees full isolation between the old UI branch and the new (preview) * Guarantees full isolation between the old (v1, archived) UI branch and the
* branch. Unlike no-restricted-imports (which matches the literal import * new one. Unlike no-restricted-imports (which matches the literal import
* specifier string only), these rules RESOLVE the specifier to a real file * specifier string only), these rules RESOLVE the specifier to a real file
* (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then * (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then
* classify both sides by their actual location on disk. * classify both sides by their actual location on disk.
@@ -1,5 +1,5 @@
/** /**
* Isolation rule: forbid mixing the "old" and the "new" (preview) branches. * Isolation rule: forbid mixing the "old" (v1) and the "new" branches.
* *
* The rule RESOLVES every import specifier to a real file (handles both the * The rule RESOLVES every import specifier to a real file (handles both the
* `$lib/...` alias and relative `./` / `../` paths), classifies the source * `$lib/...` alias and relative `./` / `../` paths), classifies the source
@@ -17,13 +17,19 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
const DEFAULT_OLD = ["routes/(old)/**", "lib/old/**"]; // NEW declared as an allowlist: `lib/**` descends into v1/ and the shared dirs
// (core/, i18n/, assets/, theme), so matching by prefix would misclassify them.
// Negations (`!`) carve old paths out of `routes/**`.
const DEFAULT_OLD = ["routes/v1/**", "lib/v1/**"];
const DEFAULT_NEW = [ const DEFAULT_NEW = [
"routes/preview/**", "routes/**",
"lib/components/kit/**", "!routes/v1/**",
"lib/preview/**", "lib/components/**",
"lib/registry-new/**", "lib/registry/**",
"lib/catalog.ts",
"lib/categories.ts",
"lib/tool-icons.ts",
"lib/registry-schema.ts", "lib/registry-schema.ts",
"lib/registry-schema.test.ts", "lib/registry-schema.test.ts",
]; ];
@@ -92,7 +98,7 @@ export default {
type: "problem", type: "problem",
docs: { docs: {
description: description:
"Forbid imports between the old UI branch and the new (preview) branch.", "Forbid imports between the old (v1) UI branch and the new branch.",
category: "Best Practices", category: "Best Practices",
}, },
schema: [ schema: [
+50 -11
View File
@@ -1,16 +1,18 @@
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 isolationPlugin from "./eslint-plugins/isolation/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";
import designTokens from "./eslint-plugins/index.js";
import isolationPlugin from "./eslint-plugins/isolation/index.js";
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас. // Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
// Когда старый дизайн удалим (C19), этот scoped-блок убирается и recommended // Когда старый дизайн удалим (C19), этот scoped-блок убирается и recommended
// включается на весь код (см. план-redesign §10, шаг 6). // включается на весь код (см. план-redesign §10, шаг 6).
const newCode = ["**/src/lib/components/kit/**", "**/src/routes/preview/**"]; const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
const oldCode = ["**/src/lib/v1/**", "**/src/routes/v1/**"];
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier). // Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
const jsRecommended = Array.isArray(js.configs.recommended) const jsRecommended = Array.isArray(js.configs.recommended)
@@ -21,10 +23,19 @@ const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"])
: [svelte.configs["flat/recommended"]]; : [svelte.configs["flat/recommended"]];
// Svelte-рекомендации применяем только к .svelte-файлам нового кода, иначе // Svelte-рекомендации применяем только к .svelte-файлам нового кода, иначе
// svelte-eslint-parser "съедает" обычные .ts в тех же папках (напр. +page.ts). // svelte-eslint-parser "съедает" обычные .ts в тех же папках (напр. +page.ts).
const svelteFiles = [ const newSvelteFiles = [
"**/src/lib/components/kit/**/*.svelte", "**/src/lib/components/**/*.svelte",
"**/src/routes/preview/**/*.svelte", "**/src/routes/**/*.svelte",
]; ];
const oldSvelteFiles = [
"**/src/lib/v1/**/*.svelte",
"**/src/routes/v1/**/*.svelte",
];
// FIXME:
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
// Migrate to defineConfig(...)
// The core defineConfig(...) helper is a nearly exact clone of tseslint.config(...)
export default tseslint.config( export default tseslint.config(
{ {
@@ -60,8 +71,23 @@ export default tseslint.config(
"e2e/tools-smoke.spec.ts", "e2e/tools-smoke.spec.ts",
"e2e/generators.spec.ts", "e2e/generators.spec.ts",
"e2e/known-issues.spec.ts", "e2e/known-issues.spec.ts",
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
// tsconfig), поэтому для типизированного парсинга резолвятся
// через default-проект. Перечисляются точечно: `**` в
// allowDefaultProject запрещён tseslint.
"eslint-plugins/__tests__/design-tokens.test.ts",
"eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/t.ts",
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
"eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte",
"eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte",
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
], ],
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 12, maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32,
}, },
extraFileExtensions: [".svelte"], extraFileExtensions: [".svelte"],
}, },
@@ -100,7 +126,8 @@ export default tseslint.config(
// Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн // Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн
// удалят, расширить glob на весь код, исключив (old)/. // удалят, расширить glob на весь код, исключив (old)/.
{ {
files: svelteFiles, files: newSvelteFiles,
ignores: oldSvelteFiles,
plugins: { plugins: {
"design-tokens": designTokens, "design-tokens": designTokens,
}, },
@@ -113,9 +140,21 @@ export default tseslint.config(
}, },
// Полные recommended-наборы — только на новый код. // Полные recommended-наборы — только на новый код.
...[ ...[
...jsRecommended.map((cfg) => ({ ...cfg, files: newCode })), ...jsRecommended.map((cfg) => ({
...tseslint.configs.recommended.map((cfg) => ({ ...cfg, files: newCode })), ...cfg,
...svelteRecommended.map((cfg) => ({ ...cfg, files: svelteFiles })), files: newCode,
ignores: oldCode,
})),
...tseslint.configs.recommended.map((cfg) => ({
...cfg,
files: newCode,
ignores: oldCode,
})),
...svelteRecommended.map((cfg) => ({
...cfg,
files: newSvelteFiles,
ignores: oldSvelteFiles,
})),
], ],
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и // В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и
+2 -6
View File
@@ -11,17 +11,13 @@
"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",
"test:rules": "vitest run eslint-plugins/__tests__",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"format": "prettier --write . --log-level warn", "format": "prettier --write . --log-level warn",
"lint": "eslint .", "lint": "eslint .",
"lint:css": "stylelint \"./src/**/*.css\"", "lint:css": "stylelint \"./src/**/*.css\"",
"lint:all": "node scripts/lint-all.mjs", "lint:all": "node scripts/lint-all.mjs",
"lint:tokens": "node scripts/check-tokens.mjs", "lint:tokens": "node scripts/check-tokens.mjs"
"refs-cdp-audit": "node scripts/audit-cdp.mjs",
"refs-cdp-audit:large": "node scripts/audit-cdp.mjs --viewport 2560x1440",
"refs-cdp-audit:responsive": "node scripts/audit-cdp-responsive.mjs",
"refs-css-audit": "node scripts/audit-css.mjs",
"refs-dom-audit": "node scripts/audit-dom.mjs"
}, },
"devDependencies": { "devDependencies": {
"@csstools/postcss-global-data": "^4.0.0", "@csstools/postcss-global-data": "^4.0.0",
+4 -3
View File
@@ -1,10 +1,11 @@
/** /**
* Категории инструментов для нового registry/preview. * Категории инструментов для нового registry.
* *
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге * Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться. * выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
* Этот файл локальная копия подхода для нового UI; старый `../categories` * Этот файл локальная копия подхода для нового UI; старый
* (массив + union вручную) не трогаем он обслуживает старый UI в `(old)/`. * `lib/v1/categories` (массив + union вручную) не трогаем он обслуживает
* старый UI в `/v1/*`.
* *
* Человекочитаемые названия живут в словарях i18n: секция categories, * Человекочитаемые названия живут в словарях i18n: секция categories,
* ключ = CategoryId. * ключ = CategoryId.
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
test: { test: {
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts", "eslint-plugins/**/*.test.ts"],
environment: "node", environment: "node",
}, },
}); });