diff --git a/docs/plan-run-unify.md b/docs/plan-run-unify.md
new file mode 100644
index 0000000..9cc2e7e
--- /dev/null
+++ b/docs/plan-run-unify.md
@@ -0,0 +1,125 @@
+# План: единый `run(ctx)` в registry-new (унификация 5 режимов исполнения)
+
+> Статус: выполняется пошагово (ремонтограф: 11 шагов, ревью после каждого).
+> Ветка `feat/redesign`. Убивает пару багов-классов «инструмент с generate
+> показывает загрузку и не выдаёт результат» (linear-gradient-png и все
+> генераторы): причина — `input` не задан, диспетч по факту наличия методов.
+> Целевое состояние по завершении: `svelte-check` → 0 errors, `pnpm test` →
+> зелёный, `lint` → без ошибок. E2E не делаем.
+
+## Исходная проблема
+
+Два бага у `linear-gradient-png` (и у всех генераторов):
+
+1. Показывается загрузка изображения — `SchemaToolView.svelte` считает
+ `inputMode = tool.input ?? "file"`, а `tool.input` у генераторов не задан.
+2. Результата нет — `SchemaActions` рендерит кнопку «Generate» только при
+ `inputMode === "none"`, а он всегда `"file"`. Ui-контур «догадывается» о
+ генераторе по эвристике `Boolean(tool.generate && !tool.run)`.
+
+Корневая причина архитектурная: у инструмента 5 режимов исполнения (`run` 79
+шт., `generate` 21, `runFromText` 6, `toText` 9, `textToText` 1), и диспетч
+«какой метод вызывать» размазан между UI и executor. Способ исполнения
+(worker/поток, было `domOnly`) просочился в метод-диспетч. Договорились:
+**свести всё к одному методу `run(ctx)`**, а требование входа (`input`) — к
+обязательному полю. `domOnly` остаётся только executor-хинтом (уйдёт при
+переезде на WASM-ядро).
+
+## Целевой контракт
+
+```ts
+export type InputMode = "image" | "text" | "none"; // было "file"
+export type ResultKind = "image" | "text" | "verdict";
+
+export interface ToolContext
{
+ params: P;
+ source?: PixelImage; // присутствует ⇔ input === "image"
+ text?: string; // присутствует ⇔ input === "text"
+}
+
+export type ToolResult = PixelImage | string;
+
+export type ToolEntry
> = {
+ id: string;
+ title: string;
+ description: string;
+ category: CategoryId;
+ schema: ToolSchema
;
+ input: InputMode; // ОБЯЗАТЕЛЬНОЕ, дефолта нет
+ result?: ResultKind; // default "image"
+ run(ctx: ToolContext
): Promise | ToolResult;
+ output?: OutputFormat;
+ icon?: string;
+ domOnly?: boolean; // executor-хинт, уйдёт с WASM
+};
+```
+
+Удаляем: `generate`, `runFromText`, `toText`, `textToText`. Хелперы контракта
+(бросают `ToolError`): `requireSource(ctx)`, `requireText(ctx)`. Обёртки для
+миграции тел 1:1: `imgTool((img, p) => …)`, `genTool((p) => …)`,
+`textGen((t, p) => …)`.
+
+```ts
+const imgTool =
+ (fn: (img: PixelImage, p: P) => ToolResult) =>
+ (ctx: ToolContext
) =>
+ fn(requireSource(ctx), ctx.params);
+const genTool =
+
(fn: (p: P) => ToolResult) =>
+ (ctx: ToolContext
) =>
+ fn(ctx.params);
+const textGen =
+
(fn: (t: string, p: P) => ToolResult) =>
+ (ctx: ToolContext
) =>
+ fn(requireText(ctx), ctx.params);
+```
+
+## Executor (единый)
+
+```ts
+export async function execute(
+ tool: ToolEntry,
+ ctx: { params: Record; source?: PixelImage; text?: string },
+): Promise {
+ const params = sanitizeSchemaParams(tool.schema, ctx.params);
+ if (tool.input === "image" && !ctx.source)
+ throw new ToolError("errors.sourceRequired");
+ if (tool.input === "text" && !ctx.text?.trim())
+ throw new ToolError("errors.textRequired");
+ return route(tool, { params, source: ctx.source, text: ctx.text });
+}
+```
+
+`route()` — единственное место выбора места исполнения: `domOnly` или нет Worker
+→ прямой вызов `tool.run(ctx)`, иначе worker с fallback (логика
+`ensureWorker/runInWorker/disableWorker` остаётся). Worker-протокол —
+`{ id, toolId, params, source?, text? }`.
+
+## Шаги (ревью после каждого)
+
+| # | Шаг | Файлы | Что |
+| --- | --------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | Типы | `registry-new/types.ts` | новый `ToolEntry`/`ToolContext`/`ToolResult`/`InputMode "image"`, хелперы `requireSource`/`requireText` + `imgTool`/`genTool`/`textGen` |
+| 2 | Executor+Worker | `preview/executor/executor.ts`, `executor.worker.ts`, `index.ts` | единый `execute`, `route` по `domOnly`, протокол `source?`/`text?` |
+| 3 | UI kit | `kit/SchemaToolView.svelte`, `SchemaPreview.svelte`, `SchemaActions.svelte`, `SchemaSourceTile.svelte` | одна `run()`, без `isGenerator`-эвристики, `inputMode` → `"image"` |
+| 4 | Генераторы | `registry-new/generate.ts` | 21 `generate(p)` → `run(ctx)` + `input: "none"` (чинит градиент и др.) |
+| 5 | Текст | `registry-new/convert.ts`, `analyze.ts` | `runFromText`/`toText`/`textToText` → `run`; `input: "image"` на run-инструментах |
+| 6 | Текст | `registry-new/text.ts` | domOnly run-инструменты → `run(ctx)` + `input: "image"` |
+| 7 | Геометрия | `registry-new/geometry.ts` | run → `run(ctx)` + `input: "image"` |
+| 8 | Альфа/цвет | `registry-new/alpha.ts`, `color.ts` | run → `run(ctx)` + `input: "image"` |
+| 9 | Фильтры | `registry-new/filters.ts` | run → `run(ctx)` + `input: "image"` |
+| 10 | Тесты | `registry-new/registry-new.test.ts` | `generate!`/`run!` → `run(ctx)`; guard-тест: `input` всегда задан, image-инструменты работают с источником, `none` — без |
+| 11 | Гейт | — | `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, `pnpm --dir web lint`, `pnpm --dir web test` |
+
+Примечание по миграции: `input: "image"` добавляется всем run-инструментам (~80
+шт.), `input: "text"` уже стоит у 7 text-инструментов, `input: "none"` — у 21
+генератора.
+
+## Известные последствия
+
+- **Красная сборка с шага 1 до конца шага 10** — осознанное решение, ревью идёт
+ по diff'у шагов.
+- Старый контур (`registry/**` + `lib/tools/executor.ts` + `ToolPage.svelte`,
+ `sourceMode`) НЕ трогаем — он на удаление (C19).
+- `input: "file"` переименован в `input: "image"` — семантика «нужно именно
+ изображение», а не «файл» (между инструментами может передаваться не файлом).