diff --git a/docs/backlog.md b/docs/backlog.md
index 430bbda..d9c605d 100644
--- a/docs/backlog.md
+++ b/docs/backlog.md
@@ -20,3 +20,11 @@
6. **Избранные инструменты (fav tools)** — оценка S/M.
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке страницы инструмента; список id в localStorage. Избранное показывается отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к popularity при скоринге).
+
+7. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download** — оценка M/L.
+ Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др. форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого — бесшовное внедрение выбора формата/качества прямо в кнопку/диалог **Download**: юзер скачивает результат в нужном формате (mime/ext/quality), а pipeline при этом не усложняется лишним звеном.
+ Связано с планом типизации: в новом `ToolEntry
` под `run` в `registry-new` нет поля `output` (mime/ext/qualityParamId), которое есть у старых конвертеров — см. `plan-composite-params.md`, замечание по Фазе 2. Формат-метаданные должны переехать в понятие «формат результата» на уровне скачивания, а не отдельного инструмента.
+ **Решение «не сейчас» (исследование, 2026-09):**
+ - В старом UI этот паттерн уже работает: `convert-*`-инструменты не конвертируют в `run`, а только настраивают `output` (mime/ext/qualityParamId), который читает `DownloadButton` → `encode(img, mime, quality)`. То есть формат уже фактически «поле скачивания» в старом pipeline.
+ - В новом preview `registry-new` поля `output` нет, `executor.worker` возвращает только пиксели, а `SchemaToolView.download()` захардкожен на `image/png` + имя `with-border.png`. Делать ретработу сейчас = параллельный спец-проект.
+ - **Когда делать:** вместе с достройкой нового download/сохранения в новом UI (в рамках `plan-composite-params`, Фаза 4/UI). Тогда выбор формата встраивается в download-flow естественно, без выдёргивания отдельным проектом. Не начинать, пока не закрыта типизация pipeline.
diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md
index c360fb0..c0a66ef 100644
--- a/docs/plan-composite-params.md
+++ b/docs/plan-composite-params.md
@@ -1,12 +1,15 @@
# План: составные типы параметров и полная типизация pipeline
> Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент
-> в preview) ✔, Фаза 2 — начата: построен отдельный **новый registry**
-> (`web/src/lib/registry-new/`) с первыми инструментами (`add-border-png`,
-> `add-stroke-png`), preview переключён на него. Следующее: продолжить Фазу 2 —
-> следующий инструмент `find-contour-png` в новый registry.
+> в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены
+> все одиночные инструменты (18 шт): `add-border-png`, `add-stroke-png`,
+> `find-contour-png` и весь блок 8-22 (convert/noise/pixelate/color/filters...).
+> Новый registry `web/src/lib/registry-new/` разбит по категориям
+> (`geometry`, `alpha`, `convert`, `analyze`, `filters`, `color`), preview
+> переключён на него. Следующее: Фаза 3 — инструменты с составными типами.
>
-> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,geometry,alpha,index}.ts`,
+> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
+> (по файлу на категорию) + `web/src/lib/preview/categories.ts`,
> `web/src/lib/registry-schema.ts`. Старый `web/src/lib/registry.ts` разбит по
> категориям в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
@@ -21,6 +24,16 @@
сборка, изоляция от старого, затем новый становится основным и старый
удаляется вместе со старым дизайном.
+**Правило копий (важно!):** если для нового UI/registry нужно внести изменения
+в файл, который **прямо или косвенно** уже используется `(old)` веткой, — этот
+файл **НЕ трогаем**. Вместо этого делаем **копию** в новом месте (например,
+в `lib/preview/`) и правим копию. Это приводит к дублированию, но
+**гарантированно не задевает старую ветку сайта**. Пример: категории — новый
+`lib/preview/categories.ts` (object as const) копирует и заменяет собой
+`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и
+остаётся без изменений. После перехода (Фаза 5) копия становится основной,
+а исходник удаляется вместе со старым UI.
+
**Разделение схем: две независимые схемы.**
| Схема | Источник | Использование |
@@ -286,28 +299,32 @@ Typed field builders + `Field` + `toolSchema()` + `ToolSchema
` —
составных типов. Каждый — отдельный маленький diff (~15-30 строк), тем самым
проверенным в Фазе 1 паттерном. **Переезжают в `registry-new`.**
-Переведено: `add-border-png` (Фаза 1), `add-stroke-png`. Следующий —
-`find-contour-png`.
+Переведено: `add-border-png` (Фаза 1), `add-stroke-png`, `find-contour-png`, плюс
+весь блок простых инструментов ниже (пункты 8-22). Следующее — Фаза 3.
6. ~~add-stroke-png (color + slider)~~ → переведён в `registry-new` ✔
-7. find-contour-png (color + slider) — **следующий**
-8. convert-png-to-jpg (color + slider)
-9. convert-png-to-webp (slider)
-10. remove-color-from-png (color + slider)
-11. extract-color-from-png (color + slider)
-12. add-noise-png (slider + select + number)
-13. randomize-pixels-png (slider + number)
-14. pixelate-png (slider)
-15. vignette-png (slider)
-16. gamma-png (slider)
-17. temperature-png (slider)
-18. tint-png (color + slider)
-19. quantize-png (slider)
-20. custom-palette-png (text)
-21. dithering-png (slider + select)
-22. jpeg-artifacts-png (slider)
+7. ~~find-contour-png (color + slider)~~ → переведён в `registry-new` ✔
+8. ~~convert-png-to-jpg (color + slider)~~ → переведён в `registry-new` ✔
+9. ~~convert-png-to-webp (slider)~~ → переведён в `registry-new` ✔
+10. ~~remove-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
+11. ~~extract-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
+12. ~~add-noise-png (slider + select + number)~~ → переведён в `registry-new` ✔
+13. ~~randomize-pixels-png (slider + number)~~ → переведён в `registry-new` ✔
+14. ~~pixelate-png (slider)~~ → переведён в `registry-new` ✔
+15. ~~vignette-png (slider)~~ → переведён в `registry-new` ✔
+16. ~~gamma-png (slider)~~ → переведён в `registry-new` ✔
+17. ~~temperature-png (slider)~~ → переведён в `registry-new` ✔
+18. ~~tint-png (color + slider)~~ → переведён в `registry-new` ✔
+19. ~~quantize-png (slider)~~ → переведён в `registry-new` ✔
+20. ~~custom-palette-png (text)~~ → переведён в `registry-new` ✔
+21. ~~dithering-png (slider + select)~~ → переведён в `registry-new` ✔
+22. ~~jpeg-artifacts-png (slider)~~ → переведён в `registry-new` ✔
-(и т.п. — все одиночные инструменты в этом же ключе)
+> **Замечание (конвертеры):** старые `convert-png-to-jpg`/`convert-png-to-webp`
+> несли `output`-метаданные (`mime`/`ext`/`qualityParamId`) для выбора формата
+> вывода. В новом `ToolEntry
` поля `output` пока нет, поэтому при переводе
+> эти метаданные не перенесены — preview пока отдаёт результат как PNG.
+> Механика выбора формата/качества в новом UI — отдельный шаг (не блокирует Фазу 2).
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
diff --git a/web/src/lib/preview/catalog.ts b/web/src/lib/preview/catalog.ts
index ffd3760..c9aefec 100644
--- a/web/src/lib/preview/catalog.ts
+++ b/web/src/lib/preview/catalog.ts
@@ -1,4 +1,4 @@
-import { CATEGORIES } from "../categories";
+import { CATEGORY_IDS } from "./categories";
import { TOOLS, type ToolEntry } from "../registry-new";
export type PreviewGroup = {
@@ -23,7 +23,7 @@ const GROUP_LABELS: Record = {
* Preview-каталог: полный реестр инструментов, сгруппированный по категориям.
* Больше не урезаем до референс-набора — показываем все инструменты из TOOLS.
*/
-export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORIES.map((category) => ({
+export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
id: category,
label: GROUP_LABELS[category] ?? category.toUpperCase(),
tools: TOOLS.filter((tool) => tool.category === category),
diff --git a/web/src/lib/preview/categories.ts b/web/src/lib/preview/categories.ts
new file mode 100644
index 0000000..893f7ac
--- /dev/null
+++ b/web/src/lib/preview/categories.ts
@@ -0,0 +1,26 @@
+/**
+ * Категории инструментов для нового registry/preview.
+ *
+ * Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
+ * выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
+ * Этот файл — локальная копия подхода для нового UI; старый `../categories`
+ * (массив + union вручную) не трогаем — он обслуживает старый UI в `(old)/`.
+ *
+ * Человекочитаемые названия живут в словарях i18n: секция categories,
+ * ключ = CategoryId.
+ */
+export const CATEGORIES = {
+ convert: "convert",
+ alpha: "alpha",
+ color: "color",
+ geometry: "geometry",
+ filters: "filters",
+ text: "text",
+ analyze: "analyze",
+ generate: "generate",
+} as const;
+
+export type CategoryId = (typeof CATEGORIES)[keyof typeof CATEGORIES];
+
+/** Порядок категорий в каталоге (как задано в `CATEGORIES`). */
+export const CATEGORY_IDS = Object.keys(CATEGORIES) as CategoryId[];
diff --git a/web/src/lib/registry-new/alpha.ts b/web/src/lib/registry-new/alpha.ts
index 3637e75..ebc9b79 100644
--- a/web/src/lib/registry-new/alpha.ts
+++ b/web/src/lib/registry-new/alpha.ts
@@ -1,6 +1,7 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
-import { strokeImage } from "../core/morphology";
+import { colorMask, removeColorToAlpha } from "../core/alpha";
+import { contourImage, strokeImage } from "../core/morphology";
interface AddStrokeParams {
color: string;
@@ -22,4 +23,45 @@ const addStroke: ToolEntry = {
run: (img, p) => strokeImage(img, p.thickness, p.color),
};
-export const alphaEntries = [addStroke];
+interface FindContourParams {
+ color: string;
+ thickness: number;
+}
+
+export const findContourSchema = toolSchema({
+ color: field.color({ default: "#000000" }),
+ thickness: field.slider({ min: 1, max: 5, step: 1, default: 1 }),
+});
+
+const findContour: ToolEntry = {
+ id: "find-contour-png",
+ title: "Find contour PNG",
+ description:
+ "Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
+ category: "alpha",
+ schema: findContourSchema,
+ run: (img, p) => contourImage(img, p.thickness, p.color),
+};
+
+interface RemoveColorParams {
+ targetColor: string;
+ tolerance: number;
+}
+
+export const removeColorSchema = toolSchema({
+ targetColor: field.color({ default: "#00ff00" }),
+ tolerance: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
+});
+
+const removeColor: ToolEntry = {
+ id: "remove-color-from-png",
+ title: "Remove color from PNG (make transparent)",
+ description:
+ "Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
+ category: "alpha",
+ schema: removeColorSchema,
+ run: (img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance),
+ preview: (img, p) => colorMask(img, p.targetColor, p.tolerance),
+};
+
+export const alphaEntries = [addStroke, findContour, removeColor];
diff --git a/web/src/lib/registry-new/analyze.ts b/web/src/lib/registry-new/analyze.ts
new file mode 100644
index 0000000..657bb07
--- /dev/null
+++ b/web/src/lib/registry-new/analyze.ts
@@ -0,0 +1,25 @@
+import type { ToolEntry } from "./types";
+import { field, toolSchema } from "../registry-schema";
+import { extractByColor } from "../core/masks";
+
+interface ExtractColorParams {
+ color: string;
+ tolerance: number;
+}
+
+export const extractColorSchema = toolSchema({
+ color: field.color({ default: "#00ff88" }),
+ tolerance: field.slider({ min: 0, max: 50, step: 1, default: 10 }),
+});
+
+const extractColor: ToolEntry = {
+ id: "extract-color-from-png",
+ title: "Extract Color from PNG",
+ description:
+ "Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
+ category: "analyze",
+ schema: extractColorSchema,
+ run: (img, p) => extractByColor(img, p.color, p.tolerance),
+};
+
+export const analyzeEntries = [extractColor];
diff --git a/web/src/lib/registry-new/color.ts b/web/src/lib/registry-new/color.ts
new file mode 100644
index 0000000..0c7ee47
--- /dev/null
+++ b/web/src/lib/registry-new/color.ts
@@ -0,0 +1,136 @@
+import type { ToolEntry } from "./types";
+import { field, toolSchema } from "../registry-schema";
+import {
+ ditherImage,
+ mapToNearest,
+ quantizeImage,
+} from "../core/quantize";
+import { gammaCorrection, temperature, tint } from "../core/color";
+import { parseHexList } from "../core/palette";
+
+interface GammaParams {
+ value: number;
+}
+
+export const gammaSchema = toolSchema({
+ value: field.slider({ min: 0.1, max: 3, step: 0.05, default: 1 }),
+});
+
+const gammaTool: ToolEntry = {
+ id: "gamma-png",
+ title: "Gamma correction PNG",
+ description:
+ "Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
+ category: "color",
+ schema: gammaSchema,
+ run: (img, p) => gammaCorrection(img, p.value),
+};
+
+interface TemperatureParams {
+ percent: number;
+}
+
+export const temperatureSchema = toolSchema({
+ percent: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
+});
+
+const temperatureTool: ToolEntry = {
+ id: "temperature-png",
+ title: "Temperature PNG",
+ description:
+ "Positive values make the image warmer (more orange), negative ones cooler (more blue).",
+ category: "color",
+ schema: temperatureSchema,
+ run: (img, p) => temperature(img, p.percent),
+};
+
+interface TintParams {
+ color: string;
+ strength: number;
+}
+
+export const tintSchema = toolSchema({
+ color: field.color({ default: "#ffb060" }),
+ strength: field.slider({ min: 0, max: 100, step: 1, default: 30 }),
+});
+
+const tintTool: ToolEntry = {
+ id: "tint-png",
+ title: "Tint PNG",
+ description:
+ "Multiplies color channels by the chosen tint with the given strength.",
+ category: "color",
+ schema: tintSchema,
+ run: (img, p) => tint(img, p.color, p.strength),
+};
+
+interface QuantizeParams {
+ colors: number;
+}
+
+export const quantizeSchema = toolSchema({
+ colors: field.slider({ min: 2, max: 64, step: 1, default: 16 }),
+});
+
+const quantizeTool: ToolEntry = {
+ id: "quantize-png",
+ title: "Quantize PNG",
+ description:
+ "Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
+ category: "color",
+ schema: quantizeSchema,
+ run: (img, p) => quantizeImage(img, p.colors).image,
+};
+
+interface CustomPaletteParams {
+ colors: string;
+}
+
+export const customPaletteSchema = toolSchema({
+ colors: field.text({ default: "#000000,#ffffff" }),
+});
+
+const customPalette: ToolEntry = {
+ id: "custom-palette-png",
+ title: "Custom Palette PNG",
+ description:
+ "Maps every pixel to the nearest color from your comma-separated hex list.",
+ category: "color",
+ schema: customPaletteSchema,
+ run: (img, p) => mapToNearest(img, parseHexList(p.colors)),
+};
+
+interface DitheringParams {
+ colors: number;
+ pattern: "floyd-steinberg" | "bayer";
+}
+
+export const ditheringSchema = toolSchema({
+ colors: field.slider({ min: 2, max: 16, step: 1, default: 4 }),
+ pattern: field.select({
+ default: "floyd-steinberg",
+ options: [
+ { value: "floyd-steinberg", label: "Floyd–Steinberg" },
+ { value: "bayer", label: "Bayer 4x4" },
+ ],
+ }),
+});
+
+const ditheringTool: ToolEntry = {
+ id: "dithering-png",
+ title: "Dithering PNG",
+ description:
+ "Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
+ category: "color",
+ schema: ditheringSchema,
+ run: (img, p) => ditherImage(img, p.colors, p.pattern),
+};
+
+export const colorEntries = [
+ gammaTool,
+ temperatureTool,
+ tintTool,
+ quantizeTool,
+ customPalette,
+ ditheringTool,
+];
diff --git a/web/src/lib/registry-new/convert.ts b/web/src/lib/registry-new/convert.ts
new file mode 100644
index 0000000..4246831
--- /dev/null
+++ b/web/src/lib/registry-new/convert.ts
@@ -0,0 +1,44 @@
+import type { ToolEntry } from "./types";
+import { field, toolSchema } from "../registry-schema";
+import { flattenOntoColor } from "../core/alpha";
+import { clonePixelImage } from "../core/types";
+
+interface ConvertToJpgParams {
+ background: string;
+ quality: number;
+}
+
+export const convertToJpgSchema = toolSchema({
+ background: field.color({ default: "#ffffff" }),
+ quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }),
+});
+
+const convertToJpg: ToolEntry = {
+ id: "convert-png-to-jpg",
+ title: "Convert PNG to JPG",
+ description:
+ "Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
+ category: "convert",
+ schema: convertToJpgSchema,
+ run: (img, p) => flattenOntoColor(img, p.background),
+};
+
+interface ConvertToWebpParams {
+ quality: number;
+}
+
+export const convertToWebpSchema = toolSchema({
+ quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }),
+});
+
+const convertToWebp: ToolEntry = {
+ id: "convert-png-to-webp",
+ title: "Convert PNG to WebP",
+ description:
+ "Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
+ category: "convert",
+ schema: convertToWebpSchema,
+ run: (img) => clonePixelImage(img),
+};
+
+export const convertEntries = [convertToJpg, convertToWebp];
diff --git a/web/src/lib/registry-new/filters.ts b/web/src/lib/registry-new/filters.ts
new file mode 100644
index 0000000..30b529c
--- /dev/null
+++ b/web/src/lib/registry-new/filters.ts
@@ -0,0 +1,115 @@
+import type { ToolEntry } from "./types";
+import { field, toolSchema } from "../registry-schema";
+import { addNoise, pixelate, shuffleBlocks } from "../core/pixel-fx";
+import { vignette } from "../core/effects";
+import { jpegRoundtrip } from "../core/io";
+
+interface VignetteParams {
+ strength: number;
+}
+
+export const vignetteSchema = toolSchema({
+ strength: field.slider({ min: 0, max: 100, step: 5, default: 50 }),
+});
+
+const vignetteTool: ToolEntry = {
+ id: "vignette-png",
+ title: "Vignette PNG",
+ description:
+ "Smoothly darkens the edges of the image, leaving the center untouched.",
+ category: "filters",
+ schema: vignetteSchema,
+ run: (img, p) => vignette(img, p.strength),
+};
+
+interface PixelateParams {
+ blockSize: number;
+}
+
+export const pixelateSchema = toolSchema({
+ blockSize: field.slider({ min: 2, max: 64, step: 1, default: 8 }),
+});
+
+const pixelateTool: ToolEntry = {
+ id: "pixelate-png",
+ title: "Pixelate PNG",
+ description:
+ "Averages every blockSize×blockSize area into one color — classic mosaic.",
+ category: "filters",
+ schema: pixelateSchema,
+ run: (img, p) => pixelate(img, p.blockSize),
+};
+
+interface RandomizePixelsParams {
+ blockSize: number;
+ seed: number;
+}
+
+export const randomizePixelsSchema = toolSchema({
+ blockSize: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
+ seed: field.number({ min: 0, max: 999999, step: 1, default: 42 }),
+});
+
+const randomizePixels: ToolEntry = {
+ id: "randomize-pixels-png",
+ title: "Randomize Pixels PNG",
+ description:
+ "Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
+ category: "filters",
+ schema: randomizePixelsSchema,
+ run: (img, p) => shuffleBlocks(img, p.blockSize, p.seed),
+};
+
+interface AddNoiseParams {
+ amount: number;
+ mode: "mono" | "color";
+ seed: number;
+}
+
+export const addNoiseSchema = toolSchema({
+ amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }),
+ mode: field.select({
+ default: "mono",
+ options: [
+ { value: "mono", label: "Monochrome grain" },
+ { value: "color", label: "Color noise" },
+ ],
+ }),
+ seed: field.number({ min: 0, max: 999999, step: 1, default: 1234 }),
+});
+
+const addNoiseTool: ToolEntry = {
+ id: "add-noise-png",
+ title: "Add Noise to PNG",
+ description:
+ "Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
+ category: "filters",
+ schema: addNoiseSchema,
+ run: (img, p) => addNoise(img, p.amount, p.mode, p.seed),
+};
+
+interface JpegArtifactsParams {
+ quality: number;
+}
+
+export const jpegArtifactsSchema = toolSchema({
+ quality: field.slider({ min: 1, max: 50, step: 1, default: 10 }),
+});
+
+const jpegArtifacts: ToolEntry = {
+ id: "jpeg-artifacts-png",
+ title: "JPEG artifacts",
+ description:
+ "Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
+ category: "filters",
+ schema: jpegArtifactsSchema,
+ run: (img, p) => jpegRoundtrip(img, p.quality),
+};
+
+export const filtersEntries = [
+ vignetteTool,
+ pixelateTool,
+ randomizePixels,
+ addNoiseTool,
+ jpegArtifacts,
+];
diff --git a/web/src/lib/registry-new/index.ts b/web/src/lib/registry-new/index.ts
index 1ee46eb..cfaec75 100644
--- a/web/src/lib/registry-new/index.ts
+++ b/web/src/lib/registry-new/index.ts
@@ -1,12 +1,20 @@
import type { ToolEntry } from "./types";
import { geometryEntries } from "./geometry";
import { alphaEntries } from "./alpha";
+import { convertEntries } from "./convert";
+import { analyzeEntries } from "./analyze";
+import { filtersEntries } from "./filters";
+import { colorEntries } from "./color";
export type { ToolEntry } from "./types";
export const TOOLS: ToolEntry[] = [
...geometryEntries,
...alphaEntries,
+ ...convertEntries,
+ ...analyzeEntries,
+ ...filtersEntries,
+ ...colorEntries,
] as unknown as ToolEntry[];
export function getTool(id: string): ToolEntry | undefined {
diff --git a/web/src/lib/registry-new/types.ts b/web/src/lib/registry-new/types.ts
index a5299bf..d8d2fca 100644
--- a/web/src/lib/registry-new/types.ts
+++ b/web/src/lib/registry-new/types.ts
@@ -1,4 +1,4 @@
-import type { CategoryId } from "../categories";
+import type { CategoryId } from "../preview/categories";
import type { ToolSchema } from "../registry-schema";
import type { PixelImage } from "../core/types";