diff --git a/AGENTS.md b/AGENTS.md index a8caaa7..43f9cd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,29 @@ scoped-путям (`kit/**`, `preview/**`). определён в `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/registry.ts`, `lib/registry/**`, + `lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`, + `lib/components/**` (кроме `kit/`). +- Новое: `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) — известный техдолг, его НЕ чинить и не игнорировать правилами. diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index cf2f8a5..f0098d4 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -31,7 +31,8 @@ > step-colors, placeholder, text-to-png), text (add-text, date-stamp), > filters (randomize-pixels, add-noise) — везде, где канвас отделён от > параметров эффекта либо фигура от позиции. -> Следующее: шаг 34 — поглощение `tool-views.ts` (Фаза 5, зачистка). +> Следующее: шаг 37 — перенос всех старых компонентов/библиотек в папки `old/` +> (линтер-изоляция уже на месте, переезд — просто дописывание glob-паттерна). > > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` > (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate) @@ -61,7 +62,7 @@ `lib/preview/categories.ts` (object as const) копирует и заменяет собой `../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и остаётся без изменений. После перехода (Фаза 5) копия становится основной, -а исходник удаляется вместе со старым UI. +а исходник продолжает обслуживать `(old)` UI (он не удаляется — см. шаг 35). **Разделение схем: две независимые схемы.** @@ -81,8 +82,8 @@ Помимо двух схем, registry тоже разделён по UI (по факту миграции): - **Старый registry** (`web/src/lib/registry.ts` + `registry/` + - `registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]` и - `tool-views.ts`. **Не трогаем**; идёт под удаление вместе со старым UI. + `registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]`. + **Не трогаем**; остаётся обслуживать `(old)/` маршруты (см. шаг 35). - **Новый registry** (`web/src/lib/registry-new/`) — строится **с нуля «как надо»**: `ToolEntry
` с обязательным `schema`, типизированный `run`, **без** `ParamDef[]`
и **без** связи со старым. Импортирует core-функции (`expandCanvas`,
@@ -442,12 +443,50 @@ Typed field builders + `Field ()` + `ToolSchema ` —
(Canvas/Colors/Text), text-to-png (Text/Background/Padding);
- text: add-text, date-stamp (Text/Placement/Plate);
- filters: randomize-pixels (Blocks), add-noise (Noise/Seed).
- Инструменты с 1–3 простыми полями остались без layout (одна общая группа).
+ Инструменты с 1–3 простыми полями остались без layout (одна общая группа).
-### Фаза 5 — зачистка
+### Фаза 5 — изоляция старого UI
-34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента).
-35. После перехода на новый дизайн — удаление старого UI и старого `ParamDef[]`.
+34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента) ✔.
+ Отдельного `tool-views.ts` в репо нет: preview сразу строился на
+ `registry-new`. Meta инструмента живёт в `ToolEntry` (`title`, `description`,
+ `category`, `run`/`generate`), layout — в `schema.layout` (шаг 32), рендер —
+ `SchemaToolView`/`SchemaFields`/`SchemaPreview`. Дублирующей системы нет.
+35. Старый UI **не удаляется**, а выносится в `(old)/`-маршруты и остаётся там
+ временно (посмотреть, как работает, сравнить с новым; старые тесты
+ продолжают проходить):
+ - маршруты `(old)/{+page,demo,list-tools,tools/[id]}` — старый дизайн,
+ тянет `old.css` (не `design2.css`), старый header/footer;
+ - старый `registry.ts`/`registry/` + `ParamDef[]` обслуживают только
+ `(old)/`-инструменты — не удаляются, не рефакторятся;
+ - удаление происходит позже, отдельным решением (когда новый UI покроет
+ все инструменты и ревью завершено).
+36. **Линтер-изоляция веток** (гарантия, что old и preview не смешиваются) ✔.
+ Кастомный ESLint-плагин `web/eslint-plugins/isolation/no-mixed-imports`
+ Резолвит каждый импорт (и `$lib/...`, и относительные `./`/`../`) до
+ реального файла, классифицирует источник и цель по фактическому пути и
+ ругается на old→new и new→old. Конфигурация (`old`/`new` glob-паттерны,
+ `root`, `alias`) вынесена в настройки правила — единая точка правды:
+ - старое: `routes/(old)/**`, `lib/registry.ts`, `lib/registry/**`,
+ `lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
+ `lib/components/**` (кроме `kit/`);
+ - новое: `routes/preview/**`, `lib/registry-new/**`, `lib/preview/**`,
+ `lib/registry-schema.ts`, `lib/registry-schema.test.ts`,
+ `lib/components/kit/**`;
+ - общее (разрешено обоим): всё прочее — `core/`, `i18n/`, `theme`,
+ `assets/`, корневой `lib` (`index.ts`, тесты).
+ Достигнутая полная изоляция (одиночные пересечения устранены):
+ - старые пилоты `registry/geometry.ts` (`add-border`) и `registry/alpha.ts`
+ (`add-stroke`) получали `schema` из нового `registry-schema` — убрано;
+ оба инструмента работают в старом UI через `params: ParamDef[]`,
+ в preview — через свои schema-версии в `registry-new/`;
+ - `registry.ts` больше не импортирует `ToolSchema` из `registry-schema`;
+ - preview `list-tools` тянул `TOOL_ICONS` из старого `lib/tools/tool-icons`
+ → создана копия `lib/preview/tool-icons.ts` (правило копий).
+37. Перенос всех старых компонентов/библиотек в папки `old/` (**следующее**):
+ переезд не трогает плагин — достаточно дописать один glob-паттерн в
+ настройку правила (например `lib/old/**`, `lib/components/old/**`), а сама
+ проверка работает по фактическим путям автоматически.
### Как ревьюить каждый шаг
@@ -484,8 +523,8 @@ Typed field builders + `Field ()` + `ToolSchema ` —
на `ParamDef[]` как раньше.
- **Новый UI** (kit/`SchemaToolView` + `SchemaFields` + `SchemaPreview`, читает
`ToolSchema `) — сейчас рендерит поля по схеме (number/slider/color).
-- `tool-views.ts` — со временем поглощается registry (preview/lede/layout →
- meta инструмента). Отдельный шаг, НЕ блокирует типизацию params.
+- `tool-views.ts` отсутствует — preview/lede/layout уже живут в meta
+ инструмента (`ToolEntry` + `schema.layout`), дублирования нет.
## Оценка трудозатрат
@@ -498,15 +537,16 @@ Typed field builders + `Field ()` + `ToolSchema ` —
| Миграция инструментов на interface Params + схемы | Механическая, но крупная | ~8-12ч |
| Новый рендер (kit/ParamControl) + составные виджеты | Средняя | ~4-5ч |
| Поглощение tool-views.ts | Средняя | ~1-2ч |
-| **Итого** | | **~20-29ч** (поэтапно) |
+| Вынос старого UI в (old)/ + адаптация маршрутов | Средняя-Низкая | ~2-3ч |
+| **Итого** | | **~22-32ч** (поэтапно) |
> Оценка выросла по сравнению с ранней версией плана, потому что принят путь
> «явный interface Params + схема + общий рендер с пер-инструмент layout» —
> это полный рефакторинг pipeline, а не только добавление составных типов.
>
> Старый UI/`ParamDef[]`/старый pipeline в смету **не входят** — они не
-> рефакторятся, а продолжают работать до перехода (затем удаляются вместе со
-> старым дизайном).
+> рефакторятся, а продолжают работать на `(old)/`-маршрутах до перехода
+> (затем убираются отдельным решением — см. шаг 35).
## Порядок реализации (кратко)
@@ -525,7 +565,10 @@ Typed field builders + `Field ()` + `ToolSchema ` —
отдельно.
5. **Фаза 4** — масштаб UI на остальные: составные виджеты, пер-инструмент
layout (для каждого инструмента — UI-макет).
-6. **Фаза 5** — поглощение `tool-views.ts`, затем удаление старого UI/`ParamDef[]`
+6. **Фаза 5** — изоляция: старый UI на `(old)/`-маршруты (не удаляется,
+ остаётся для ревью); preview на `registry-new` полностью;
+ линтер-изоляция веток (плагин `isolation`), затем перенос старых
+ компонентов/библиотек в папки `old/`.
## Зависимости
diff --git a/web/eslint-plugins/isolation/index.js b/web/eslint-plugins/isolation/index.js
new file mode 100644
index 0000000..f527437
--- /dev/null
+++ b/web/eslint-plugins/isolation/index.js
@@ -0,0 +1,33 @@
+/**
+ * Local ESLint plugin "isolation".
+ *
+ * Guarantees full isolation between the old UI branch and the new (preview)
+ * branch. Unlike no-restricted-imports (which matches the literal import
+ * specifier string only), these rules RESOLVE the specifier to a real file
+ * (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then
+ * classify both sides by their actual location on disk.
+ *
+ * Why this is needed:
+ * - old code imports new things via RELATIVE paths (e.g. `../registry-schema`),
+ * which `$lib/...` glob patterns cannot catch;
+ * - the same target can be written many ways (`$lib/x`, `../x`, `../../x`),
+ * so enumerating literal strings is fragile.
+ *
+ * Configuration (per rule options):
+ * - `root`: path from the lint cwd to the source root (default "src");
+ * - `alias`: mapping from import aliases to dirs relative to root
+ * (default { "$lib": "lib" });
+ * - `old`, `new`: glob patterns (gitignore-style, `!` = negation) for the two
+ * branches. Files matched by neither are "shared" and unrestricted.
+ */
+import noMixedImports from "./no-mixed-imports.js";
+
+export default {
+ meta: {
+ name: "isolation",
+ version: "1.0.0",
+ },
+ rules: {
+ "no-mixed-imports": noMixedImports,
+ },
+};
diff --git a/web/eslint-plugins/isolation/no-mixed-imports.js b/web/eslint-plugins/isolation/no-mixed-imports.js
new file mode 100644
index 0000000..c27a01c
--- /dev/null
+++ b/web/eslint-plugins/isolation/no-mixed-imports.js
@@ -0,0 +1,201 @@
+/**
+ * Isolation rule: forbid mixing the "old" and the "new" (preview) branches.
+ *
+ * The rule RESOLVES every import specifier to a real file (handles both the
+ * `$lib/...` alias and relative `./` / `../` paths), classifies the source
+ * file and each import target by their actual location, and reports whenever an
+ * "old" file imports a "new" one or vice versa. Files that match neither side
+ * are "shared" (core/, i18n/, theme, assets, tests, ...) and can import freely.
+ *
+ * Options (object, all optional):
+ * - root: path from lint cwd to the source dir (default "src");
+ * - alias: { $lib: "lib" } — alias -> dir relative to root;
+ * - old, new: gitignore-style glob lists for the two branches. Both default
+ * to the current layout so that moving a module between branches is a
+ * one-line config change (e.g. everything old will live under `old/`).
+ */
+import fs from "node:fs";
+import path from "node:path";
+
+const DEFAULT_OLD = [
+ "routes/(old)/**",
+ "lib/registry/**",
+ "lib/registry.ts",
+ "lib/registry-helpers.ts",
+ "lib/categories.ts",
+ "lib/tools/**",
+ "lib/components/**",
+ "!lib/components/kit/**",
+];
+
+const DEFAULT_NEW = [
+ "routes/preview/**",
+ "lib/components/kit/**",
+ "lib/preview/**",
+ "lib/registry-new/**",
+ "lib/registry-schema.ts",
+ "lib/registry-schema.test.ts",
+];
+
+/** Escape everything except glob metacharacters, then handle **, *, ?. */
+function globToRegExp(glob) {
+ let out = "^";
+ for (let i = 0; i < glob.length; i++) {
+ const ch = glob[i];
+ if (ch === "*" && glob[i + 1] === "*") {
+ out += ".*";
+ i++;
+ } else if (ch === "*") {
+ out += "[^/]*";
+ } else if (ch === "?") {
+ out += "[^/]";
+ } else if ("\\^$.|?*+()[]{}".includes(ch)) {
+ out += `\\${ch}`;
+ } else {
+ out += ch;
+ }
+ }
+ return new RegExp(out + "$");
+}
+
+/** gitignore-style: last-match-wins, `!` negates earlier positives. */
+function matchesGlobList(patterns, rel) {
+ let included = false;
+ for (const raw of patterns) {
+ const negated = raw.startsWith("!");
+ const pattern = negated ? raw.slice(1) : raw;
+ if (globToRegExp(pattern).test(rel)) {
+ if (negated) return false;
+ included = true;
+ }
+ }
+ return included;
+}
+
+/** First existing candidate when resolving a bare path (no extension known). */
+function firstExisting(base) {
+ const candidates = [
+ base,
+ `${base}.ts`,
+ `${base}.svelte`,
+ `${base}.svelte.ts`,
+ `${base}/index.ts`,
+ `${base}/index.svelte`,
+ ];
+ for (const c of candidates) {
+ try {
+ if (fs.statSync(c).isFile()) return c;
+ } catch {
+ // keep trying
+ }
+ }
+ return null;
+}
+
+function toPortable(p) {
+ return p.replace(/\\/g, "/");
+}
+
+export default {
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Forbid imports between the old UI branch and the new (preview) branch.",
+ category: "Best Practices",
+ },
+ schema: [
+ {
+ type: "object",
+ properties: {
+ root: { type: "string" },
+ alias: { type: "object", additionalProperties: { type: "string" } },
+ old: { type: "array", items: { type: "string" } },
+ new: { type: "array", items: { type: "string" } },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ noMixed:
+ 'Isolation violation: "{{side}}" file imports "{{target}}" ({{targetRel}}).',
+ },
+ },
+ create(context) {
+ const options = context.options[0] || {};
+ const cwd = context.cwd;
+ const srcRoot = path.resolve(cwd, options.root || "src");
+ const alias = options.alias || { $lib: "lib" };
+ const oldPatterns = options.old || DEFAULT_OLD;
+ const newPatterns = options.new || DEFAULT_NEW;
+
+ /** Classify a path (already absolute or portable rel to srcRoot). */
+ function classify(absOrRel, relativeToRoot) {
+ const rel = relativeToRoot
+ ? absOrRel
+ : toPortable(path.relative(srcRoot, absOrRel));
+ // new wins over old if a path is matched by both (e.g. components/kit
+ // is != lib/components/** via negation, but keep precedence safe).
+ if (matchesGlobList(newPatterns, rel)) return "new";
+ if (matchesGlobList(oldPatterns, rel)) return "old";
+ return "shared";
+ }
+
+ /** Resolve a specifier to a portable rel path from srcRoot, or null. */
+ function resolveToRel(spec, currentDir) {
+ if (spec.startsWith("$")) {
+ const dot = spec.indexOf("/");
+ const mapKey = spec.slice(0, dot === -1 ? spec.length : dot);
+ const dir = alias[mapKey];
+ if (!dir) return null;
+ const rest = dot === -1 ? "" : spec.slice(dot + 1);
+ const base = path.resolve(srcRoot, dir, rest);
+ const found = firstExisting(base);
+ if (!found) return null;
+ return toPortable(path.relative(srcRoot, found));
+ }
+ if (spec.startsWith("./") || spec.startsWith("../")) {
+ const base = path.resolve(currentDir, spec);
+ const found = firstExisting(base);
+ if (!found) return null;
+ const rel = path.relative(srcRoot, found);
+ if (rel.startsWith("..")) return null; // outside srcRoot
+ return toPortable(rel);
+ }
+ return null; // package import (svelte, vitest, @lucide, ...)
+ }
+
+ const filename = context.filename || context.physicalFilename;
+ const currentAbs = path.resolve(filename);
+ const currentRel = toPortable(path.relative(srcRoot, currentAbs));
+ const currentSide = classify(currentRel, true);
+ const currentDir = path.dirname(currentAbs);
+
+ if (currentSide === "shared") return {};
+
+ function checkSource(node) {
+ const spec = node.source?.value;
+ if (typeof spec !== "string") return;
+ const targetRel = resolveToRel(spec, currentDir);
+ if (!targetRel) return;
+ const targetSide = classify(targetRel, true);
+ if (
+ (currentSide === "old" && targetSide === "new") ||
+ (currentSide === "new" && targetSide === "old")
+ ) {
+ context.report({
+ node,
+ messageId: "noMixed",
+ data: { side: currentSide, target: targetSide, targetRel },
+ });
+ }
+ }
+
+ return {
+ ImportDeclaration: checkSource,
+ ImportExpression: checkSource,
+ ExportNamedDeclaration: checkSource,
+ ExportAllDeclaration: checkSource,
+ };
+ },
+};
diff --git a/web/eslint.config.js b/web/eslint.config.js
index 7b5f600..4e64c75 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -2,6 +2,7 @@ import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import designTokens from "./eslint-plugins/index.js";
+import isolationPlugin from "./eslint-plugins/isolation/index.js";
import globals from "globals";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
@@ -114,6 +115,20 @@ export default tseslint.config(
"prefer-const": "off",
},
},
+ // ===== Изоляция старого UI (old) и нового preview =====================
+ // Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5).
+ // Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному
+ // пути (и $lib, и относительные) и ругается на old→new / new→old.
+ // Общее (core/, i18n/, theme) разрешено обоим.
+ {
+ files: ["**/*.{ts,svelte}"],
+ plugins: {
+ isolation: isolationPlugin,
+ },
+ rules: {
+ "isolation/no-mixed-imports": "error",
+ },
+ },
// prettier — последним, чтобы гасить форматирующие правила из recommended.
prettier,
);
diff --git a/web/src/lib/preview/tool-icons.ts b/web/src/lib/preview/tool-icons.ts
new file mode 100644
index 0000000..30b7523
--- /dev/null
+++ b/web/src/lib/preview/tool-icons.ts
@@ -0,0 +1,184 @@
+import {
+ AppWindow,
+ Binary,
+ Blend,
+ CalendarDays,
+ ClipboardPaste,
+ Circle,
+ Contrast,
+ Crop,
+ Crosshair,
+ Dices,
+ Droplet,
+ Droplets,
+ Eye,
+ FileImage,
+ FileOutput,
+ FilePlus2,
+ Film,
+ FlipHorizontal2,
+ Focus,
+ Frame,
+ Grid3x3,
+ Hammer,
+ Hash,
+ Image as ImageIcon,
+ Info,
+ Layers,
+ Link2,
+ Maximize2,
+ Minimize2,
+ Moon,
+ PaintBucket,
+ Palette,
+ Rainbow,
+ Repeat2,
+ RotateCw,
+ Ruler,
+ Scaling,
+ Scan,
+ Scissors,
+ SearchCheck,
+ FaceSlightlySmiling,
+ Square,
+ Stamp,
+ Star,
+ Sun,
+ Type,
+ WavesHorizontal,
+ ZoomIn,
+ ZoomOut,
+} from "@lucide/svelte";
+
+export const TOOL_ICONS: Record > = {
category: CategoryId;
sourceMode?: SourceMode;
params: ParamDef[];
- schema?: ToolSchema ;
run?: (img: PixelImage, params: P) => Promise