diff --git a/AGENTS.md b/AGENTS.md index 6247f2b..64a7627 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,8 @@ `docs/quality-gates.md`. - Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок: `pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг - выполняется, даже если предыдущий упал. `app_v1.css` и legacy-ветка исключены - из новых правил; их долг чинится только по заведённым задачам, а новые - изменения не должны добавлять новые предупреждения без записи в baseline. + выполняется, даже если предыдущий упал. Новые изменения не должны добавлять + предупреждения без записи в baseline. ## Правила кода @@ -49,8 +48,7 @@ Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила, которые линтер не проверяет: -- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в - `web/src/app_v1.css` (маршруты `/v1/*`). +- Новый дизайн живёт в `web/src/app.css` и применяется ко всем production-маршрутам. - Все повторяющиеся визуальные элементы — отдельные компоненты в `web/src/lib/components/`, даже «просто div с двумя стилями». - Дизайн-токены (цвета, размеры, длительности, z-index) — только из @@ -69,10 +67,5 @@ дату архивирования и срок удаления; архив — только справка, не источник требований. -## Изоляция веток old ↔ new - -Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит -каждый импорт до файла и запрещает смешивание. - - Trunk-based: коммиты делает разработчик после ревью, самому не коммитить. Изменения делать небольшими (< ~500 строк), атомарными. diff --git a/docs/README.md b/docs/README.md index eeffd96..368b160 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ ## Порядок чтения -1. `AGENTS.md` — команды, обязательные инварианты и границы old/new. +1. `AGENTS.md` — команды и обязательные инварианты. 2. Этот файл — выбрать актуальный документ по теме. 3. `docs/plan-codebase-quality.md` — текущий план приведения качества в порядок и последовательность следующих шагов. @@ -23,8 +23,7 @@ | `docs/quality-gates.md` | Команды проверки, warnings и CI | active | | `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active | | `docs/roadmap.md` | Общие направления развития | active | -| `docs/plan-redesign.md` | Новый дизайн и границы `/v1` | active | -| `docs/plan-preview-i18n.md` | План i18n нового UI | active | +| `docs/plan-redesign.md` | Новый дизайн production UI | active | | `docs/plan-seo.md` | SEO/GEO-план | active | | `docs/plan-platform.md` | PWA/API/WASM-план | active | | `docs/checklist-manual-testing.md` | Ручная приёмка preview | active | @@ -51,16 +50,17 @@ только если темы нет в существующем документе; иначе обновляется источник истины. -## Текущий baseline Q0 +## Текущий baseline Q3 -На момент фиксации Q0: +На момент фиксации Q3: -- registry нового UI содержит 122 инструмента; +- production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web lint:all` завершается с 0 errors и 0 i18n warnings; остаются 4 - unused design tokens от token audit; -- `pnpm verify` проходит; unit/e2e результаты считать актуальными только после - отдельного прогона, исторические числа в `docs/archive/` не являются baseline. +- `pnpm --dir web test` — 39 test files / 422 tests; +- `pnpm --dir web test:rules` — 4 test files / 74 tests; +- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused + design tokens от token audit; +- `pnpm verify` проходит; production E2E — 105 passed, 5 skipped. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index 69d968e..1af9921 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -12,12 +12,11 @@ 4. Найти существующие типы, константы, helpers и тестовые паттерны до создания нового определения. 5. Определить границу изменения: - - новый UI — `web/src/routes/**` и `web/src/lib/components/**`; - - legacy — `web/src/routes/v1/**` и `web/src/lib/v1/**`; + - production UI — `web/src/routes/**` и `web/src/lib/components/**`; - shared — `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts`. -Изменение shared-кода требует проверки обеих веток. Смешивание old/new нельзя -обходить относительными импортами или исключениями линтера. +Изменение shared-кода требует проверки production-потребителей. Не добавлять +исключения линтера ради отдельных модулей без записи в baseline. ## 2. Порядок изменения @@ -73,7 +72,7 @@ E2E запускается отдельно от быстрых проверок - обязательное поведение агента — `AGENTS.md`; - индекс и карта документов — `docs/README.md`; - качество и точные команды — `docs/quality-gates.md`; -- тестовая архитектура — `docs/testing-strategy.md` после Q3; +- тестовая архитектура — `docs/testing-strategy.md` после Q4; - lint implementation — `web/eslint-plugins/README.md`; - продуктовые решения — backlog и активные планы. diff --git a/docs/plan-preview-i18n.md b/docs/archive/plan-preview-i18n.md similarity index 98% rename from docs/plan-preview-i18n.md rename to docs/archive/plan-preview-i18n.md index fb83913..551bc93 100644 --- a/docs/plan-preview-i18n.md +++ b/docs/archive/plan-preview-i18n.md @@ -1,3 +1,7 @@ +> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина: +> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в +> backlog. Использование: только историческая справка + # План: i18n в preview (новый UI) > Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI, diff --git a/docs/backlog.md b/docs/backlog.md index a07e5d0..98c450f 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -4,9 +4,9 @@ - [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые значения, совпадение `{placeholder}`-переменных между всеми локалями) — - правило `i18n/dict-consistency`, детали в §Фаза 8 - `docs/plan-preview-i18n.md`. Правило реализовано; Q2 перевёл i18n baseline - к zero-warning через policy-aware fallback, не меняя `/v1`. + правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`. + Правило реализовано; Q2 перевёл i18n baseline к zero-warning через + policy-aware fallback. - [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть что за `SonarLint` или написать кастомный линтер) - [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n @@ -14,7 +14,7 @@ - [ ] консистентность комментариев - если есть описание к одному инструменту - оно должно быть у всех или объяснено, почему этот инструмент такой особенный и заслуживает комментарий -- [ ] удалить legacy `tools.*.params` из root i18n после проверки потребителей; +- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей; до этого Q2 держит pattern-based policy-исключение ## Мелочи всякие @@ -53,8 +53,7 @@ - [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone) - [ ] square-mask-png - проверить работу - параметры неверно считаются. width - 100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку -- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность - (range?) +- [ ] прикрутить кастомный color picker, добавить прозрачность (range?) - [ ] проверить настройки range slider на crop-png - слишком большие значения - [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса значения @@ -75,8 +74,7 @@ - [x] добавить i18n в preview - [ ] множественные числа (часть/части/частей; «N параметров», счётчики) — - `tPlural()` и формат `PluralForms`, детали в §Фаза 9 - `docs/plan-preview-i18n.md` + `tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog ## SEO/GEO @@ -103,8 +101,7 @@ Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md` (фазы 3–8) — здесь только новые треки. -- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1; - C19 отложен — не блокер) +- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1) - [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier (после wasm-фаз 5–7) diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index b540dbe..6395885 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -1,10 +1,10 @@ # План: линтеры, тесты и качество кодовой базы -> Статус: **draft — на ревью**. +> Статус: **draft — Q0–Q3 готовы к ревью; Q4 — следующий этап**. > > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и -> инфраструктурных скриптов. До подтверждения плана кодовые изменения не -> выполняются. +> инфраструктурных скриптов. Q0–Q3 выполнены; Q4–Q8 выполняются отдельными +> атомарными шагами. > > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» > проверок, стабилизировать тестовые контракты и только после этого расширять @@ -14,51 +14,40 @@ 1. Сначала stabilизировать существующие проверки и тестовые контракты, а не добавлять новые сложные lint-правила. -2. Старый UI (`src/lib/v1/`, `src/routes/v1/`, `app_v1.css`) больше не является - обязательной поддерживаемой версией: текущий production на корневых маршрутах - уже достаточно стабилен для использования. -3. Пока `/v1` существует, старый и новый UI остаются изолированными. Не тратить - время на исправления, тесты и документацию, которые нужны только v1. -4. Удаление v1 — реальный следующий cleanup-задача (Q3). Не ждать, пока legacy - начнёт мешать, и не тратить время на его поддержку; если задача упирается в - v1, Q3 выполняется перед ней. -5. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и - smoke-сценариями для обеих веток, пока ветка v1 не удалена. -6. Изменения делаются небольшими атомарными частями, в соответствии с +2. Старый UI удалён: production работает на корневых маршрутах, а `/v1/**` и + v1-specific код больше не являются частью приложения. +3. Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты. +4. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и + smoke-сценариями production-потребителей. +5. Изменения делаются небольшими атомарными частями, в соответствии с `AGENTS.md`. -7. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме, +6. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме, получает fixtures и тесты, и только после очистки baseline становится blocking. -8. Детали реализации правил остаются в `web/eslint-plugins/README.md`; +7. Детали реализации правил остаются в `web/eslint-plugins/README.md`; `AGENTS.md` содержит краткие обязательные политики, понятные агенту без дополнительных поисков, и ссылки на подробные документы. -### Политика legacy v1 +### Политика удаления legacy v1 -- v1 можно удалить; это разрешённый cleanup, а не обязательная работа перед - каждой задачей. -- Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты без - прямой зависимости текущей задачи от legacy-кода. -- Если задача требует изменений в v1 или общих модулях, сначала выполнить Q3, - если это не противоречит атомарности текущей задачи, и продолжить работу - только с production-веткой. -- Если порядок работ изменится, Q3 можно перенести как целый cleanup, но не - возвращать постоянную поддержку v1. -- После удаления проверить маршруты, импорты, i18n, тесты, документацию и - конфигурацию lint; старые ссылки не оставлять как «источник совместимости». +- Q3 — отдельная атомарная cleanup-задача; поддержка v1 не возвращается. +- Старые маршруты и UI-файлы удаляются без redirects и без совместимого + maintenance-режима. +- В рабочей кодовой базе не оставлять UI-v1-пути, исключения или документацию, + которые выглядят как действующий источник совместимости. +- Исторические упоминания допустимы только в `docs/archive/`. +- Будущий API-контракт `POST /v1/process` из `docs/plan-platform.md` не связан с + удалённым UI-v1 и остаётся в планах. -## 2. Что уже есть и что мешает +## 2. Текущее состояние quality-контура -- `i18n/dict-consistency` реализован и подключён как `warn-only` - (`web/eslint.config.js:177-188`). Проверки ключей, пустых значений и - placeholders есть, но текущий baseline ещё содержит расхождения. -- `lint:all` запускает ESLint, Stylelint и token audit - (`web/scripts/lint-all.mjs:31-54`), но не запускает typecheck, unit-тесты, - Prettier или Playwright. -- CI workflow сейчас проверяет только production build - (`.github/workflows/deploy.yml:40-53`). -- В e2e есть фиксированные числа, точные тексты, CSS-классы и `alt`-селекторы: - `web/e2e/catalog.spec.ts:4-55`, `web/e2e/text-and-verdicts.spec.ts:52-101`. +- `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование, + docs и production build; deploy выполняет его перед сборкой. +- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt — + 4 unused design tokens от token audit. +- В e2e остаются фиксированные числа, точные тексты, CSS-классы и + `alt`-селекторы: `web/e2e/catalog.spec.ts:4-55`, + `web/e2e/text-and-verdicts.spec.ts:52-101`. - Backlog и реализация расходятся по генераторам: продуктовая заметка требует автоматический запуск без `Generate`-кнопки (`docs/backlog.md:115-122`), а тест ожидает кнопку (`web/e2e/generators.spec.ts:8-16`). @@ -137,9 +126,10 @@ pnpm --dir web test:e2e - `BASE_LOCALE` — единственный источник registry-English fallback для `tools.*.title` и `tools.*.description`; остальные локали требуют физические ключи. -- Legacy `tools.*.params` исключён pattern-based policy до отдельной очистки - мёртвого словаря; пустые значения и placeholders не подавляются. -- Исправлены три реальных RU option-label gaps и удалены неиспользуемые legacy +- Временно неиспользуемые `tools.*.params` исключены pattern-based policy до + отдельной очистки мёртвого словаря; пустые значения и placeholders не + подавляются. +- Исправлены три реальных RU option-label gaps и удалены неиспользуемые option-группы из `ru.ts`. - Исправлен tie-break placeholder parity: при равенстве голосов выбирается `BASE_LOCALE`. @@ -148,22 +138,21 @@ pnpm --dir web test:e2e - Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим этапом. -### Q3. Удалить v1 (следующий cleanup) +### Q3. Удалить UI-v1 (cleanup завершён) -**Статус:** следующий шаг после Q2. **Оценка:** M. **Зависимости:** Q0, Q1, Q2. +**Статус:** выполнен. **Проверено:** `pnpm verify` и production E2E smoke +проходят; после проверки обновлены конфиги и активные документы. **Оценка:** M. +**Зависимости:** Q0, Q1, Q2. -- Удалить `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific - i18n/tests и оставшиеся v1-ссылки. -- Убрать v1 из isolation/lint/test-конфигураций и документации; не оставлять - исключения только ради удалённого кода. -- Обновить shared `core`/`theme` проверки: после удаления v1 проверять только - production-потребителя. -- Прогнать `pnpm verify` и отдельный e2e smoke production-маршрутов. -- Не выполнять удаление как побочный эффект другого PR; это отдельная атомарная - задача. +- Удалены `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific + i18n/tests и fixtures. +- Удалены isolation plugin, его тест и все v1-исключения из lint-конфигураций. +- Убраны v1-ссылки из production-кода и активных документов; исторические + упоминания остаются в `docs/archive/`. +- Проверены production build, verify и отдельный E2E smoke production-маршрутов. -**Готово, когда:** production build и verify зелёные, v1-маршруты и файлы -отсутствуют, а ссылки и конфиги не содержат legacy-путей. +**Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют; +следующий этап — Q4. ### Q4. Тестовый контракт e2e @@ -243,8 +232,6 @@ file-result-инструментам. - Очистить или использовать unused design tokens; после очистки решить, должен ли unused audit быть fail-режимом. - Добавить тесты для token audit и PostCSS HCT-плагина. -- Проверить/исправить расхождение документации isolation rule и реально - передаваемых options. - Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов. - Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и hardcoded colors наравне со Svelte styles. @@ -291,8 +278,8 @@ Blanket-запрет всех string literals не использовать. 1. Какой набор команд считается полным quality gate и что означает успешный результат при warnings. -2. Старый/new scope и политику v1: v1 можно удалить, но пока он существует, не - расширять на него новые правила и не тратить время на его поддержку. +2. Production-scope и политику удаления v1: новые изменения относятся только к + production UI; исторические UI-v1-упоминания остаются в архиве. 3. Политику e2e: semantic locators, допустимый `data-testid`, запрет бессмысленных counts/classes/alt/exact prose. 4. Политику i18n: новые пользовательские тексты через i18n; registry source, @@ -301,7 +288,7 @@ Blanket-запрет всех string literals не использовать. исправление и удаление теста происходят вместе. 6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на работу. -7. Shared-модули `core`/`theme` требуют проверки обеих веток. +7. Shared-модули `core`/`theme` требуют проверки production-потребителей. Синтаксические списки ESLint-правил и подробные токены остаются в `web/eslint-plugins/README.md`. @@ -309,7 +296,7 @@ Blanket-запрет всех string literals не использовать. ### Политика документации для агентов `AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем -только обязательный рабочий контракт: команды, границы old/new, ключевые +только обязательный рабочий контракт: команды, production-scope, ключевые инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка; длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в @@ -351,15 +338,15 @@ history или пересоздают как новый активный док ### Какие документы подготовить -| Документ | Назначение | Когда создавать/обновлять | -| ------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | -| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа | -| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка, работа с legacy и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` | -| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI | -| `docs/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры | -| `docs/architecture-overview.md` | Карта `core`, registry, executor, нового UI и `/v1`, shared-модули и зависимости | Q5 или перед крупным рефакторингом; не дублировать существующие планы | -| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами | -| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики | +| Документ | Назначение | Когда создавать/обновлять | +| ------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа | +| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` | +| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI | +| `docs/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры | +| `docs/architecture-overview.md` | Карта `core`, registry, executor, production UI и shared-модулей | Q5 или перед крупным рефакторингом; не дублировать существующие планы | +| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами | +| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики | Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и @@ -392,11 +379,9 @@ refactor, новые i18n-правила и исправление продук ## 7. Решения, которые нужно принять на ревью -- Оставляем ли dictionary linter warn-only до очистки или сразу вводим blocking - baseline? -- Считаем ли `registry.title/description` физическими i18n-ключами или - допустимым английским fallback? -- Подтверждаем ли auto-run генераторов без кнопки Generate? -- Что именно должен делать Reset при включённом auto-run? +- Какие semantic locators и допустимые `data-testid` закрепить в Q4? - Какие browser/e2e проверки должны быть обязательными для PR, а какие — отдельным ночным прогоном? +- Подтверждаем ли auto-run генераторов без кнопки Generate? +- Что именно должен делать Reset при включённом auto-run? +- Какие i18n-сценарии проверяются в e2e, а какие остаются unit-проверками? diff --git a/docs/plan-platform.md b/docs/plan-platform.md index 213e30d..0f38fe2 100644 --- a/docs/plan-platform.md +++ b/docs/plan-platform.md @@ -43,7 +43,7 @@ - Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере, CLI и API (harness фазы 7 расширяется третьим рантаймом). -## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер) +## 2. Фаза 10 — PWA (после S1) - [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 + maskable, theme-color из дизайн-токенов, `display: standalone`. @@ -64,8 +64,7 @@ ## 3. Порядок и связи - WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются). -- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19 - отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7. +- PWA — после S1 `plan-seo.md`; API — после фаз 5–7. - Синергия с дистрибуцией: «works offline, installable» и публичный API — аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для GEO-текстов (`llms.txt`, описания категорий). diff --git a/docs/plan-redesign.md b/docs/plan-redesign.md index 8b8fe34..b2f582e 100644 --- a/docs/plan-redesign.md +++ b/docs/plan-redesign.md @@ -1,44 +1,26 @@ # План: переезд на новый дизайн (refs) — параллельная сборка -> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**. -> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21 -> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не -> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт, -> аудит против рефа прекращён. Общий tech-debt baseline зафиксирован в -> `docs/plan-codebase-quality.md`; legacy cleanup остаётся отложенным. Backlog -> №14 (переезд старых файлов в `old/`) — **выполнено**. Покупка домена НЕ была -> блокером C17: домен нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие -> `noindex`. +> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**, Q3 +> (удаление старого UI) **выполнен**. Остался C20 (шаг 6, проверка нового +> дизайна) — **следующий этап**. C21 (удалить refs) остаётся отдельной уборкой. +> Общий tech-debt baseline и следующие quality-задачи зафиксированы в +> `docs/plan-codebase-quality.md`. Покупка домена НЕ была блокером C17: домен +> нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`. > > Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву), > `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере. > -> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка -> общих компонентов на месте с алиасами токенов) — **параллельная сборка**: -> новый дизайн строится отдельно, изолируется от старого, проверяется целиком, -> затем становится основным и старый удаляется. Старое приложение остаётся -> рабочим и шippable на всём протяжении перехода. +> **Историческая стратегия перехода:** сначала новый UI собирался отдельно, +> затем стал production, после чего legacy-ветка была удалена Q3. Текущие +> критерии относятся только к production UI; старые решения и детали перехода +> остаются в архивных планах. ## 0. Почему меняем стратегию -- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые токены - как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие компоненты - редактируются вживую → регресс старого UI во время работы, новый дизайн нельзя - принять изолированно. -- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые страницы - рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем его основным, - (5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги. -- **Главный технический риск и его решение.** Сейчас `web/src/app.css` - импортируется в **корневом** `web/src/routes/+layout.svelte:2` и потому - грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать новый - CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без - наследования**: (а) убрать `import '../app.css'` из корневого layout; (б) - старые маршруты обернуть группой `(old)/` с собственным layout, импортирующим - `app.css`; (в) новые маршруты (`preview/*`) — собственный layout, - импортирующий `design2.css`. SvelteKit делает code-splitting CSS по маршрутам, - а стили компонентов скоупятся сами — поэтому глобальные стили каждого дизайна - живут только в своей ветке, и префикс-обёртка `.ds2` не нужна. Старые `ui/` и - страницы не трогаются до фазы 5. +- Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить + независимо; теперь production использует только новые маршруты и `app.css`. +- Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout + для удалённого UI-v1 больше не нужны. ## 1. Источники и их оценка @@ -189,68 +171,29 @@ > `app.css`; если позже захотим держать новые маршруты внутри группы, `@` > пригодится для сброса родительского layout. -## 6. Маршрутизация параллельных страниц +## 6. Маршрутизация: историческая схема перехода -Новые страницы живут в **отдельной ветке `preview/*`**, не трогая старые: +Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были +перенесены на production-маршруты. Эта схема нужна только для восстановления +контекста C17; текущая структура описана в разделе 7, отдельных preview- или +legacy-layouts в production больше нет. -```txt -src/routes/+layout.svelte # нейтральный, БЕЗ app.css -src/routes/(old)/+layout.svelte # импорт app.css + старая шапка/подвал -src/routes/(old)/+page.svelte # / (старое) -src/routes/(old)/demo/+page.svelte # /demo -src/routes/(old)/list-tools/... -src/routes/(old)/tools/[id]/... -src/routes/preview/+layout.svelte # импорт design2.css, -src/routes/preview/+page.svelte # /preview — воркспейс (index/demo) -src/routes/preview/demo/+page.svelte # /preview/demo -src/routes/preview/list-tools/+page.svelte -src/routes/preview/tools/[id]/+page.svelte -``` +## 7. Фазы выполнения -- Каждая новая страница импортирует **только** `kit/*`. Старые страницы не - изменяются (только перемещены в `(old)/`). -- Фаза 4: содержимое `preview/*` переносится на реальные маршруты (move-файлы), - группа `(old)/` удаляется, `design2.css` подключается в корневом - `+layout.svelte` (как новый основной слой), временный флаг/кнопка убираются. +- **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и + интерактивные состояния приняты; детали перехода остаются в архивном + `plan-design-fix.md`. +- **C17/C18 — production-переход.** Новые страницы перенесены на реальные + корневые маршруты; `app.css` стал единственным production-стилем. +- **Q3/C19 — cleanup.** Удалены UI-v1, legacy CSS, v1-тесты, fixtures и + isolation-конфигурация; production build и verify проверяются отдельно. +- **C20 — следующий этап.** Финальный проход по брейкпоинтам и визуальной + приёмке production UI. +- **C21 — отдельная уборка.** Удалить `refs/` и `refs-html/`, если они больше не + нужны. -## 7. Фазы выполнения (1–5 по запросу) - -Каждая фаза заканчивается зелёными `test`/`check`/`build` и ревью в браузере -(обе темы) против `refs-html/`. - -- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все примитивы - §3 и контролы §4 как отдельные компоненты. Критерий: демо-страница kit'а - (`/preview` или временная) показывает все состояния (light/dark, - hover/disabled, все варианты Button/Segmented/Badge); unit-тесты форм не - падают; **старые страницы визуально не изменились** (изоляция работает). - -- **Фаза 2 — Новые страницы.** Под `preview/*` (§6): воркспейс, demo, - list-tools, tool-page (двухпанельная раскладка из рефа: settings-panel + - preview-panel, step-cards для chain). Критерий: разметка соответствует - `refs-html/index.html`, `gradient.html`, `background-remover.html`, - `list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает в - новом стиле; старые маршруты продолжают работать как раньше. - -- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран, 800px), - скриншоты против `refs-html/`, прогон старого тест-сьюта для доказательства - отсутствия регресса, RU/EN-локализация новых лейблов. Критерий: визуальное - совпадение с рефом, зелёные тесты, записанные скриншоты приняты. - -- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*` → реальные - маршруты; `design2.css` подключается в корневом `+layout.svelte`; удаление - preview-группы и временного флага/кнопки. Старые страницы теперь не - используются (мёртвый код), но физически ещё в репо. Критерий: реальные - маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений. - -- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`, - старого `app.css` (и его токенов), неиспользуемых классов; наведение порядка в - `components/` (имя каталога и подпапок — решается на фазе 1; к C17 `kit/` уже - разложен в `components/`). Финальный проход по брейкпоинтам 1200/1100/800/480. - Критерий: `grep` по старым токенам/классам пуст, build чистый, старый дизайн - нигде не упоминается. - -Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня), -Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня). +Критерии каждого этапа — зелёные `test`/`check`/`build`, отсутствие остаточных +legacy-путей в production-коде и ревью в браузере для UI-изменений. ## 8. Смоук (после фазы 3, контроль на фазе 4) @@ -268,14 +211,10 @@ src/routes/preview/tools/[id]/+page.svelte ## 9. Открытые вопросы - **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17 - разложил его в `components/` (подпапки fields/layout/ui). На C19 — только + разложил его в `components/` (подпапки fields/layout/ui). На C21 — только навести порядок внутри `components/`, переименование не требуется. -- **Изоляция через layout:** `app.css` вынесен из корневого layout в - `(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`. - Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна - (DevTools → вкладка Styles, нет правил из `app.css`). -- **Переключатель для проверки:** рекомендую `localStorage`-флаг + кнопка - «Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4. +- **Единый production CSS:** `app.css` подключается в корневом layout; отдельных + legacy-layouts и переключателей версий больше нет. - **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n- ключи, но допустить часть английских брендовых («приборная панель»). - **Версия в шапке/футере:** из `package.json` через `define` или захардкодить — @@ -283,8 +222,8 @@ src/routes/preview/tools/[id]/+page.svelte - **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде (`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код» - не нужен и не создаётся. `refs/` и `refs-html/` можно удалить из репо на фазе - 5 (по желанию), они не являются частью рантайма. + не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка; + их можно удалить в C21, они не являются частью рантайма. ## 10. План исполнения (мелкие коммиты) @@ -362,10 +301,10 @@ src/routes/preview/tools/[id]/+page.svelte ### Шаг 5. Сделать новый дизайн основным -> C17 — **выполнен** (`docs/archive/plan-c17.md`): старый `(old)/` → `/v1/`, -> preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки -> импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались: -> C19 (удаление старого, раскрытие `recommended` на весь код), C20, C21. +> C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты +> перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов +> после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и +> код. Остались C20 и C21. - [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить `(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый @@ -387,26 +326,21 @@ src/routes/preview/tools/[id]/+page.svelte - [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без предупреждений; `grep` по старым токенам/классам пуст. Доказательство - отсутствия регресса: старый `v1/` работает и не затронут. + отсутствия регресса: production-маршруты и основные пользовательские + потоки работают. -### Шаг 7. Опциональная чистка (отложен, нет срока) +### Шаг 7. Cleanup +- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`, + `app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения + из конфигурации и активной документации. - [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник правды. -- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`, - `app_v1.css` (и его токены), неиспользуемые классы. **И раскрыть ESLint - `recommended` на весь код** — убрать scoped-блок `newCode` в - `web/eslint.config.js`, прогнать `lint` и починить всплывшие ошибки. - _Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) — - это не «старый дизайн», у них свой линт-долг; расширение `recommended` на - них может выдать много ошибок. Решить на C19: либо чиним сразу, либо - расширяем scoped только на `src/routes/**` + `src/lib/components/**`._ - [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя каталога). -> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской -> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит -> только когда нет горящих задач и хочется навести порядок. +> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности. +> После удаления проверяются только production-маршруты и общие модули. > > Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать > на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита, diff --git a/docs/plan-seo.md b/docs/plan-seo.md index 115764f..07536da 100644 --- a/docs/plan-seo.md +++ b/docs/plan-seo.md @@ -138,7 +138,7 @@ Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и **domain-required** (после покупки и DNS-настройки). -#### S1-free: без домена (параллельно с C18–C19) +#### S1-free: без домена (параллельно с quality-планом) - [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд, @@ -149,7 +149,7 @@ для lossy) для всех инструментов — механика `ToolEntry.output` уже есть (backlog №7, райз «выбор формата/качества»). Продуктовая основа «image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести - параллельно с C18–C19. + параллельно с текущими quality-задачами. - [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`, @@ -166,8 +166,8 @@ страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в `build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт опционального `BASE_PATH`. -- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до - C19; после — вернуть минимальный allow-all). +- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после + cleanup — вернуть минимальный allow-all. - [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по актуальному маршруту), OG-теги (title/description/type/url/image), twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`** @@ -177,7 +177,7 @@ > зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами; > canonical/OG присутствуют на всех страницах; e2e зелёные. -### S2. Контентный слой (параллельно C18–C19, волнами по категориям) +### S2. Контентный слой (параллельно quality-плану, волнами по категориям) - [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`, `howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде @@ -328,10 +328,10 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl - [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain: canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`. -- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2 - (домен) — блокер только для S1-domain (canonical + sitemap + снятие - `noindex`). После индексации переименование = переезд с 301, потеря позиций и - ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17. +- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не + блокируют). R2 (домен) — блокер только для S1-domain (canonical + sitemap + + снятие `noindex`). После индексации переименование = переезд с 301, потеря + позиций и ссылок — не делать. ## 7. Что осознанно НЕ делаем diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 99919b1..e2f0a27 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -55,10 +55,10 @@ baseline; после закрытия известных ошибок его м - Token audit пока оставляет unused-token warnings отдельным неблокирующим сообщением. - `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным - предупреждением token audit; это следующий debt Q6. + предупреждением token audit; это следующий debt Q7. - `dict-consistency` использует base-locale fallback для registry metadata и - pattern-based policy для legacy `tools.*.params`; это не молчаливый - `allowPaths`-список. + pattern-based policy для временно неиспользуемых `tools.*.params`; это не + молчаливый `allowPaths`-список. ## CI diff --git a/docs/roadmap.md b/docs/roadmap.md index d11a166..3cffb83 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,13 +2,13 @@ > **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт, > каталог переведён в типизированный `registry` (122 текущих инструмента), новый -> дизайн по `plan-redesign.md` стал основным на корневых маршрутах (C17/C18 -> выполнены), старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19 -> в бэклоге). Tech debt не закрыт полностью; текущий baseline и следующие -> quality-задачи зафиксированы в `docs/plan-codebase-quality.md`. Следующий -> этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO S1-free -> (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`, S1-domain). Фазы -> 2–8 — будущие. Фазы 9–10 спланированы в `docs/plan-platform.md`. +> дизайн по `plan-redesign.md` стал единственным production UI на корневых +> маршрутах; UI-v1 удалён в Q3. Tech debt не закрыт полностью; текущий baseline +> и следующие quality-задачи зафиксированы в `docs/plan-codebase-quality.md`. +> Следующий этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO +> S1-free (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`, +> S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в +> `docs/plan-platform.md`. ## 0. Решения @@ -162,8 +162,8 @@ wasm. ### Фаза 10 — PWA — см. `docs/plan-platform.md` Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают -устройство»), установка как приложение. Wasm-фаз не требует, идёт после C17/C19 -и S1 из `plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2. +устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из +`plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2. ## 3. Правила сравнения (для фаз 4–7) diff --git a/web/eslint-plugins/README.md b/web/eslint-plugins/README.md index 999ced7..0ec41b1 100644 --- a/web/eslint-plugins/README.md +++ b/web/eslint-plugins/README.md @@ -8,12 +8,11 @@ - `web/eslint-plugins/` — локальные ESLint-плагины. - `design-tokens/` — правила для ` diff --git a/web/src/lib/v1/components/DropZone.svelte b/web/src/lib/v1/components/DropZone.svelte deleted file mode 100644 index 59ce2fc..0000000 --- a/web/src/lib/v1/components/DropZone.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - openPicker(); - } - }} - ondragenter={enter} - ondragleave={leave} - ondragover={over} - ondragend={end} - ondrop={(e) => { - e.preventDefault(); - depth = 0; - accept(e.dataTransfer?.files[0]); - }} -> - - {label} -
- { - accept(input?.files?.[0]); - if (input) { - input.value = ""; - } - }} -/> - - diff --git a/web/src/lib/v1/components/InfoPanel.svelte b/web/src/lib/v1/components/InfoPanel.svelte deleted file mode 100644 index e325be4..0000000 --- a/web/src/lib/v1/components/InfoPanel.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -{#if info} -
-
-
{t("infoPanel.dimensions")}
-
{info.width} × {info.height} px
-
-
-
{t("infoPanel.alpha")}
-
- {info.hasAlpha ? t("infoPanel.alphaYes") : t("infoPanel.alphaNo")} -
-
-
-
{t("infoPanel.colorCount")}
-
{info.colorCount.toLocaleString(LOCALE_TAGS[getLocale()])}
-
-
-{/if} - - diff --git a/web/src/lib/v1/components/ParamForm.svelte b/web/src/lib/v1/components/ParamForm.svelte deleted file mode 100644 index d3de99b..0000000 --- a/web/src/lib/v1/components/ParamForm.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - -
- {#if hasMask} - - {/if} - {#each params as param (param.id)} -
- {#if param.type === "checkbox"} - - {:else if param.type === "number"} - - {:else if param.type === "slider"} - - {:else if param.type === "select"} - ({ - value: o.value, - label: optionLabel(tool, param, o.value), - }))} - bind:value={values[param.id]} - /> - {:else if param.type === "color"} - onPipetteToggle?.(param.id)} - /> - {:else if param.type === "text"} - - {/if} -
- {/each} -
- - diff --git a/web/src/lib/v1/components/Preview.svelte b/web/src/lib/v1/components/Preview.svelte deleted file mode 100644 index b6aeca1..0000000 --- a/web/src/lib/v1/components/Preview.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - -{#if image} - - -

{image.width} × {image.height} px

- {#if pipetteActive && hover && image} - - {/if} -{/if} - - diff --git a/web/src/lib/v1/components/ToolPage.svelte b/web/src/lib/v1/components/ToolPage.svelte deleted file mode 100644 index bb08ca1..0000000 --- a/web/src/lib/v1/components/ToolPage.svelte +++ /dev/null @@ -1,543 +0,0 @@ - - - - -
-

{toolTitle(tool)}

-

{toolDescription(tool)}

- - {#if errorText} - - {/if} - - 0} - {hasMask} - bind:showMask - bind:values - {pipetteTargetId} - {handleFile} - onTextSubmit={handleTextSubmit} - onSourceError={(e) => (errorText = errorMessage(e))} - {reset} - {toggleChain} - {handlePipetteToggle} - {handlePickColor} - {showError} - {errorMessage} - {overlayImage} - onOverlayFile={(f) => void handleOverlayFile(f)} - onOverlayError={(e) => (errorText = errorMessage(e))} - onOverlayClear={clearOverlay} - /> - -
- {#each chain as step, index (step.id)} - {#if step.toolId === ""} -
-
-

- {t("toolPage.stepHeading", { n: index + 2 })} -

- -
- applyChainTool(index, id)} - chainableOnly - /> -
- {:else if getTool(step.toolId)} - {@const stepTool = getTool(step.toolId)!} - {#if stageVariant === "inline"} - {@const StepIcon = TOOL_ICONS[stepTool.id]} - removeChainStep(index)} - onError={showError} - onAddStep={addChainStep} - onRemoveChain={removeChain} - > - {#snippet header()} - - {#if StepIcon} - - {/if} - {t("chain.stepLabel", { - n: index + 2, - title: toolTitle(stepTool), - })} - - - {/snippet} - - {:else} - removeChainStep(index)} - onError={showError} - onAddStep={addChainStep} - onRemoveChain={removeChain} - /> - {/if} - {/if} - {/each} -
-
- - diff --git a/web/src/lib/v1/components/chain/ChainToolBlock.svelte b/web/src/lib/v1/components/chain/ChainToolBlock.svelte deleted file mode 100644 index ae65068..0000000 --- a/web/src/lib/v1/components/chain/ChainToolBlock.svelte +++ /dev/null @@ -1,193 +0,0 @@ - - -
-
- - {#if StepIcon} - - {/if} - {t("chain.stepLabel", { n: index + 2, title: toolTitle(tool) })} - - - -
- -
- -
-
-
- - {#if busy && !result} -
- -
- {:else} -
- - {#if busy} - {t("resultCard.recalc")} - {/if} -
-
- - -
- {/if} -
-
- - {#if tool.params.length > 0} -
- -
- - {/if} -
- - diff --git a/web/src/lib/v1/components/search/ToolCard.svelte b/web/src/lib/v1/components/search/ToolCard.svelte deleted file mode 100644 index 88ea551..0000000 --- a/web/src/lib/v1/components/search/ToolCard.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - -{#snippet content()} - - - {title} - {description} - -{/snippet} - -{#if href} - - {@render content()} - -{:else} -
{ - if (e.key === "Enter") onActivate?.(); - }} - > - {@render content()} -
-{/if} - - diff --git a/web/src/lib/v1/components/search/ToolSearch.svelte b/web/src/lib/v1/components/search/ToolSearch.svelte deleted file mode 100644 index ec62e5e..0000000 --- a/web/src/lib/v1/components/search/ToolSearch.svelte +++ /dev/null @@ -1,176 +0,0 @@ - - - - - diff --git a/web/src/lib/v1/components/stage/ToolStage.svelte b/web/src/lib/v1/components/stage/ToolStage.svelte deleted file mode 100644 index 1f6d01e..0000000 --- a/web/src/lib/v1/components/stage/ToolStage.svelte +++ /dev/null @@ -1,362 +0,0 @@ - - -
- {#if header} - {@render header()} - {/if} - - {#if isChain} -
- -
- -
-
- {:else if !isSourceless} -
- -
- {#if isTextSource && !source} - onTextSubmit?.(text)} /> - {:else} - handleFile?.(f)} - onError={(e) => onSourceError?.(e)} - onReset={() => reset?.()} - pipetteActive={!!pipetteTargetId} - onPickColor={(hex) => handlePickColor?.(hex)} - /> - {#if tool.needsOverlaySource && source} - onOverlayFile?.(f)} - onError={(e) => onOverlayError?.(e)} - onClear={() => onOverlayClear?.()} - /> - {/if} - {/if} -
-
- {/if} - - {#if showParams} -
- -
- handlePipetteToggle?.(id)} - {hasMask} - bind:showMask - /> -
-
- {/if} - -
- -
- {#if isChain} - {#if busy && !result} - - {:else} - - {#if busy} - {t("resultCard.recalc")} - {/if} -
- showError?.(e)} - /> - -
- {/if} - {:else} - showError?.(e)} - /> - {/if} -
-
-
- - diff --git a/web/src/lib/v1/components/stage/ToolStageClassic.svelte b/web/src/lib/v1/components/stage/ToolStageClassic.svelte deleted file mode 100644 index aba184e..0000000 --- a/web/src/lib/v1/components/stage/ToolStageClassic.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - -
-
- {#if !isSourceless} - -
- {#if isTextSource && !source} - - {:else} - - {/if} -
- {/if} - -
- -
-
- - {#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)} -
- -
- - {/if} -
- - diff --git a/web/src/lib/v1/components/stage/stage-props.ts b/web/src/lib/v1/components/stage/stage-props.ts deleted file mode 100644 index e163cc7..0000000 --- a/web/src/lib/v1/components/stage/stage-props.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ImageInfo } from "$lib/core/analyze"; -import type { PixelImage } from "$lib/core/types"; -import type { ToolEntry } from "$lib/v1/registry"; - -export type StageStatus = "idle" | "loaded" | "processing" | "error"; - -export interface StageProps { - tool: ToolEntry; - source: PixelImage | null; - result: PixelImage | null; - displayImage: PixelImage | null; - info: ImageInfo | null; - status: StageStatus; - isInfo: boolean; - isSourceless: boolean; - isTextSource: boolean; - textResult: string | null; - sanitized: Record; - canChainBase: boolean; - hasChain: boolean; - hasMask: boolean; - showMask: boolean; - values: Record; - pipetteTargetId: string | null; - handleFile: (file: File) => Promise | void; - onTextSubmit: (text: string) => Promise | void; - onSourceError: (e: unknown) => void; - reset: () => void; - toggleChain: () => void; - handlePipetteToggle: (id: string) => void; - handlePickColor: (hex: string) => void; - showError: (e: unknown) => void; - errorMessage: (e: unknown) => string; -} - -export interface ChainStageProps { - tool: ToolEntry; - index: number; - input: PixelImage | null; - result: PixelImage | null; - busy: boolean; - isLast: boolean; - values: Record; - onRemove: () => void; - onError: (e: unknown) => void; - onAddStep: () => void; - onRemoveChain: () => void; -} diff --git a/web/src/lib/v1/components/tool/OverlayCard.svelte b/web/src/lib/v1/components/tool/OverlayCard.svelte deleted file mode 100644 index ec06af4..0000000 --- a/web/src/lib/v1/components/tool/OverlayCard.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -
- - {#if !overlay} - - {:else} -
- - -
- {/if} -
- - diff --git a/web/src/lib/v1/components/tool/ParamsCard.svelte b/web/src/lib/v1/components/tool/ParamsCard.svelte deleted file mode 100644 index ea55e6c..0000000 --- a/web/src/lib/v1/components/tool/ParamsCard.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - -
- {#if params.length > 0 || hasMask} - - {:else} -

{t("paramsCard.noParams")}

- {/if} -
- - diff --git a/web/src/lib/v1/components/tool/PipetteLoupe.svelte b/web/src/lib/v1/components/tool/PipetteLoupe.svelte deleted file mode 100644 index 211c0ee..0000000 --- a/web/src/lib/v1/components/tool/PipetteLoupe.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - - - - diff --git a/web/src/lib/v1/components/tool/ResultCard.svelte b/web/src/lib/v1/components/tool/ResultCard.svelte deleted file mode 100644 index 887045b..0000000 --- a/web/src/lib/v1/components/tool/ResultCard.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - -
- {#if !sourceLoaded} -
- -
- {:else if !isInfo && status === "processing" && !result} -
- -
- {:else if isInfo} -
- {#if info} - - {/if} -
- {:else if tool.resultType === "text"} -
- {#if textResult !== null} - - {/if} -
- {:else} -
- - {#if status === "processing"} - {t("resultCard.recalc")} - {/if} -
-
- - {#if onChainToggle} - - {/if} -
- {/if} -
- - diff --git a/web/src/lib/v1/components/tool/SourceCard.svelte b/web/src/lib/v1/components/tool/SourceCard.svelte deleted file mode 100644 index 1e17780..0000000 --- a/web/src/lib/v1/components/tool/SourceCard.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
- {#if !source} - - {:else} - -
- -
- -
- {/if} -
- - diff --git a/web/src/lib/v1/components/tool/TextInputCard.svelte b/web/src/lib/v1/components/tool/TextInputCard.svelte deleted file mode 100644 index 2ffe876..0000000 --- a/web/src/lib/v1/components/tool/TextInputCard.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -
-

{t("textInput.heading")}

- - -
- - diff --git a/web/src/lib/v1/components/tool/TextResult.svelte b/web/src/lib/v1/components/tool/TextResult.svelte deleted file mode 100644 index 81a8ae9..0000000 --- a/web/src/lib/v1/components/tool/TextResult.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - -
- -
- - -
-
- - diff --git a/web/src/lib/v1/components/ui/Button.svelte b/web/src/lib/v1/components/ui/Button.svelte deleted file mode 100644 index f87de44..0000000 --- a/web/src/lib/v1/components/ui/Button.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/web/src/lib/v1/components/ui/CheckboxField.svelte b/web/src/lib/v1/components/ui/CheckboxField.svelte deleted file mode 100644 index 5ffb737..0000000 --- a/web/src/lib/v1/components/ui/CheckboxField.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- - {#if hint} -

{hint}

- {/if} -
- - diff --git a/web/src/lib/v1/components/ui/ColorField.svelte b/web/src/lib/v1/components/ui/ColorField.svelte deleted file mode 100644 index d6d1929..0000000 --- a/web/src/lib/v1/components/ui/ColorField.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - - -
- - {#if onPipetteToggle} - - {/if} -
-
- - diff --git a/web/src/lib/v1/components/ui/EmptyState.svelte b/web/src/lib/v1/components/ui/EmptyState.svelte deleted file mode 100644 index 8b5d898..0000000 --- a/web/src/lib/v1/components/ui/EmptyState.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -
-

{title}

- {#if hint} -

{hint}

- {/if} - {#if children} - {@render children()} - {/if} -
- - diff --git a/web/src/lib/v1/components/ui/Field.svelte b/web/src/lib/v1/components/ui/Field.svelte deleted file mode 100644 index 2b29fe1..0000000 --- a/web/src/lib/v1/components/ui/Field.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -
- -
- {@render children()} - {#if hint} -

{hint}

- {/if} -
-
- - diff --git a/web/src/lib/v1/components/ui/SelectField.svelte b/web/src/lib/v1/components/ui/SelectField.svelte deleted file mode 100644 index 83cc478..0000000 --- a/web/src/lib/v1/components/ui/SelectField.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - - diff --git a/web/src/lib/v1/components/ui/SliderField.svelte b/web/src/lib/v1/components/ui/SliderField.svelte deleted file mode 100644 index f3462cd..0000000 --- a/web/src/lib/v1/components/ui/SliderField.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - - -
- - - - - {value} -
-
- - diff --git a/web/src/lib/v1/components/ui/TextField.svelte b/web/src/lib/v1/components/ui/TextField.svelte deleted file mode 100644 index e7d4e4d..0000000 --- a/web/src/lib/v1/components/ui/TextField.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - diff --git a/web/src/lib/v1/i18n/dict.ts b/web/src/lib/v1/i18n/dict.ts deleted file mode 100644 index 382ddc3..0000000 --- a/web/src/lib/v1/i18n/dict.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { CategoryId } from "../categories"; - -export const LOCALES = ["ru", "en"] as const; -export type Locale = (typeof LOCALES)[number]; - -export const BASE_LOCALE: Locale = "en"; - -export const LOCALE_TAGS: Record = { - ru: "ru-RU", - en: "en-US", -}; - -export function isLocale(value: unknown): value is Locale { - return ( - typeof value === "string" && (LOCALES as readonly string[]).includes(value) - ); -} - -export type ToolStrings = { - title?: string; - description?: string; - /** Подписи параметров по их id. */ - params?: Record; - /** Подписи опций select: paramId -> value -> label. */ - options?: Record>; - /** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */ - results?: Record; -}; - -export type HeaderStrings = { - workspace: string; - catalog: string; - sectionsAria: string; - footerNote: string; -}; - -export type Dict = { - header: HeaderStrings; - categories: Record; - home: Record; - catalog: Record; - toolPage: Record; - chain: Record; - sourceCard: Record; - resultCard: Record; - paramsCard: Record; - textInput: Record; - textResult: Record; - download: Record; - infoPanel: Record; - dropZone: Record; - search: Record; - ui: Record; - errors: Record; - tools: Record; -}; diff --git a/web/src/lib/v1/i18n/en.ts b/web/src/lib/v1/i18n/en.ts deleted file mode 100644 index ee7f100..0000000 --- a/web/src/lib/v1/i18n/en.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { Dict } from "./dict"; - -export const en: Dict = { - header: { - workspace: "Workspace", - catalog: "Catalog", - sectionsAria: "Sections", - footerNote: - "All operations run locally in your browser — your files are never uploaded anywhere.", - }, - categories: { - convert: "Convert", - alpha: "Transparency", - color: "Color", - geometry: "Geometry", - filters: "Filters", - text: "Text", - analyze: "Analyze", - generate: "Generate", - }, - home: { - defaultTitle: "easy-png-tools — PNG utilities right in your browser", - heroTitle: "What do you want to do with the image?", - heroLead: "Find a tool — everything runs locally in your browser.", - restoreLast: "↩ Restore last: {title}", - changeTool: "← Change tool", - }, - catalog: { - pageTitle: "All tools — easy-png-tools", - metaDescription: - "Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.", - heading: "Tool catalog", - lead: "{count} utilities for working with PNG. Everything runs locally in your browser.", - }, - toolPage: { - fallbackTitle: "Tool", - legendSource: "Source", - legendSummary: "Summary", - legendResult: "Result", - legendParams: "Parameters", - stepHeading: "Step {n}", - removeStepAria: "Remove step", - stepError: "Step {n} ({title}): {msg}", - }, - chain: { - stepLabel: "Step {n}: {title}", - inputLegend: "Input", - resultLegend: "Result", - paramsLegend: "Parameters", - busyTitle: "Processing…", - busyHint: "Running chain step", - removeStepAria: "Remove step", - }, - sourceCard: { - replaceImage: "Replace image", - }, - resultCard: { - emptyTitle: "The result will appear here", - emptyHint: "First upload a source image on the left", - processingTitle: "Processing…", - processingHint: "The image is being processed, this will take a moment", - recalc: "Recalculating…", - nextTool: "⛓ Next tool", - breakChain: "✂ Break the chain", - }, - paramsCard: { - noParams: "This tool has no parameters — the result is ready as is.", - }, - textInput: { - heading: "Text", - placeholder: "Paste data here", - aria: "Text data", - decode: "Decode", - }, - textResult: { - outputAria: "Text result", - copied: "Copied", - copy: "Copy", - downloadTxt: "Download .txt", - }, - download: { - busy: "Preparing file…", - file: "Download .{ext}", - }, - infoPanel: { - dimensions: "Dimensions", - alpha: "Alpha channel", - alphaYes: "yes — there are semi-transparent pixels", - alphaNo: "no", - colorCount: "Unique colors (RGBA)", - }, - dropZone: { - pickDefault: "Drop an image here or click to choose a file", - overlayDefault: "Release the file to replace the image", - }, - search: { - placeholder: "Find a tool…", - aria: "Search tools", - nothingFound: "Nothing found — try another word.", - }, - ui: { - showMask: "Show mask", - decrease: "Decrease", - increase: "Increase", - reset: "Reset", - pipette: "Eyedropper", - themeLight: "Light theme", - themeDark: "Dark theme", - overlayTitle: "Watermark", - overlayDrop: "Drop a watermark PNG or click", - overlayRemove: "Remove watermark", - }, - errors: { - noImageRun: "This tool does not process images", - sourceRequired: "Upload an image first", - textRequired: "Enter text first", - workerFailed: "Worker execution failed", - workerUnavailable: "Worker is unavailable", - notFound: "Tool not found", - noWatermark: "Pick a watermark image first", - badTransform: "Degenerate transformation matrix", - skewAngle: "Skew angles cannot be 90° or -90°", - badHex: 'Invalid HEX color: "{value}"', - radiusInt: "Radius must be a non-negative integer", - kernelSize: "Kernel does not match the image dimensions", - sizeInt: "Width and height must be integers ≥ 1", - cropBounds: "Crop area does not intersect the image", - noCanvasCtx: "Canvas 2D context is unavailable in this environment", - badBase64: "Expected a base64 string or a data-uri of an image", - qualityRange: "quality must be within 0..1", - svgSize: "Could not determine SVG dimensions", - svgLoad: "Failed to load SVG — check the markup", - encodeUnsupported: "The browser does not support encoding to {mime}", - unsupportedFile: - "Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.", - widthInt: "Image width must be an integer ≥ 1", - noHexPixels: "No hex pixel values found", - badPixelToken: - "Each pixel must be 8 hex characters RRGGBBAA, separated by spaces", - pixelCountMismatch: - "Pixel count ({count}) is not divisible by width {width} without a remainder", - toolNotFound: 'Tool "{id}" not found', - badJson: "The file is not valid JSON", - badPipelineShape: "The file structure does not look like a chain of steps", - pipelineVersion: "Unsupported chain version: {version}", - noSteps: "The file has no list of steps", - paramNumber: 'Parameter "{id}" must be a number', - paramString: 'Parameter "{id}" must be a string', - paramBool: 'Parameter "{id}" must be a checkbox value', - resizeSize: "Width and/or height must be positive", - cropSize: "Crop width and height must be positive", - sizePositive: "Dimensions must be positive and finite", - tooManyParts: "Too many parts ({count}). Maximum is 1000.", - }, - tools: { - "png-file-size": { - results: { - line: "PNG size: {kb} KB", - }, - }, - "verify-is-png": { - results: { - verifyYes: "Yes — this is a valid PNG signature.", - verifyNo: "No — the signature does not match a PNG file.", - }, - }, - "png-is-grayscale": { - results: { - grayscaleYes: "Yes — all pixels are shades of gray.", - grayscaleNo: "No — colored pixels were found.", - }, - }, - "png-is-transparent": { - results: { - transparentYes: - "Yes — there are transparent or semi-transparent pixels.", - transparentNo: "No — all pixels are fully opaque.", - }, - }, - "png-orientation": { - results: { - orientationPortrait: "Portrait — height is greater than width.", - orientationLandscape: "Landscape — width is greater than height.", - orientationSquare: "Square — the sides are equal.", - }, - }, - }, -}; diff --git a/web/src/lib/v1/i18n/i18n.test.ts b/web/src/lib/v1/i18n/i18n.test.ts deleted file mode 100644 index 25bfd29..0000000 --- a/web/src/lib/v1/i18n/i18n.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getLocale, initLocale, setLocale } from "./locale.svelte"; -import { interpolate, t } from "./t"; -import { en } from "./en"; - -type Store = Record; - -function stubStorage(): { store: Store } { - const store: Store = {}; - vi.stubGlobal("localStorage", { - getItem: (k: string) => (k in store ? store[k] : null), - setItem: (k: string, v: string) => { - store[k] = v; - }, - }); - vi.stubGlobal("window", {}); - return { store }; -} - -beforeEach(() => { - setLocale("ru"); - delete (en.home as Record).onlyEnKey; - vi.unstubAllGlobals(); -}); - -describe("t", () => { - it("возвращает строку по точечному пути активной локали", () => { - expect(t("header.workspace")).toBe("Рабочая область"); - setLocale("en"); - expect(t("header.catalog")).toBe("Catalog"); - }); - - it("фолбэк на английскую базу, если в активной локали нет ключа", () => { - (en.home as Record).onlyEnKey = "Only English string"; - setLocale("ru"); - expect(t("home.onlyEnKey")).toBe("Only English string"); - }); - - it("неизвестный путь возвращает сам путь", () => { - expect(t("no.such.key")).toBe("no.such.key"); - }); -}); - -describe("interpolate", () => { - it("подставляет переменные в шаблон", () => { - expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe( - "Шаг 2 из 5", - ); - }); - - it("оставляет плейсхолдер без переменной как есть", () => { - expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!"); - }); - - it("без переменных возвращает строку без изменений", () => { - expect(interpolate("Просто текст")).toBe("Просто текст"); - }); -}); - -describe("персист локали", () => { - it("initLocale читает сохранённый выбор", () => { - const { store } = stubStorage(); - store["locale"] = "en"; - initLocale(); - expect(getLocale()).toBe("en"); - }); - - it("initLocale игнорирует мусор в хранилище", () => { - const { store } = stubStorage(); - store["locale"] = "fr"; - initLocale(); - expect(getLocale()).toBe("ru"); - }); - - it("setLocale сохраняет выбор в localStorage", () => { - const { store } = stubStorage(); - initLocale(); - setLocale("en"); - expect(store["locale"]).toBe("en"); - expect(getLocale()).toBe("en"); - }); -}); diff --git a/web/src/lib/v1/i18n/locale.svelte.ts b/web/src/lib/v1/i18n/locale.svelte.ts deleted file mode 100644 index d7e1d2f..0000000 --- a/web/src/lib/v1/i18n/locale.svelte.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict"; -import { ru } from "./ru"; -import { en } from "./en"; - -const STORAGE_KEY = "locale"; - -const DICTS: Record = { ru, en }; - -let locale = $state(BASE_LOCALE); - -function storage(): Storage | null { - return typeof localStorage === "undefined" ? null : localStorage; -} - -export function getLocale(): Locale { - return locale; -} - -export function getDict(): Dict { - return DICTS[locale]; -} - -/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */ -export function getMergedDict(): Dict { - if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE]; - const active = DICTS[locale]; - const base = DICTS[BASE_LOCALE]; - return new Proxy(base, { - get(_target, section: string) { - const a = (active as unknown as Record)[section]; - const b = (base as unknown as Record)[section]; - if (a && b && typeof a === "object" && typeof b === "object") { - return { ...(b as object), ...(a as object) }; - } - return a ?? b; - }, - }) as Dict; -} - -export function setLocale(next: Locale): void { - locale = next; - storage()?.setItem(STORAGE_KEY, next); - syncLangAttr(); -} - -/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */ -export function initLocale(): void { - if (typeof window === "undefined") return; - const saved = storage()?.getItem(STORAGE_KEY); - if (isLocale(saved)) locale = saved; - syncLangAttr(); -} - -function syncLangAttr(): void { - if (typeof document !== "undefined") { - document.documentElement.lang = locale; - } -} diff --git a/web/src/lib/v1/i18n/matching.test.ts b/web/src/lib/v1/i18n/matching.test.ts deleted file mode 100644 index a9b48b7..0000000 --- a/web/src/lib/v1/i18n/matching.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TOOLS } from "../registry"; -import { setLocale } from "./locale.svelte"; -import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching"; -import { toolSearchDoc } from "./tool-strings"; - -describe("normalizeForSearch", () => { - it("нижний регистр", () => { - expect(normalizeForSearch("Rotate PNG")).toBe("rotate png"); - }); - - it("ё заменяется на е", () => { - expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж"); - }); - - it("диакритика снимается через NFD", () => { - expect(normalizeForSearch("café")).toBe("cafe"); - expect(normalizeForSearch("Über")).toBe("uber"); - }); -}); - -describe("scoreDoc", () => { - const doc: SearchDoc = { - id: "rotate-free-png", - titles: ["Повернуть на произвольный угол"], - descriptions: [ - "Поворот на любой угол. Холст расширяется под новые габариты.", - ], - }; - - it("пустой запрос даёт нейтральный балл", () => { - expect(scoreDoc(doc, "")).toBe(1); - }); - - it("префикс названия ценнее подстроки", () => { - const prefix = scoreDoc(doc, normalizeForSearch("повер")); - const infix = scoreDoc(doc, normalizeForSearch("верну")); - expect(prefix!).toBe(100); - expect(infix!).toBeGreaterThan(30); - }); - - it("подстрока названия ценнее совпадения в описании", () => { - const title = scoreDoc(doc, normalizeForSearch("угол")); - const desc = scoreDoc(doc, normalizeForSearch("габариты")); - expect(title!).toBeGreaterThan(desc!); - }); - - it("нет совпадения — null", () => { - expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull(); - }); - - it("запрос с опечаткой ё/е всё равно находит", () => { - expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull(); - }); -}); - -describe("кросс-языковой поиск на реальном реестре", () => { - it("английский запрос находит инструмент при русской локали", () => { - setLocale("ru"); - const hits = TOOLS.filter((tool) => { - const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch("rotate")); - return s !== null && s > 0; - }).map((tool) => tool.id); - expect(hits).toContain("rotate-png"); - expect(hits).toContain("rotate-free-png"); - }); - - it("русский запрос находит инструмент при английской локали", () => { - setLocale("en"); - try { - for (const q of ["повернуть", "пово"]) { - const hits = TOOLS.filter((tool) => { - const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q)); - return s !== null && s > 0; - }).map((tool) => tool.id); - expect(hits, `запрос «${q}» при en-локали`).toContain("rotate-png"); - expect(hits, `запрос «${q}» при en-локали`).toContain( - "rotate-free-png", - ); - } - } finally { - setLocale("ru"); - } - }); - - it("поиск работает и по описанию, не только по названию", () => { - setLocale("en"); - try { - const hits = TOOLS.filter((tool) => { - const s = scoreDoc( - toolSearchDoc(tool), - normalizeForSearch("полупрозрачные"), - ); - return s !== null && s > 0; - }).map((tool) => tool.id); - expect(hits).toContain("png-is-transparent"); - } finally { - setLocale("ru"); - } - }); -}); diff --git a/web/src/lib/v1/i18n/matching.ts b/web/src/lib/v1/i18n/matching.ts deleted file mode 100644 index 7f80458..0000000 --- a/web/src/lib/v1/i18n/matching.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD). - */ -export function normalizeForSearch(value: string): string { - return value - .toLowerCase() - .replaceAll("ё", "е") - .normalize("NFD") - .replace(/\p{M}/gu, ""); -} - -export type SearchDoc = { - id: string; - titles: string[]; - descriptions: string[]; -}; - -const SUBSEQUENCE_BASE = 20; - -function bestTitleScore(titles: string[], q: string): number | null { - let best: number | null = null; - for (const raw of titles) { - const title = normalizeForSearch(raw); - if (title.startsWith(q)) return 100; - const at = title.indexOf(q); - if (at >= 0) { - const s = 80 - Math.min(at, 40); - if (best === null || s > best) best = s; - continue; - } - let pos = 0; - let sub = SUBSEQUENCE_BASE; - for (const ch of q) { - pos = title.indexOf(ch, pos); - if (pos < 0) { - sub = -1; - break; - } - sub -= 1; - pos += 1; - } - if (sub >= 0 && (best === null || sub > best)) best = sub; - } - return best; -} - -/** - * Скоринг документа против нормализованного запроса. - * Пустой запрос — нейтральный балл; отсутствие совпадения — null. - */ -export function scoreDoc( - doc: SearchDoc, - normalizedQuery: string, -): number | null { - if (normalizedQuery.length === 0) return 1; - if (normalizeForSearch(doc.id).includes(normalizedQuery)) { - const at = normalizeForSearch(doc.id).indexOf(normalizedQuery); - const byId = 60 - Math.min(at, 40); - const byTitle = bestTitleScore(doc.titles, normalizedQuery); - if (byTitle !== null && byTitle >= 80) return byTitle; - return Math.max(byId, byTitle ?? -1); - } - const byTitle = bestTitleScore(doc.titles, normalizedQuery); - if (byTitle !== null && byTitle >= 20) return byTitle; - for (const raw of doc.descriptions) { - if (normalizeForSearch(raw).includes(normalizedQuery)) return 30; - } - return byTitle !== null && byTitle > 0 ? byTitle : null; -} diff --git a/web/src/lib/v1/i18n/ru.ts b/web/src/lib/v1/i18n/ru.ts deleted file mode 100644 index 15b1669..0000000 --- a/web/src/lib/v1/i18n/ru.ts +++ /dev/null @@ -1,1351 +0,0 @@ -import type { Dict } from "./dict"; - -export const ru: Dict = { - header: { - workspace: "Рабочая область", - catalog: "Каталог", - sectionsAria: "Разделы", - footerNote: - "Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.", - }, - categories: { - convert: "Конвертация", - alpha: "Прозрачность", - color: "Цвет", - geometry: "Геометрия", - filters: "Фильтры", - text: "Текст", - analyze: "Анализ", - generate: "Генерация", - }, - home: { - defaultTitle: "easy-png-tools — PNG-утилиты прямо в браузере", - heroTitle: "Что делаем с изображением?", - heroLead: - "Найдите инструмент — все операции выполняются локально в браузере.", - restoreLast: "↩ Вернуть последний: {title}", - changeTool: "← Сменить инструмент", - }, - catalog: { - pageTitle: "Все инструменты — easy-png-tools", - metaDescription: - "Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений.", - heading: "Каталог инструментов", - lead: "{count} утилит для работы с PNG. Все операции выполняются локально в браузере.", - }, - toolPage: { - fallbackTitle: "Инструмент", - legendSource: "Исходник", - legendSummary: "Сводка", - legendResult: "Результат", - legendParams: "Параметры", - stepHeading: "Шаг {n}", - removeStepAria: "Убрать шаг", - stepError: "Шаг {n} ({title}): {msg}", - }, - chain: { - stepLabel: "Шаг {n}: {title}", - inputLegend: "Вход", - resultLegend: "Результат", - paramsLegend: "Параметры", - busyTitle: "Обработка…", - busyHint: "Выполняется шаг цепочки", - removeStepAria: "Убрать шаг", - }, - sourceCard: { - replaceImage: "Заменить изображение", - }, - resultCard: { - emptyTitle: "Результат появится здесь", - emptyHint: "Сначала загрузите исходное изображение слева", - processingTitle: "Обработка…", - processingHint: "Изображение обрабатывается, это займёт немного времени", - recalc: "Пересчёт…", - nextTool: "⛓ Следующий инструмент", - breakChain: "✂ Оборвать цепочку", - }, - paramsCard: { - noParams: "У этого инструмента нет параметров — результат уже готов.", - }, - textInput: { - heading: "Текст", - placeholder: "Вставьте данные сюда", - aria: "Текстовые данные", - decode: "Декодировать", - }, - textResult: { - outputAria: "Текстовый результат", - copied: "Скопировано", - copy: "Копировать", - downloadTxt: "Скачать .txt", - }, - download: { - busy: "Готовим файл…", - file: "Скачать .{ext}", - }, - infoPanel: { - dimensions: "Размеры", - alpha: "Альфа-канал", - alphaYes: "есть — есть полупрозрачные пиксели", - alphaNo: "нет", - colorCount: "Уникальных цветов (RGBA)", - }, - dropZone: { - pickDefault: "Перетащите изображение сюда или нажмите, чтобы выбрать файл", - overlayDefault: "Отпустите файл, чтобы заменить изображение", - }, - search: { - placeholder: "Найдите инструмент…", - aria: "Поиск инструмента", - nothingFound: "Ничего не найдено — попробуйте другое слово.", - }, - ui: { - showMask: "Показать маску", - decrease: "Уменьшить", - increase: "Увеличить", - reset: "Сбросить", - pipette: "Пипетка", - themeLight: "Светлая тема", - themeDark: "Тёмная тема", - overlayTitle: "Знак", - overlayDrop: "Перетащите PNG-знак или нажмите", - overlayRemove: "Убрать знак", - }, - errors: { - noImageRun: "Этот инструмент не обрабатывает изображения", - sourceRequired: "Сначала загрузите изображение", - textRequired: "Сначала введите текст", - workerFailed: "Ошибка исполнения в воркере", - workerUnavailable: "Воркер недоступен", - notFound: "Инструмент не найден", - noWatermark: "Сначала выберите картинку-знак", - badTransform: "Вырожденная матрица трансформации", - skewAngle: "Углы наклона не могут быть 90° или -90°", - badHex: 'Некорректный HEX-цвет: "{value}"', - radiusInt: "Радиус должен быть целым неотрицательным числом", - kernelSize: "Ядро не совпадает с изображением по размеру", - sizeInt: "Ширина и высота должны быть целыми числами ≥ 1", - cropBounds: "Область обрезки не пересекает изображение", - noCanvasCtx: "Canvas 2D context недоступен в этом окружении", - badBase64: "Ожидается base64-строка или data-uri изображения", - qualityRange: "quality должно быть в диапазоне 0..1", - svgSize: "Не удалось определить размер SVG", - svgLoad: "Не удалось загрузить SVG — проверьте разметку", - encodeUnsupported: "Браузер не поддерживает кодирование в {mime}", - unsupportedFile: - "Неподдерживаемый формат файла ({type}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.", - widthInt: "Ширина изображения должна быть целым числом ≥ 1", - noHexPixels: "Не найдено hex-значений пикселей", - badPixelToken: - "Каждый пиксель — 8 hex-символов RRGGBBAA, значения через пробел", - pixelCountMismatch: - "Число пикселей ({count}) не делится на ширину {width} без остатка", - toolNotFound: 'Инструмент "{id}" не найден', - badJson: "Файл не является корректным JSON", - badPipelineShape: "Структура файла не похожа на цепочку шагов", - pipelineVersion: "Неподдерживаемая версия цепочки: {version}", - noSteps: "В файле нет списка шагов", - paramNumber: 'Параметр "{id}" должен быть числом', - paramString: 'Параметр "{id}" должен быть строкой', - paramBool: 'Параметр "{id}" должен быть значением флажка', - resizeSize: "Ширина и/или высота должны быть положительными", - cropSize: "Ширина и высота области обрезки должны быть положительными", - sizePositive: "Размеры должны быть положительными и конечными", - tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.", - }, - tools: { - "jpg-to-png": { - title: "Конвертировать JPG в PNG", - description: - "Открывает JPEG и сохраняет его как PNG без потерь. Прозрачность, если была, сохраняется.", - }, - "webp-to-png": { - title: "Конвертировать WebP в PNG", - description: "Перекодирует WebP-изображение в универсальный PNG.", - }, - "gif-to-png": { - title: "Конвертировать GIF в PNG", - description: "Достаёт первый кадр GIF-анимации и сохраняет его как PNG.", - }, - "bmp-to-png": { - title: "Конвертировать BMP в PNG", - description: "Перекодирует BMP в компактный PNG без потерь.", - }, - "ico-to-png": { - title: "Конвертировать ICO в PNG", - description: "Превращает иконку .ico в обычный PNG нужного размера.", - }, - "png-to-bmp": { - title: "Конвертировать PNG в BMP", - description: - "Сохраняет изображение в 24-битный BMP без альфа-канала: прозрачность заменяется чёрным фоном.", - }, - "png-to-base64": { - title: "PNG в Base64", - description: - "Кодирует изображение в base64-строку для вставки в код или стили.", - }, - "base64-to-png": { - title: "Base64 в PNG", - description: - "Декодирует base64-строку или data-uri обратно в картинку. Вставьте строку слева.", - }, - "png-to-data-uri": { - title: "PNG в Data URI", - description: - "Строит полный data-uri (data:image/png;base64,…) для встраивания в HTML/CSS.", - }, - "data-uri-to-png": { - title: "Data URI в PNG", - description: "Декодирует data:image/…;base64,… обратно в файл картинки.", - }, - "png-to-hex": { - title: "PNG в HEX-пиксели", - description: - "Показывает все пиксели как hex-значения rrggbbaa — по строкам, через пробел.", - }, - "hex-to-png": { - title: "HEX-пиксели в PNG", - description: - "Собирает картинку из hex-значений rrggbbaa (через пробел). Укажите ширину — высота рассчитается сама.", - params: { width: "Ширина изображения" }, - }, - "resize-png": { - title: "Изменить размер PNG", - description: - "Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций одна сторона задаёт масштаб, а если указаны обе — изображение вписывается в эти размеры.", - params: { - width: "Ширина (0 — авто)", - height: "Высота (0 — авто)", - keepAspect: "Сохранять пропорции", - }, - }, - "crop-png": { - title: "Обрезать PNG", - description: - "Вырезает прямоугольную область. Координаты и размеры выходят за границы изображения — область усекается до пересечения с картинкой.", - params: { - x: "X (слева)", - y: "Y (сверху)", - width: "Ширина области", - height: "Высота области", - }, - }, - "rotate-png": { - title: "Повернуть PNG", - description: - "Поворот на 90°, 180° или 270° по часовой стрелке без потери качества.", - params: { angle: "Угол поворота" }, - options: { - angle: { - "90": "90° по часовой", - "180": "180°", - "270": "270° по часовой", - }, - }, - }, - "flip-png": { - title: "Отразить PNG", - description: - "Зеркальное отражение по горизонтали или вертикали без потери качества.", - params: { axis: "Ось отражения" }, - options: { - axis: { - horizontal: "По горизонтали (слева направо)", - vertical: "По вертикали (сверху вниз)", - }, - }, - }, - "add-padding-png": { - title: "Добавить поля PNG", - description: - "Расширяет холст во все стороны на выбранное число пикселей.", - params: { - padding: "Поля, px", - transparent: "Прозрачные поля", - color: "Цвет полей", - }, - }, - "add-border-png": { - title: "Добавить рамку PNG", - description: "Рисует цветную рамку вокруг изображения выбранной толщины.", - params: { thickness: "Толщина рамки, px", color: "Цвет рамки" }, - }, - "fit-on-background-png": { - title: "Вписать PNG на фон", - description: - "Помещает изображение по центру полотна заданного размера с прозрачным или цветным фоном.", - params: { - width: "Ширина полотна", - height: "Высота полотна", - transparent: "Прозрачный фон", - color: "Цвет фона", - }, - }, - "tile-png": { - title: "Замостить PNG", - description: - "Повторяет изображение сеткой из выбранного числа столбцов и строк.", - params: { columns: "Столбцов", rows: "Строк" }, - }, - "center-by-alpha-png": { - title: "Центрировать PNG по содержимому", - description: - "Находит непрозрачную часть изображения и размещает её по центру прежнего холста.", - }, - "blur-png": { - title: "Размытие PNG", - description: - "Гауссово размытие: три прохода разделяемого бокса — быстро при любом радиусе. Прозрачные края не темнеют.", - params: { radius: "Радиус, px" }, - }, - "sharpen-png": { - title: "Резкость PNG", - description: - "Подчёркивает края ядром резкости; сила задаёт смесь с оригиналом. 0% — без изменений.", - params: { strength: "Сила, %" }, - }, - "grayscale-png": { - title: "Чёрно-белый PNG", - description: - "Переводит изображение в оттенки серого по яркостной формуле BT.601. Альфа сохраняется.", - }, - "invert-colors-png": { - title: "Инвертировать цвета PNG", - description: - "Обращает каждый цветовой канал (255 − значение). Альфа не меняется.", - }, - "adjust-brightness-contrast-png": { - title: "Яркость и контраст PNG", - description: - "Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.", - params: { brightness: "Яркость", contrast: "Контраст" }, - }, - "change-png-opacity": { - title: "Изменить прозрачность PNG", - description: - "Умножает альфа-канал на процент: 0% — полностью прозрачный, 100% — без изменений.", - params: { percent: "Прозрачность, %" }, - }, - "sepia-png": { - title: "Эффект сепии", - description: - "Тонирует изображение в тёплые коричневые тона классической сепии.", - }, - "change-png-hue": { - title: "Сменить оттенок PNG", - description: - "Сдвиг цветового тона по кругу. Насыщенность и яркость сохраняются.", - params: { degrees: "Сдвиг тона, °" }, - }, - "extract-channel-png": { - title: "Извлечь канал PNG", - description: - "Оставляет выбранный канал — красный, зелёный или синий — в оттенках серого.", - params: { channel: "Канал" }, - options: { channel: { red: "Красный", green: "Зелёный", blue: "Синий" } }, - }, - "swap-channels-png": { - title: "Переставить каналы PNG", - description: - "Меняет местами два цветовых канала — быстрый способ получить необычный окрас.", - params: { pair: "Пара каналов" }, - options: { - pair: { - "r-g": "Красный ↔ Зелёный", - "r-b": "Красный ↔ Синий", - "g-b": "Зелёный ↔ Синий", - }, - }, - }, - "black-and-white-png": { - title: "Чёрно-белый PNG по порогу", - description: - "Жёсткая бинаризация по яркости: каждый пиксель становится чёрным или белым.", - params: { threshold: "Порог яркости, %" }, - }, - "posterize-png": { - title: "Постеризация PNG", - description: "Уменьшает число уровней каждого канала — плакатный эффект.", - params: { levels: "Уровней на канал" }, - }, - "two-colors-png": { - title: "Два цвета PNG", - description: - "Перекрашивает изображение в два выбранных цвета по порогу яркости.", - params: { - lightColor: "Цвет светлых участков", - darkColor: "Цвет тёмных участков", - threshold: "Порог яркости, %", - }, - }, - "convert-png-to-jpg": { - title: "Конвертировать PNG в JPG", - description: - "Прозрачность накладывается на выбранный цвет подложки (по умолчанию белый), результат сохраняется в JPEG.", - params: { background: "Цвет подложки", quality: "Качество JPEG" }, - }, - "convert-png-to-webp": { - title: "Конвертировать PNG в WebP", - description: - "Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.", - params: { quality: "Качество WebP" }, - }, - "remove-alpha-channel-png": { - title: "Убрать альфа-канал PNG", - description: - "Накладывает изображение на белый фон и сохраняет без прозрачности.", - }, - "set-alpha-channel-png": { - title: "Задать альфа-канал PNG", - description: - "Присваивает всем пикселям одинаковую прозрачность, цвета не меняются.", - params: { percent: "Прозрачность, %" }, - }, - "extract-alpha-mask-png": { - title: "Извлечь маску альфы PNG", - description: "Превращает прозрачность в чёрно-белую непрозрачную маску.", - }, - "round-corners-png": { - title: "Скруглить углы PNG", - description: - "Обрезает углы по радиусу, заданному в процентах от половины меньшей стороны.", - params: { radius: "Радиус скругления, %" }, - }, - "invert-alpha-png": { - title: "Инвертировать альфа-канал PNG", - description: "Непрозрачные области становятся прозрачными и наоборот.", - }, - "remove-background-png": { - title: "Удалить фон PNG (умно)", - description: - "Убирает однотонный фон: по цвету с допуском, только внешние области от краёв или весь совпадающий цвет. Умеет сглаживать границу.", - params: { - color: "Цвет фона", - tolerance: "Допуск похожести, %", - outerOnly: "Только внешние области", - smooth: "Сглаживание границы, проходы", - }, - }, - "add-stroke-png": { - title: "Обвести PNG", - description: - "Добавляет цветную обводку-кольцо вокруг непрозрачного содержимого заданной толщины.", - params: { color: "Цвет обводки", thickness: "Толщина, px" }, - }, - "find-contour-png": { - title: "Найти контур PNG", - description: - "Оставляет только линию по границе непрозрачных областей выбранного цвета и толщины.", - params: { color: "Цвет линии", thickness: "Толщина линии, px" }, - }, - "make-thicker-png": { - title: "Утолщить PNG", - description: "Расширяет непрозрачные области на заданное число пикселей.", - params: { radius: "На сколько px" }, - }, - "make-thinner-png": { - title: "Утончить PNG", - description: - "Сужает непрозрачные области — утоньшает штрихи надписей и деталей.", - params: { radius: "На сколько px" }, - }, - "harden-alpha-png": { - title: "Жёсткие края PNG", - description: - "Бинаризует альфа-канал по порогу: полупрозрачные пиксели становятся либо полностью прозрачными, либо непрозрачными.", - params: { threshold: "Порог альфы, %" }, - }, - "despeckle-alpha-png": { - title: "Убрать мусор PNG", - description: - "Открытие: убирает одиночные полупрозрачные пиксели и мелкие крапинки.", - params: { radius: "Радиус очистки, px" }, - }, - "close-holes-png": { - title: "Закрыть дыры PNG", - description: - "Закрытие: заполняет одиночные прозрачные точки внутри объекта.", - params: { radius: "Радиус закрытия, px" }, - }, - "remove-color-from-png": { - title: "Удалить цвет из PNG (прозрачность)", - description: - "Делает прозрачными все пиксели, близкие к выбранному цвету. Порог задаёт допустимое отклонение в процентах от максимального цветового расстояния.", - params: { - targetColor: "Цвет для удаления", - tolerance: "Порог похожести, %", - }, - }, - "png-info": { - title: "Информация о PNG", - description: - "Показывает размеры, наличие альфа-канала и количество уникальных цветов загруженного изображения.", - }, - "create-empty-png": { - title: "Создать пустой PNG", - description: - "Генерирует холст выбранного размера — прозрачный или залитый цветом.", - params: { - width: "Ширина", - height: "Высота", - transparent: "Прозрачный", - color: "Цвет", - }, - }, - "single-color-png": { - title: "Создать одноцветный PNG", - description: "Генерирует прямоугольник заданного размера и цвета.", - params: { width: "Ширина", height: "Высота", color: "Цвет" }, - }, - "random-noise-png": { - title: "Создать случайный шум PNG", - description: - "Генерирует картинку со случайными пикселями. Зерно фиксирует результат: одно зерно — одна картинка.", - params: { width: "Ширина", height: "Высота", seed: "Зерно" }, - }, - "linear-gradient-png": { - title: "Создать градиент PNG", - description: - "Генерирует плавный переход между двумя цветами по горизонтали или вертикали.", - params: { - width: "Ширина", - height: "Высота", - fromColor: "Цвет начала", - toColor: "Цвет конца", - direction: "Направление", - }, - options: { - direction: { horizontal: "По горизонтали", vertical: "По вертикали" }, - }, - }, - "color-wheel-png": { - title: "Цветовой круг PNG", - description: - "Круг HSL: оттенок по окружности, насыщенность от центра к краю, выбранная светлота.", - params: { width: "Размер", lightness: "Светлота, %" }, - }, - "complementary-png": { - title: "Комплементарная палитра PNG", - description: - "Два противоположных цвета круга — базовый и его дополнение.", - params: { - baseColor: "Базовый цвет", - width: "Ширина", - layout: "Раскладка", - }, - options: { layout: { grid: "Сетка", strip: "Полоса" } }, - }, - "triadic-png": { - title: "Триадная палитра PNG", - description: "Три цвета через 120° друг от друга на цветовом круге.", - params: { - baseColor: "Базовый цвет", - width: "Ширина", - layout: "Раскладка", - }, - options: { layout: { grid: "Сетка", strip: "Полоса" } }, - }, - "tetradic-png": { - title: "Тетрадная палитра PNG", - description: "Четыре цвета — две комплементарные пары, шаг 90° по кругу.", - params: { - baseColor: "Базовый цвет", - width: "Ширина", - layout: "Раскладка", - }, - options: { layout: { grid: "Сетка", strip: "Полоса" } }, - }, - "analogous-png": { - title: "Аналоговая палитра PNG", - description: - "Соседние оттенки вокруг базового — спокойная родственная гамма.", - params: { - baseColor: "Базовый цвет", - width: "Ширина", - layout: "Раскладка", - spread: "Разброс оттенка, °", - count: "Сколько цветов", - }, - options: { layout: { grid: "Сетка", strip: "Полоса" } }, - }, - "monochromatic-png": { - title: "Монохромная палитра PNG", - description: - "Тоны одного оттенка: меняется светлота в выбранном диапазоне, тон и насыщенность фиксированы.", - params: { - baseColor: "Базовый цвет", - width: "Ширина", - layout: "Раскладка", - count: "Сколько цветов", - range: "Диапазон светлоты, %", - }, - options: { layout: { grid: "Сетка", strip: "Полоса" } }, - }, - "shades-png": { - title: "Градация оттенка PNG", - description: "Ступени базового цвета от исходного к более тёмному.", - params: { - baseColor: "Базовый цвет", - width: "Ширина", - layout: "Раскладка", - count: "Сколько цветов", - depth: "Глубина затемнения, %", - }, - options: { layout: { grid: "Сетка", strip: "Полоса" } }, - }, - "mix-colors-png": { - title: "Смешать цвета PNG", - description: - "Усредняет несколько hex-цветов в один свотч. Введите значения через запятую; неверные токены пропускаются.", - params: { colors: "Цвета (hex через запятую)", width: "Ширина" }, - }, - "blend-two-png": { - title: "Перелив двух цветов PNG", - description: "Непрерывный горизонтальный градиент между двумя цветами.", - params: { colorA: "Цвет A", colorB: "Цвет B", width: "Ширина" }, - }, - "step-colors-png": { - title: "Ступени между цветами PNG", - description: - "Дискретный набор равномерно распределённых ступеней между двумя цветами.", - params: { - colorA: "Цвет A", - colorB: "Цвет B", - steps: "Сколько ступеней", - width: "Ширина", - layout: "Раскладка", - }, - options: { layout: { grid: "Сетка", strip: "Полоса" } }, - }, - "sort-colors-png": { - title: "Отсортировать цвета PNG", - description: - "Рисует ваш hex-список свотчами, отсортированными по тону, яркости или насыщенности. Неверные токены пропускаются.", - params: { - colors: "Цвета (hex через запятую)", - order: "Сортировка", - width: "Ширина", - layout: "Раскладка", - }, - options: { - order: { hue: "Оттенок", luma: "Яркость", sat: "Насыщенность" }, - layout: { grid: "Сетка", strip: "Полоса" }, - }, - }, - "png-is-grayscale": { - title: "Проверить: PNG монохромный?", - description: - "Сообщает, состоит ли изображение только из оттенков серого.", - results: { - grayscaleYes: "Да — все пиксели являются оттенками серого.", - grayscaleNo: "Нет — найдены цветные пиксели.", - }, - }, - "png-to-hsl": { - title: "Разложить PNG в HSL", - description: - "Раскладывает изображение на компоненты Тон, Насыщенность и Светлота.", - params: { component: "Компонент", display: "Режим показа" }, - options: { - component: { h: "Тон (H)", s: "Насыщенность (S)", l: "Светлота (L)" }, - display: { gray: "Градациями серого", color: "Пространство как RGB" }, - }, - }, - "png-to-hsv": { - title: "Разложить PNG в HSV", - description: - "Раскладывает изображение на Тон, Насыщенность и Яркость (Value).", - params: { component: "Компонент", display: "Режим показа" }, - options: { - component: { h: "Тон (H)", s: "Насыщенность (S)", v: "Яркость (V)" }, - display: { gray: "Градациями серого", color: "Пространство как RGB" }, - }, - }, - "png-to-hsi": { - title: "Разложить PNG в HSI", - description: - "Раскладывает изображение на Тон, Насыщенность и Интенсивность.", - params: { component: "Компонент", display: "Режим показа" }, - options: { - component: { - h: "Тон (H)", - s: "Насыщенность (S)", - i: "Интенсивность (I)", - }, - display: { gray: "Градациями серого", color: "Пространство как RGB" }, - }, - }, - "png-to-cmyk": { - title: "PNG в CMYK-цвета", - description: - "Раскладывает изображение на печатные компоненты: Голубой, Пурпурный, Жёлтый и Чёрный (Key).", - params: { component: "Компонент", display: "Режим показа" }, - options: { - component: { - c: "Голубой (C)", - m: "Пурпурный (M)", - y: "Жёлтый (Y)", - k: "Чёрный (K)", - }, - display: { gray: "Градациями серого", color: "Пространство как RGB" }, - }, - }, - "png-to-ycbcr": { - title: "PNG в YCbCr-цвета", - description: - "Раскладывает изображение на Яркость (Y) и цветоразностные компоненты Cb / Cr.", - params: { component: "Компонент", display: "Режим показа" }, - options: { - component: { - y: "Яркость (Y)", - cb: "Синий-разностный (Cb)", - cr: "Красный-разностный (Cr)", - }, - display: { gray: "Градациями серого", color: "Пространство как RGB" }, - }, - }, - "png-to-lab": { - title: "PNG в LAB-цвета", - description: - "Раскладывает изображение на perceptual-компоненты: Светлость, зелёный–пурпурный и синий–жёлтый.", - params: { component: "Компонент", display: "Режим показа" }, - options: { - component: { - l: "Светлость (L)", - a: "Зелёный–пурпурный (a)", - b: "Синий–жёлтый (b)", - }, - display: { gray: "Градациями серого", color: "Пространство как RGB" }, - }, - }, - "circle-mask-png": { - title: "Круглая маска PNG", - description: - "Вырезает из изображения круг. Диаметр задаётся в процентах от меньшей стороны.", - params: { - size: "Диаметр, % меньшей стороны", - offsetX: "Смещение X, %", - offsetY: "Смещение Y, %", - }, - }, - "square-mask-png": { - title: "Прямоугольная маска PNG", - description: - "Вырезает прямоугольник со сторонами в процентах от меньшей стороны изображения.", - params: { - widthPct: "Ширина, % меньшей стороны", - heightPct: "Высота, % меньшей стороны", - offsetX: "Смещение X, %", - offsetY: "Смещение Y, %", - }, - }, - "star-mask-png": { - title: "Маска-звезда PNG", - description: - "Вырезает звезду с настраиваемым числом лучей, глубиной впадин и поворотом.", - params: { - points: "Лучи", - innerRadius: "Радиус впадин, %", - size: "Внешний радиус, % меньшей стороны", - rotation: "Поворот, °", - offsetX: "Смещение X, %", - offsetY: "Смещение Y, %", - }, - }, - "wavy-mask-png": { - title: "Волнистая маска PNG", - description: - "Вырезает круг с волнистым краем: радиус модулируется синусом с заданной амплитудой и частотой.", - params: { - size: "Базовый радиус, % меньшей стороны", - amplitude: "Амплитуда волн, %", - waves: "Количество волн", - phase: "Фаза, °", - offsetX: "Смещение X, %", - offsetY: "Смещение Y, %", - }, - }, - "trim-empty-space-png": { - title: "Обрезать пустые поля PNG", - description: - "Обрезает прозрачные рамки вокруг содержимого. Пиксели с альфой выше порога считаются содержимым.", - params: { threshold: "Порог альфы" }, - }, - "change-canvas-size-png": { - title: "Изменить размер холста PNG", - description: - "Задаёт точный размер холста: лишнее обрезается, недостающее дополняется прозрачностью. Якорь выбирает, какая часть изображения остаётся.", - params: { width: "Ширина", height: "Высота", anchor: "Якорь" }, - options: { - anchor: { - "top-left": "Сверху слева", - "top-center": "Сверху по центру", - "top-right": "Сверху справа", - "middle-left": "По центру слева", - center: "По центру", - "middle-right": "По центру справа", - "bottom-left": "Снизу слева", - "bottom-center": "Снизу по центру", - "bottom-right": "Снизу справа", - }, - }, - }, - "change-aspect-ratio-png": { - title: "Изменить соотношение сторон PNG", - description: - "Вписывает изображение в целевое соотношение сторон: обрезать центр до заполнения или дополнить прозрачностью.", - params: { ratio: "Целевое отношение", mode: "Режим" }, - options: { - ratio: { - "1:1": "1:1", - "4:3": "4:3", - "3:4": "3:4", - "3:2": "3:2", - "2:3": "2:3", - "16:9": "16:9", - "9:16": "9:16", - }, - mode: { - crop: "Обрезать до заполнения", - pad: "Дополнить до вписывания", - }, - }, - }, - "swap-orientation-png": { - title: "Поменять ориентацию PNG", - description: - "Поворачивает изображение на 90°, если ориентация отличается от целевой — ландшафт становится портретом и наоборот. Квадрат не трогается.", - params: { target: "Целевая ориентация" }, - options: { target: { portrait: "Портрет", landscape: "Ландшафт" } }, - }, - "symmetric-copy-png": { - title: "Симметричная копия PNG", - description: - "Удваивает холст, зеркалируя сохранённую половину на пустую — мгновенный симметричный узор.", - params: { axis: "Линия зеркала", keepSide: "Какая сторона остаётся" }, - options: { - axis: { - vertical: "Вертикальная (ширина ×2)", - horizontal: "Горизонтальная (высота ×2)", - }, - keepSide: { - left: "Левая", - right: "Правая", - top: "Верхняя", - bottom: "Нижняя", - }, - }, - }, - "feather-edges-png": { - title: "Растушевать края PNG", - description: - "Размывает только альфа-канал: жёсткие края выреза становятся мягкими, цвета не трогаются.", - params: { radius: "Радиус растушёвки, px" }, - }, - "clean-edges-png": { - title: "Почистить края PNG (defringe)", - description: - "Заменяет цветную кайму полупрозрачных пикселей цветом ближайшего полностью непрозрачного соседа. Альфа остаётся как была.", - params: { radius: "Радиус поиска, px" }, - }, - "pixelate-png": { - title: "Пикселизация PNG", - description: - "Усредняет каждый блок blockSize×blockSize в один цвет — классическая мозаика.", - params: { blockSize: "Размер блока, px" }, - }, - "randomize-pixels-png": { - title: "Перемешать пиксели PNG", - description: - "Переставляет блоки изображения между собой. Одинаковый seed даёт одинаковую раскладку.", - params: { blockSize: "Размер блока, px", seed: "Seed" }, - }, - "add-noise-png": { - title: "Добавить шум PNG", - description: - "Добавляет зерно в стиле плёнки. Детерминировано по seed; монохромный сохраняет баланс оттенков.", - params: { amount: "Сила, %", mode: "Тип шума", seed: "Seed" }, - options: { mode: { mono: "Монохромное зерно", color: "Цветной шум" } }, - }, - "silhouette-png": { - title: "Силуэт PNG", - description: - "Заливает все видимые пиксели одним цветом, сохраняя их прозрачность — мгновенный силуэт.", - params: { color: "Цвет силуэта", threshold: "Порог видимости, %" }, - }, - "png-to-bytes": { - title: "PNG в байты", - description: - "Выводит каждый пиксель четырьмя десятичными байтами (R G B A), по строке изображения на строку текста.", - }, - "bytes-to-png": { - title: "Байты в PNG", - description: - "Собирает изображение из десятичных RGBA-байтов (разделители любые). Укажите ширину — высота рассчитается сама.", - params: { width: "Ширина изображения" }, - }, - "png-to-rgb-values": { - title: "PNG в RGB-значения", - description: - "Выводит каждый пиксель как rgba(r, g, b, a), ряды через перевод строки.", - }, - "rgb-values-to-png": { - title: "RGB-значения в PNG", - description: - "Собирает изображение из чисел rgba(r, g, b, a). Укажите ширину — высота рассчитается сама.", - params: { width: "Ширина изображения" }, - }, - "verify-is-png": { - title: "Проверить: это PNG?", - description: - "Проверяет сигнатуру вставленного base64 / data-uri и сообщает, настоящий ли это PNG.", - results: { - verifyYes: "Да — сигнатура настоящего PNG.", - verifyNo: "Нет — сигнатура не совпадает с PNG-файлом.", - }, - }, - "text-to-png": { - title: "Текст в PNG", - description: - "Создаёт PNG из текста: холст подгоняется под размер надписи с полями.", - params: { - text: "Текст", - fontSize: "Размер шрифта, px", - font: "Шрифт", - bold: "Жирный", - color: "Цвет текста", - transparentBg: "Прозрачный фон", - backgroundColor: "Цвет фона", - padding: "Отступ, px", - }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - }, - }, - "emoji-to-png": { - title: "Эмодзи в PNG", - description: - "Рисует эмодзи или любой Unicode-символ как прозрачный PNG выбранного размера.", - params: { emoji: "Эмодзи / символ", size: "Размер" }, - }, - "placeholder-png": { - title: "Заглушка PNG", - description: - "Генерирует прямоугольник-заглушку с напечатанными размерами в центре.", - params: { - width: "Ширина", - height: "Высота", - backgroundColor: "Фон", - color: "Цвет текста", - showText: "Печатать размеры", - }, - }, - "color-spectrum-png": { - title: "Спектр цветов PNG", - description: - "Полный радужный переход 0–360° вдоль выбранной оси с настройкой насыщенности и светлоты.", - params: { - width: "Ширина", - height: "Высота", - direction: "Направление", - saturation: "Насыщенность, %", - lightness: "Светлота, %", - }, - options: { - direction: { horizontal: "По горизонтали", vertical: "По вертикали" }, - }, - }, - "random-colors-png": { - title: "Случайные цветные блоки PNG", - description: - "Заполняет холст случайными яркими блоками. Детерминировано по seed.", - params: { - width: "Ширина", - height: "Высота", - blockSize: "Размер блока, px", - seed: "Seed", - }, - }, - "draw-grid-png": { - title: "Нарисовать сетку PNG", - description: - "Рисует сетку с заданными столбцами, строками и толщиной линий на прозрачном или белом фоне.", - params: { - width: "Ширина", - height: "Высота", - cols: "Столбцы", - rows: "Строки", - lineWidth: "Толщина линий, px", - color: "Цвет линий", - transparentBg: "Прозрачный фон", - }, - }, - "quantize-png": { - title: "Квантовать PNG", - description: - "Уменьшает изображение до k цветов через median-cut палитру. Прозрачные пиксели сохраняются.", - params: { colors: "Цветов (k)" }, - }, - "decrease-color-count-png": { - title: "Уменьшить число цветов PNG", - description: - "Тот же движок median-cut с фиксированными пресетами степеней двойки — быстрый спуск до 2–256 цветов.", - params: { maxColors: "Максимум цветов" }, - options: { - maxColors: { - "2": "2", - "4": "4", - "8": "8", - "16": "16", - "32": "32", - "64": "64", - "128": "128", - "256": "256", - }, - }, - }, - "custom-palette-png": { - title: "Своя палитра PNG", - description: - "Сопоставляет каждый пиксель с ближайшим цветом из вашего списка hex через запятую.", - params: { colors: "Палитра (hex через запятую)" }, - }, - "dithering-png": { - title: "Дизеринг PNG", - description: - "Применяет распространение ошибки Флойда–Стейнберга или упорядоченный Байер при сведении к k цветам.", - params: { colors: "Цветов (k)", pattern: "Узор" }, - options: { - pattern: { "floyd-steinberg": "Флойд–Стейнберг", bayer: "Байер 4×4" }, - }, - }, - "compress-png": { - title: "Сжать PNG", - description: - "Уменьшает размер PNG сокращением палитры до выбранного уровня. Честный компромисс: меньше цветов — меньше файл.", - params: { level: "Уровень сжатия" }, - options: { - level: { - light: "Лёгкий (192 цвета)", - balanced: "Сбалансированный (96 цветов)", - strong: "Сильный (44 цвета)", - extreme: "Экстремальный (16 цветов)", - }, - }, - }, - "reduce-to-size-png": { - title: "Вписать PNG в размер", - description: - "Бинарным поиском подбирает размер палитры, чтобы закодированный PNG уложился в целевое количество КБ. Если не влезает даже при 2 цветах — вернёт двухцветный вариант.", - params: { - targetKB: "Целевой размер, КБ", - maxColors: "Максимум цветов для перебора", - }, - }, - "png-file-size": { - title: "Размер файла PNG", - description: - "Кодирует изображение в PNG и показывает итоговый размер файла.", - results: { - line: "Размер PNG: {kb} КБ", - }, - }, - "show-transparent-png": { - title: "Показать прозрачные области PNG", - description: - "Подсвечивает выбранным цветом каждый прозрачный или полупрозрачный пиксель — дыры становятся заметными.", - params: { - mode: "Режим маски", - color: "Цвет подсветки", - opacity: "Непрозрачность подсветки, %", - }, - options: { - mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, - }, - }, - "show-grayscale-pixels-png": { - title: "Показать серые пиксели PNG", - description: - "Находит пиксели с почти равными каналами и рисует их маску. Допуск — в единицах канала.", - params: { - tolerance: "Допуск по каналам", - mode: "Режим маски", - color: "Цвет подсветки", - opacity: "Непрозрачность подсветки, %", - }, - options: { - mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, - }, - }, - "show-color-pixels-png": { - title: "Показать цветные пиксели PNG", - description: - "Находит цветные (не серые) пиксели за пределами допуска и рисует их маску.", - params: { - tolerance: "Допуск по каналам", - mode: "Режим маски", - color: "Цвет подсветки", - opacity: "Непрозрачность подсветки, %", - }, - options: { - mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, - }, - }, - "light-pixel-mask-png": { - title: "Маска светлых пикселей PNG", - description: "Выбирает пиксели ярче порога яркости.", - params: { - threshold: "Порог яркости, %", - mode: "Режим маски", - color: "Цвет подсветки", - opacity: "Непрозрачность подсветки, %", - }, - options: { - mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, - }, - }, - "dark-pixel-mask-png": { - title: "Маска тёмных пикселей PNG", - description: "Выбирает пиксели темнее порога яркости.", - params: { - threshold: "Порог яркости, %", - mode: "Режим маски", - color: "Цвет подсветки", - opacity: "Непрозрачность подсветки, %", - }, - options: { - mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, - }, - }, - "unique-color-mask-png": { - title: "Маска уникальных цветов PNG", - description: - "Выбирает цвета, которые встречаются не чаще заданного числа раз, — редкие и одиночные пиксели.", - params: { - rarity: "Максимум повторов", - mode: "Режим маски", - color: "Цвет подсветки", - opacity: "Непрозрачность подсветки, %", - }, - options: { - mode: { binary: "Чёрно-белая маска", highlight: "Цветная подсветка" }, - }, - }, - "extract-color-from-png": { - title: "Извлечь цвет из PNG", - description: - "Оставляет только пиксели, близкие к выбранному цвету, остальное делает прозрачным — обратное «Удалить цвет».", - params: { - color: "Какой цвет оставить", - tolerance: "Допуск похожести, %", - }, - }, - "watermark-tile-png": { - title: "Плитка-водяной знак PNG", - description: - "Покрывает изображение повторяющейся диагональной полупрозрачной плиткой текста — защитный водяной знак.", - params: { - text: "Текст", - fontSize: "Размер шрифта, px", - color: "Цвет текста", - opacity: "Непрозрачность, %", - angle: "Угол, °", - stepX: "Шаг X, px", - stepY: "Шаг Y, px", - font: "Шрифт", - bold: "Жирный", - }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - }, - }, - "watermark-image-png": { - title: "Знак картинкой PNG", - description: - "Накладывает другой PNG (логотип, подпись): масштаб от ширины холста, прозрачность, позиция 3×3. Знак живёт, пока открыта страница, — после восстановления цепочки выберите его заново.", - params: { - scale: "Ширина знака, % от холста", - opacity: "Непрозрачность, %", - position: "Позиция", - margin: "Отступ, px", - }, - options: { - position: { - "top-left": "Сверху слева", - "top-center": "Сверху по центру", - "top-right": "Сверху справа", - "middle-left": "По центру слева", - center: "По центру", - "middle-right": "По центру справа", - "bottom-left": "Снизу слева", - "bottom-center": "Снизу по центру", - "bottom-right": "Снизу справа", - }, - }, - }, - "add-text-png": { - title: "Надпись на PNG", - description: - "Рисует текст на изображении: шрифт, размер, цвет, жирность, позиция на сетке 3×3 и опциональная подложка.", - params: { - text: "Текст", - fontSize: "Размер шрифта, px", - color: "Цвет текста", - font: "Шрифт", - bold: "Жирный", - position: "Позиция", - margin: "Отступ, px", - plate: "Подложка", - plateColor: "Цвет подложки", - plateOpacity: "Прозрачность плашки, %", - }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - position: { - "top-left": "Сверху слева", - "top-center": "Сверху по центру", - "top-right": "Сверху справа", - "middle-left": "По центру слева", - center: "По центру", - "middle-right": "По центру справа", - "bottom-left": "Снизу слева", - "bottom-center": "Снизу по центру", - "bottom-right": "Снизу справа", - }, - }, - }, - "date-stamp-png": { - title: "Дата-штамп PNG", - description: - "Ставит текущую дату и время по строке формата (токены YYYY MM DD hh mm ss). Оформление — как у надписи.", - params: { - format: "Формат", - fontSize: "Размер шрифта, px", - color: "Цвет текста", - font: "Шрифт", - bold: "Жирный", - position: "Позиция", - margin: "Отступ, px", - plate: "Подложка", - plateColor: "Цвет подложки", - plateOpacity: "Прозрачность плашки, %", - }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - position: { - "top-left": "Сверху слева", - "top-center": "Сверху по центру", - "top-right": "Сверху справа", - "middle-left": "По центру слева", - center: "По центру", - "middle-right": "По центру справа", - "bottom-left": "Снизу слева", - "bottom-center": "Снизу по центру", - "bottom-right": "Снизу справа", - }, - }, - }, - "skew-png": { - title: "Наклонить PNG", - description: - "Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.", - params: { degX: "Наклон по X, °", degY: "Наклон по Y, °" }, - }, - "rotate-free-png": { - title: "Повернуть на произвольный угол", - description: - "Поворот на любой угол. Холст расширяется под новые габариты, углы остаются прозрачными.", - params: { angle: "Угол, °" }, - }, - "zoom-png": { - title: "Приблизить PNG", - description: - "Увеличивает содержимое к центру. Холст прежнего размера — края обрезаются.", - params: { scale: "Масштаб, %" }, - }, - "shift-png": { - title: "Сдвинуть PNG", - description: "Перемещает содержимое на заданное смещение по X и Y.", - params: { - offsetX: "Смещение X, px", - offsetY: "Смещение Y, px", - color: "Цвет фона", - }, - }, - "vignette-png": { - title: "Виньетка PNG", - description: "Плавно затемняет края изображения, центр не затрагивает.", - params: { strength: "Сила затемнения, %" }, - }, - "jpeg-artifacts-png": { - title: "Артефакты JPEG", - description: - "Имитирует пережатие в JPEG с низким качеством — видимые квадраты и размытие цветов.", - params: { quality: "Качество JPEG" }, - }, - "gamma-png": { - title: "Гамма-коррекция PNG", - description: - "Корректирует яркость средних тонов. <1 темнее, >1 светлее, 1 — без изменений.", - params: { value: "Гамма" }, - }, - "auto-contrast-png": { - title: "Автоконтраст PNG", - description: - "Растягивает диапазон каждого канала на весь доступный диапазон яркости.", - }, - "temperature-png": { - title: "Температура PNG", - description: - "Положительные значения делают изображение теплее (оранжевее), отрицательные — холоднее (синеватее).", - params: { percent: "Температура" }, - }, - "tint-png": { - title: "Тонировать PNG", - description: - "Умножает цветовые каналы на выбранный оттенок с заданной силой.", - params: { color: "Цвет тонирования", strength: "Сила, %" }, - }, - "svg-to-png": { - title: "SVG в PNG", - description: - "Декодирует SVG-разметку в растровое изображение. Вставьте SVG-код слева.", - params: { width: "Ширина результата, px" }, - }, - "png-is-transparent": { - title: "Проверить: PNG прозрачный?", - description: - "Сообщает, есть ли в изображении прозрачные или полупрозрачные пиксели.", - results: { - transparentYes: "Да — есть прозрачные или полупрозрачные пиксели.", - transparentNo: "Нет — все пиксели полностью непрозрачны.", - }, - }, - "png-orientation": { - title: "Ориентация PNG", - description: "Сообщает, портрет это, ландшафт или квадрат.", - results: { - orientationPortrait: "Портрет — высота больше ширины.", - orientationLandscape: "Ландшафт — ширина больше высоты.", - orientationSquare: "Квадрат — стороны равны.", - }, - }, - }, -}; diff --git a/web/src/lib/v1/i18n/search-coverage.test.ts b/web/src/lib/v1/i18n/search-coverage.test.ts deleted file mode 100644 index 6fa3419..0000000 --- a/web/src/lib/v1/i18n/search-coverage.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isChainable, TOOLS } from "../registry"; -import { normalizeForSearch, scoreDoc } from "./matching"; -import { toolSearchDoc } from "./tool-strings"; - -function hits(q: string): string[] { - const nq = normalizeForSearch(q); - return TOOLS.filter((t) => { - const s = scoreDoc(toolSearchDoc(t), nq); - return s !== null && s > 0; - }).map((t) => t.id); -} - -describe("полнота поиска", () => { - it("генератор (не chainable) находится поиском — раньше отфильтровывался", () => { - expect(isChainable(TOOLS.find((t) => t.id === "color-wheel-png")!)).toBe( - false, - ); - expect(hits("color wheel")).toContain("color-wheel-png"); - }); - - it("анализатор-маска тоже ищется", () => { - expect(hits("уникальных цветов")).toContain("unique-color-mask-png"); - }); -}); diff --git a/web/src/lib/v1/i18n/smoke-i18n.test.ts b/web/src/lib/v1/i18n/smoke-i18n.test.ts deleted file mode 100644 index f1e0833..0000000 --- a/web/src/lib/v1/i18n/smoke-i18n.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { TOOLS } from "../registry"; -import { LOCALE_TAGS } from "./dict"; -import { setLocale } from "./locale.svelte"; -import { normalizeForSearch, scoreDoc } from "./matching"; -import { t } from "./t"; -import { toolSearchDoc } from "./tool-strings"; - -afterEach(() => { - setLocale("ru"); - vi.unstubAllGlobals(); -}); - -describe("Смоук §6 i18n", () => { - it("1. html lang следует за локалью", () => { - const doc = { documentElement: { lang: "" } }; - vi.stubGlobal("document", doc); - setLocale("en"); - expect(doc.documentElement.lang).toBe("en"); - setLocale("ru"); - expect(doc.documentElement.lang).toBe("ru"); - }); - - it("3. Ключевые секции переведены без смеси языков", () => { - const samples: Array<[string, string, string]> = [ - ["header.workspace", "Рабочая область", "Workspace"], - ["catalog.heading", "Каталог инструментов", "Tool catalog"], - [ - "home.heroTitle", - "Что делаем с изображением?", - "What do you want to do", - ], - ["chain.inputLegend", "Вход", "Input"], - ["resultCard.nextTool", "Следующий инструмент", "Next tool"], - ["download.busy", "Готовим файл", "Preparing file"], - ]; - for (const [key, ruPart, enPart] of samples) { - setLocale("ru"); - expect(t(key), key + " @ru").toContain(ruPart); - setLocale("en"); - expect(t(key), key + " @en").toContain(enPart); - } - }); - - it("5. Ошибки с vars локализуются на оба языка", () => { - setLocale("ru"); - expect(t("errors.badHex", { value: "#zz" })).toBe( - 'Некорректный HEX-цвет: "#zz"', - ); - expect(t("errors.toolNotFound", { id: "x" })).toContain("не найден"); - setLocale("en"); - expect(t("errors.badHex", { value: "#zz" })).toBe( - 'Invalid HEX color: "#zz"', - ); - }); - - it("6. Поиск кросс-языковой в обе стороны", () => { - const ids = (q: string) => - TOOLS.filter((tool) => { - const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q)); - return s !== null && s > 0; - }).map((tool) => tool.id); - - setLocale("ru"); - let hits = ids("rotate"); - expect(hits).toContain("rotate-png"); - hits = ids("повер"); - expect(hits).toContain("rotate-png"); - - setLocale("en"); - hits = ids("пово"); - expect(hits).toContain("rotate-free-png"); - hits = ids("rotate"); - expect(hits).toContain("rotate-png"); - }); - - it("6b. Ё не мешает совпадению", () => { - const blackWhite = TOOLS.find((tool) => tool.id === "black-and-white-png")!; - setLocale("ru"); - const hit = scoreDoc( - toolSearchDoc(blackWhite), - normalizeForSearch("ЧЁРНО"), - ); - expect(hit).not.toBeNull(); - const withoutYo = scoreDoc( - toolSearchDoc(blackWhite), - normalizeForSearch("черно"), - ); - expect(withoutYo).not.toBeNull(); - }); - - it("7. Теги локалей для форматирования чисел корректны", () => { - expect(LOCALE_TAGS.ru).toBe("ru-RU"); - expect(LOCALE_TAGS.en).toBe("en-US"); - }); -}); diff --git a/web/src/lib/v1/i18n/t.ts b/web/src/lib/v1/i18n/t.ts deleted file mode 100644 index f2d3bbb..0000000 --- a/web/src/lib/v1/i18n/t.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getMergedDict } from "./locale.svelte"; - -export function interpolate( - template: string, - vars?: Record, -): string { - if (!vars) return template; - return template.replace(/\{(\w+)\}/g, (match, name: string) => - name in vars ? String(vars[name]) : match, - ); -} - -/** - * Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'. - * Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь. - */ -export function t( - path: string, - vars?: Record, -): string { - let node: unknown = getMergedDict(); - for (const part of path.split(".")) { - if ( - node && - typeof node === "object" && - part in (node as Record) - ) { - node = (node as Record)[part]; - } else { - return path; - } - } - return typeof node === "string" ? interpolate(node, vars) : path; -} diff --git a/web/src/lib/v1/i18n/tool-strings.ts b/web/src/lib/v1/i18n/tool-strings.ts deleted file mode 100644 index ffc66ac..0000000 --- a/web/src/lib/v1/i18n/tool-strings.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ParamDef, ToolEntry } from "$lib/v1/registry"; -import { getMergedDict } from "./locale.svelte"; -import type { SearchDoc } from "./matching"; -import { ru } from "./ru"; - -/** - * Строки инструмента на активной локали. - * Русский текст живёт в самом реестре (базовая локаль), - * переводы — в секции tools словарей; отсутствие ключа даёт фолбэк на реестр. - */ -export function toolTitle(tool: ToolEntry): string { - return getMergedDict().tools[tool.id]?.title ?? tool.title; -} - -export function toolDescription(tool: ToolEntry): string { - return getMergedDict().tools[tool.id]?.description ?? tool.description; -} - -export function paramLabel(tool: ToolEntry, param: ParamDef): string { - const viaDict = getMergedDict().tools[tool.id]?.params?.[param.id]; - if (viaDict) return viaDict; - const viaDef = param as { label?: string }; - return viaDef.label ?? param.id; -} - -export function optionLabel( - tool: ToolEntry, - param: ParamDef, - value: string, -): string { - const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value]; - if (viaDict) return viaDict; - if (param.type === "select") { - return param.options.find((o) => o.value === value)?.label ?? value; - } - return value; -} - -function dedupe(values: string[]): string[] { - return [...new Set(values.filter((v) => v.length > 0))]; -} - -/** - * Поисковый документ инструмента: строки активной локали, русского и английского - * (реестр) вместе — запрос находит инструмент по любому из языков независимо - * от того, какой сейчас включён. - */ -export function toolSearchDoc(tool: ToolEntry): SearchDoc { - const active = getMergedDict().tools[tool.id]; - return { - id: tool.id, - titles: dedupe([ - active?.title ?? "", - ru.tools[tool.id]?.title ?? "", - tool.title, - ]), - descriptions: dedupe([ - active?.description ?? "", - ru.tools[tool.id]?.description ?? "", - tool.description, - ]), - }; -} diff --git a/web/src/lib/v1/i18n/tools-i18n.test.ts b/web/src/lib/v1/i18n/tools-i18n.test.ts deleted file mode 100644 index 8adb4ed..0000000 --- a/web/src/lib/v1/i18n/tools-i18n.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TOOLS } from "../registry"; -import { en } from "./en"; -import { ru } from "./ru"; - -describe("секция tools словарей покрывает реестр", () => { - it("у каждого инструмента есть перевод ru с непустыми title/description", () => { - for (const tool of TOOLS) { - const strings = ru.tools[tool.id]; - expect(strings, `нет перевода для ${tool.id}`).toBeDefined(); - expect(strings?.title, `${tool.id}: title`).toBeTruthy(); - expect(strings?.description, `${tool.id}: description`).toBeTruthy(); - } - }); - - it("переведены подписи всех параметров", () => { - for (const tool of TOOLS) { - const params = ru.tools[tool.id]?.params ?? {}; - for (const param of tool.params) { - const label = params[param.id]; - expect( - label, - `${tool.id}.${param.id}: нет перевода подписи`, - ).toBeDefined(); - expect(label?.length).toBeGreaterThan(0); - } - } - }); - - it("переведены подписи всех опций select", () => { - for (const tool of TOOLS) { - const options = ru.tools[tool.id]?.options ?? {}; - for (const param of tool.params) { - if (param.type !== "select") continue; - const labels = options[param.id] ?? {}; - for (const option of param.options) { - const label = labels[option.value]; - expect( - label, - `${tool.id}.${param.id}[${option.value}]: нет перевода`, - ).toBeDefined(); - expect(label.length).toBeGreaterThan(0); - } - } - } - }); - - it("в словарях нет лишних ключей инструментов и параметров", () => { - const ids = new Set(TOOLS.map((tool) => tool.id)); - for (const dict of [ru.tools, en.tools]) { - for (const id of Object.keys(dict)) { - expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true); - const tool = TOOLS.find((t) => t.id === id)!; - const paramIds = new Set(tool.params.map((p) => p.id)); - for (const paramId of Object.keys(dict[id].params ?? {})) { - expect( - paramIds.has(paramId), - `${id}: лишний параметр ${paramId}`, - ).toBe(true); - } - const selectIds = new Set( - tool.params.filter((p) => p.type === "select").map((p) => p.id), - ); - for (const paramId of Object.keys(dict[id].options ?? {})) { - expect( - selectIds.has(paramId), - `${id}: options у не-select параметра ${paramId}`, - ).toBe(true); - } - } - } - }); - - it("тексты-результаты анализаторов переведены в обоих словарях", () => { - const expectations: Array<[string, string[]]> = [ - ["png-is-grayscale", ["grayscaleYes", "grayscaleNo"]], - ["png-is-transparent", ["transparentYes", "transparentNo"]], - [ - "png-orientation", - ["orientationPortrait", "orientationLandscape", "orientationSquare"], - ], - ]; - for (const [id, keys] of expectations) { - for (const key of keys) { - expect(en.tools[id]?.results?.[key], `${id}.${key} (en)`).toBeDefined(); - expect(ru.tools[id]?.results?.[key], `${id}.${key} (ru)`).toBeDefined(); - } - } - }); -}); diff --git a/web/src/lib/v1/registry-helpers.ts b/web/src/lib/v1/registry-helpers.ts deleted file mode 100644 index 3236b72..0000000 --- a/web/src/lib/v1/registry-helpers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ToolError } from "../core/errors"; - -export function num(params: Record, id: string): number { - const v = params[id]; - if (typeof v !== "number" || !Number.isFinite(v)) { - throw new ToolError("errors.paramNumber", { id }); - } - return v; -} - -export function str(params: Record, id: string): string { - const v = params[id]; - if (typeof v !== "string") { - throw new ToolError("errors.paramString", { id }); - } - return v; -} - -export function bool(params: Record, id: string): boolean { - const v = params[id]; - if (typeof v !== "boolean") { - throw new ToolError("errors.paramBool", { id }); - } - return v; -} diff --git a/web/src/lib/v1/registry.test.ts b/web/src/lib/v1/registry.test.ts deleted file mode 100644 index b14f280..0000000 --- a/web/src/lib/v1/registry.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CATEGORIES } from "./categories"; -import { TOOL_ICONS } from "./tools/tool-icons"; -import { - defaultParams, - getTool, - outputOf, - sanitizeParams, - TOOLS, - type ParamDef, - type ToolEntry, -} from "./registry"; - -describe("реестр инструментов", () => { - it("id уникальны и в kebab-case", () => { - const ids = TOOLS.map((t) => t.id); - expect(new Set(ids).size).toBe(ids.length); - for (const id of ids) { - expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); - } - }); - - it.each(TOOLS.map((t) => [t.id, t] as const))( - "%s: категория валидна", - (_, tool) => { - expect(CATEGORIES).toContain(tool.category); - }, - ); - - it.each(TOOLS.map((t) => [t.id, t] as const))( - "%s: исполнители определены", - (_, tool) => { - if (tool.resultType === "text") { - expect( - typeof tool.toText === "function" || - typeof tool.textToText === "function", - ).toBe(true); - } else if (tool.sourceMode === "none") { - expect(typeof tool.generate).toBe("function"); - } else { - expect(typeof tool.run).toBe("function"); - } - if (tool.preview) { - expect(typeof tool.preview).toBe("function"); - } - expect(tool.title.length).toBeGreaterThan(0); - expect(tool.description.length).toBeGreaterThan(0); - }, - ); - - it("у select дефолт входит в options, у number/slider дефолт в диапазоне", () => { - for (const tool of TOOLS) { - for (const param of tool.params) { - if (param.type === "select") { - expect(param.options.map((o) => o.value)).toContain(param.default); - expect(param.options.length).toBeGreaterThan(0); - } - if (param.type === "number") { - expect(param.min === undefined || param.default >= param.min).toBe( - true, - ); - expect(param.max === undefined || param.default <= param.max).toBe( - true, - ); - } - if (param.type === "slider") { - expect(param.min).toBeLessThan(param.max); - expect(param.default).toBeGreaterThanOrEqual(param.min); - expect(param.default).toBeLessThanOrEqual(param.max); - } - if (param.type === "color") { - expect(param.default).toMatch(/^#[0-9a-f]{6}$/i); - } - } - } - }); - - it("id параметров уникальны внутри инструмента", () => { - for (const tool of TOOLS) { - const ids = tool.params.map((p) => p.id); - expect(new Set(ids).size).toBe(ids.length); - } - }); - - it("qualityParamId ссылается на существующий числовой параметр", () => { - for (const tool of TOOLS) { - const output = outputOf(tool); - if (output?.qualityParamId) { - const param = tool.params.find( - (p): p is Extract => - p.id === output.qualityParamId, - ); - expect(param).toBeDefined(); - } - } - }); - - it("png-info не имеет формата вывода, конвертеры имеют jpeg/webp, остальные — png", () => { - expect(outputOf(getToolOrThrow("png-info"))).toBeUndefined(); - expect(outputOf(getToolOrThrow("convert-png-to-jpg"))?.mime).toBe( - "image/jpeg", - ); - expect(outputOf(getToolOrThrow("convert-png-to-webp"))?.mime).toBe( - "image/webp", - ); - expect(outputOf(getToolOrThrow("grayscale-png"))).toEqual({ - mime: "image/png", - ext: "png", - }); - }); - - it("defaultParams собирает значения по умолчанию", () => { - const resize = getToolOrThrow("resize-png"); - expect(defaultParams(resize)).toEqual({ - width: 0, - height: 0, - keepAspect: true, - }); - }); - - it("словарь иконок покрывает все инструменты", () => { - for (const tool of TOOLS) { - expect(TOOL_ICONS[tool.id]).toBeDefined(); - } - }); - - it("у каждого инструмента популярность в диапазоне 0..100 и ключ иконки", () => { - for (const tool of TOOLS) { - expect(tool.popularity).toBeGreaterThanOrEqual(0); - expect(tool.popularity).toBeLessThanOrEqual(100); - expect(typeof tool.icon).toBe("string"); - expect(tool.icon!.length).toBeGreaterThan(0); - } - }); -}); - -describe("sanitizeParams", () => { - it("заменяет пустые и невалидные числа на дефолт", () => { - const resize = getToolOrThrow("resize-png"); - expect( - sanitizeParams(resize, { - width: undefined, - height: null, - keepAspect: true, - }), - ).toEqual({ - width: 0, - height: 0, - keepAspect: true, - }); - }); - - it("клампит числа в диапазон параметра", () => { - const bc = getToolOrThrow("adjust-brightness-contrast-png"); - expect(sanitizeParams(bc, { brightness: 5000, contrast: -999 })).toEqual({ - brightness: 100, - contrast: -100, - }); - }); - - it("возвращает дефолт для невалидных select, checkbox и color", () => { - const rotate = getToolOrThrow("rotate-png"); - expect(sanitizeParams(rotate, { angle: "45" })).toEqual({ angle: "90" }); - const resize = getToolOrThrow("resize-png"); - expect( - sanitizeParams(resize, { width: 10, height: 10, keepAspect: "yes" }), - ).toEqual({ - width: 10, - height: 10, - keepAspect: true, - }); - const jpg = getToolOrThrow("convert-png-to-jpg"); - expect(sanitizeParams(jpg, { background: "red", quality: 50 })).toEqual({ - background: "#ffffff", - quality: 50, - }); - }); -}); - -describe("run инструмента resize-png", () => { - const img = { - width: 100, - height: 50, - data: new Uint8ClampedArray(100 * 50 * 4), - }; - const runResize = async (params: Record) => - getToolOrThrow("resize-png").run!(img, params); - - it("keepAspect + одна сторона — вторая считается по пропорции", async () => { - const out = await runResize({ width: 200, height: 0, keepAspect: true }); - expect(out.width).toBe(200); - expect(out.height).toBe(100); - }); - - it("keepAspect + обе стороны — вписывание в размеры без искажения", async () => { - const out = await runResize({ width: 50, height: 50, keepAspect: true }); - expect(out.width).toBe(50); - expect(out.height).toBe(25); - }); - - it("без keepAspect — обе стороны как задано", async () => { - const out = await runResize({ width: 30, height: 40, keepAspect: false }); - expect(out.width).toBe(30); - expect(out.height).toBe(40); - }); - - it("обе стороны 0 — человекочитаемая ошибка", async () => { - await expect( - runResize({ width: 0, height: 0, keepAspect: true }), - ).rejects.toThrow("errors.resizeSize"); - }); -}); - -function getToolOrThrow(id: string): ToolEntry { - const tool = getTool(id); - if (!tool) throw new Error(`Инструмент "${id}" не найден`); - return tool; -} diff --git a/web/src/lib/v1/registry.ts b/web/src/lib/v1/registry.ts deleted file mode 100644 index b6de5cc..0000000 --- a/web/src/lib/v1/registry.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { CategoryId } from "./categories"; -import type { OutputMime } from "../core/io"; -import type { PixelImage } from "../core/types"; - -import { alphaEntries } from "./registry/alpha"; -import { analyzeEntries } from "./registry/analyze"; -import { colorEntries } from "./registry/color"; -import { convertEntries } from "./registry/convert"; -import { filterEntries } from "./registry/filters"; -import { generateEntries } from "./registry/generate"; -import { geometryEntries } from "./registry/geometry"; -import { textEntries } from "./registry/text"; - -// TODO: remove convert tools - move to load image \ download button flow - -export type ParamDef = - | { - id: string; - label: string; - type: "number"; - min?: number; - max?: number; - step?: number; - default: number; - } - | { - id: string; - label: string; - type: "slider"; - min: number; - max: number; - step?: number; - default: number; - } - | { - id: string; - label: string; - type: "select"; - options: { value: string; label: string }[]; - default: string; - } - | { id: string; label: string; type: "checkbox"; default: boolean } - | { id: string; label: string; type: "color"; default: string } - | { - id: string; - label: string; - type: "text"; - default: string; - placeholder?: string; - }; - -export type OutputFormat = { - mime: OutputMime; - ext: string; - qualityParamId?: string; -}; - -export type SourceMode = "file" | "none" | "text"; - -export type ToolEntry

> = { - id: string; - title: string; - description: string; - category: CategoryId; - sourceMode?: SourceMode; - params: ParamDef[]; - run?: (img: PixelImage, params: P) => Promise | PixelImage; - generate?: (params: P) => Promise | PixelImage; - toText?: (img: PixelImage, params: P) => Promise | string; - runFromText?: (text: string, params: P) => Promise | PixelImage; - textToText?: (text: string) => Promise | string; - preview?: (img: PixelImage, params: P) => Promise | PixelImage; - popularity?: number; - icon?: string; - resultType?: "image" | "info" | "text"; - output?: OutputFormat; - domOnly?: boolean; - needsOverlaySource?: boolean; -}; - -export const PNG_OUTPUT: OutputFormat = { mime: "image/png", ext: "png" }; - -export function isChainable(tool: ToolEntry): boolean { - return ( - (tool.resultType ?? "image") === "image" && - (!tool.sourceMode || tool.sourceMode === "file") - ); -} - -const _convert = convertEntries(); -const _geometry = geometryEntries(); -const _alpha = alphaEntries(); -const _color = colorEntries(); -const _analyze = analyzeEntries(); -const _generate = generateEntries(); -const _text = textEntries(); -const _filters = filterEntries(); - -export const TOOLS: ToolEntry[] = [ - ..._color.slice(0, 6), - ..._analyze.slice(0, 6), - ..._convert.slice(0, 16), - ..._geometry.slice(0, 8), - ..._alpha.slice(0, 4), - ..._geometry.slice(8, 9), - ..._filters.slice(0, 2), - ..._color.slice(6, 17), - ..._convert.slice(16, 18), - ..._alpha.slice(4, 20), - ..._analyze.slice(6, 9), - ..._generate, - ..._text, - ..._alpha.slice(20, 21), - ..._analyze.slice(9, 10), - ..._geometry.slice(9, 18), - ..._filters.slice(2, 8), - ..._color.slice(17, 25), - ..._convert.slice(18, 20), - ..._analyze.slice(10, 11), - ..._convert.slice(20, 21), - ..._analyze.slice(11, 13), -]; - -import { TOOL_POPULARITY } from "./tools/tool-popularity"; - -for (const entry of TOOLS) { - entry.icon = entry.id; - entry.popularity = TOOL_POPULARITY[entry.id] ?? 50; -} - -export function getTool(id: string): ToolEntry | undefined { - return TOOLS.find((tool) => tool.id === id); -} - -export function defaultParams(tool: ToolEntry): Record { - return Object.fromEntries(tool.params.map((p) => [p.id, p.default])); -} - -export function sanitizeParams( - tool: ToolEntry, - values: Record, -): Record { - const out: Record = {}; - for (const param of tool.params) { - const raw = values[param.id]; - switch (param.type) { - case "number": - case "slider": { - const n = - typeof raw === "number" && Number.isFinite(raw) ? raw : param.default; - out[param.id] = clampRange(n, param.min, param.max); - break; - } - case "select": - out[param.id] = - typeof raw === "string" && param.options.some((o) => o.value === raw) - ? raw - : param.default; - break; - case "checkbox": - out[param.id] = typeof raw === "boolean" ? raw : param.default; - break; - case "color": - out[param.id] = - typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw) - ? raw - : param.default; - break; - case "text": - out[param.id] = typeof raw === "string" ? raw : param.default; - break; - } - } - return out; -} - -function clampRange(value: number, min?: number, max?: number): number { - if (min !== undefined && value < min) return min; - if (max !== undefined && value > max) return max; - return value; -} - -export function outputOf(tool: ToolEntry): OutputFormat | undefined { - if (tool.resultType === "info") return undefined; - return tool.output ?? PNG_OUTPUT; -} diff --git a/web/src/lib/v1/registry/alpha.ts b/web/src/lib/v1/registry/alpha.ts deleted file mode 100644 index c5faadd..0000000 --- a/web/src/lib/v1/registry/alpha.ts +++ /dev/null @@ -1,674 +0,0 @@ -import type { ToolEntry } from "../registry"; -import { ToolError } from "../../core/errors"; -import { - colorMask, - extractAlphaMask, - flattenOntoColor, - hardenAlpha, - invertAlpha, - removeColorToAlpha, - roundCorners, - setAlphaChannel, -} from "../../core/alpha"; -import { backgroundMaskPreview, removeBackground } from "../../core/background"; -import { - closingImage, - contourImage, - dilateImage, - erodeImage, - openingImage, - strokeImage, -} from "../../core/morphology"; -import { featherAlpha, defringe } from "../../core/pixel-fx"; -import { drawImageWatermark } from "../../core/domText"; -import { getOverlay } from "../tools/overlay-store.svelte"; -import { - renderShape, - circleTest, - boxTest, - starTest, - wavyTest, -} from "../../core/shapes"; -import type { Position9 } from "../../core/textdraw"; -import { num, str } from "../registry-helpers"; - -export function alphaEntries(): ToolEntry[] { - return [ - { - id: "circle-mask-png", - title: "Circle Mask PNG", - description: - "Cuts the image into a circle. Diameter is set as a share of the smaller side.", - category: "alpha", - params: [ - { - id: "size", - label: "Diameter, % of smaller side", - type: "slider", - min: 20, - max: 100, - step: 1, - default: 100, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - circleTest(num(p, "size") / 200), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "square-mask-png", - title: "Square Mask PNG", - description: - "Cuts the image into a rectangle with sides as a share of the smaller side.", - category: "alpha", - params: [ - { - id: "widthPct", - label: "Width, % of smaller side", - type: "slider", - min: 10, - max: 100, - step: 1, - default: 100, - }, - { - id: "heightPct", - label: "Height, % of smaller side", - type: "slider", - min: 10, - max: 100, - step: 1, - default: 100, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - boxTest(num(p, "widthPct") / 200, num(p, "heightPct") / 200), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "star-mask-png", - title: "Star Mask PNG", - description: - "Cuts the image into an n-pointed star with adjustable inner radius and rotation.", - category: "alpha", - params: [ - { - id: "points", - label: "Points", - type: "slider", - min: 3, - max: 12, - step: 1, - default: 5, - }, - { - id: "innerRadius", - label: "Inner radius, %", - type: "slider", - min: 10, - max: 90, - step: 1, - default: 45, - }, - { - id: "size", - label: "Outer radius, % of smaller side", - type: "slider", - min: 20, - max: 100, - step: 1, - default: 100, - }, - { - id: "rotation", - label: "Rotation, °", - type: "slider", - min: -180, - max: 180, - step: 1, - default: 0, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - starTest( - num(p, "points"), - num(p, "innerRadius") / 100, - num(p, "size") / 200, - num(p, "rotation"), - ), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "wavy-mask-png", - title: "Wavy Mask PNG", - description: - "Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.", - category: "alpha", - params: [ - { - id: "size", - label: "Base radius, % of smaller side", - type: "slider", - min: 20, - max: 100, - step: 1, - default: 90, - }, - { - id: "amplitude", - label: "Wave amplitude, %", - type: "slider", - min: 2, - max: 30, - step: 1, - default: 8, - }, - { - id: "waves", - label: "Waves count", - type: "slider", - min: 3, - max: 24, - step: 1, - default: 8, - }, - { - id: "phase", - label: "Phase, °", - type: "slider", - min: 0, - max: 360, - step: 1, - default: 0, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - wavyTest( - num(p, "size") / 200, - num(p, "amplitude") / 200, - num(p, "waves"), - num(p, "phase"), - ), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "remove-alpha-channel-png", - title: "Remove alpha channel PNG", - description: - "Composites the image over a white background and saves without transparency.", - category: "alpha", - params: [], - run: (img) => flattenOntoColor(img, "#ffffff"), - }, - { - id: "set-alpha-channel-png", - title: "Set alpha channel PNG", - description: - "Assigns the same opacity to all pixels; colors stay unchanged.", - category: "alpha", - params: [ - { - id: "percent", - label: "Opacity, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 100, - }, - ], - run: (img, p) => setAlphaChannel(img, num(p, "percent")), - }, - { - id: "extract-alpha-mask-png", - title: "Extract alpha mask PNG", - description: "Turns transparency into a black-and-white opaque mask.", - category: "alpha", - params: [], - run: (img) => extractAlphaMask(img), - }, - { - id: "round-corners-png", - title: "Round corners PNG", - description: - "Clips corners by a radius set as a percentage of half the smaller side.", - category: "alpha", - params: [ - { - id: "radius", - label: "Corner radius, %", - type: "slider", - min: 0, - max: 50, - step: 1, - default: 10, - }, - ], - run: (img, p) => roundCorners(img, num(p, "radius")), - }, - { - id: "invert-alpha-png", - title: "Invert alpha PNG", - description: "Opaque areas become transparent and vice versa.", - category: "alpha", - params: [], - run: (img) => invertAlpha(img), - }, - { - id: "remove-background-png", - title: "Remove background PNG (smart)", - description: - "Removes a solid background: by color with tolerance, outer regions from the edges only, or every matching pixel. Can smooth the boundary.", - category: "alpha", - params: [ - { - id: "color", - label: "Background color", - type: "color", - default: "#ffffff", - }, - { - id: "tolerance", - label: "Similarity tolerance, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 10, - }, - { - id: "outerOnly", - label: "Outer regions only", - type: "checkbox", - default: true, - }, - { - id: "smooth", - label: "Edge smoothing, passes", - type: "slider", - min: 0, - max: 8, - step: 1, - default: 1, - }, - ], - run: (img, p) => - removeBackground(img, { - color: str(p, "color"), - tolerancePercent: num(p, "tolerance"), - outerOnly: p["outerOnly"] === true, - smoothPasses: num(p, "smooth"), - }), - preview: (img, p) => - backgroundMaskPreview(img, { - color: str(p, "color"), - tolerancePercent: num(p, "tolerance"), - outerOnly: p["outerOnly"] === true, - smoothPasses: num(p, "smooth"), - }), - }, - { - id: "add-stroke-png", - title: "Outline PNG", - description: - "Adds a colored ring outline around the opaque content with the chosen thickness.", - category: "alpha", - params: [ - { - id: "color", - label: "Outline color", - type: "color", - default: "#ff0000", - }, - { - id: "thickness", - label: "Thickness, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 3, - }, - ], - run: (img, p) => strokeImage(img, num(p, "thickness"), str(p, "color")), - }, - { - 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", - params: [ - { - id: "color", - label: "Line color", - type: "color", - default: "#000000", - }, - { - id: "thickness", - label: "Line thickness, px", - type: "slider", - min: 1, - max: 5, - step: 1, - default: 1, - }, - ], - run: (img, p) => contourImage(img, num(p, "thickness"), str(p, "color")), - }, - { - id: "make-thicker-png", - title: "Thicken PNG", - description: "Expands opaque areas by the given number of pixels.", - category: "alpha", - params: [ - { - id: "radius", - label: "Amount, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 2, - }, - ], - run: (img, p) => dilateImage(img, num(p, "radius")), - }, - { - id: "make-thinner-png", - title: "Thin PNG", - description: - "Shrinks opaque areas — thins the strokes of text and details.", - category: "alpha", - params: [ - { - id: "radius", - label: "Amount, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 1, - }, - ], - run: (img, p) => erodeImage(img, num(p, "radius")), - }, - { - id: "feather-edges-png", - title: "Feather Edges PNG", - description: - "Blurs only the alpha channel: hard cutout edges become soft and gradual, colors stay untouched.", - category: "alpha", - params: [ - { - id: "radius", - label: "Feather radius, px", - type: "slider", - min: 1, - max: 20, - step: 1, - default: 3, - }, - ], - run: (img, p) => featherAlpha(img, num(p, "radius")), - }, - { - id: "clean-edges-png", - title: "Clean Edges PNG (defringe)", - description: - "Replaces edge-halo colors of semi-transparent pixels with the nearest fully opaque color. Alpha stays as is.", - category: "alpha", - params: [ - { - id: "radius", - label: "Search radius, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 3, - }, - ], - run: (img, p) => defringe(img, num(p, "radius")), - }, - { - id: "harden-alpha-png", - title: "Harden edges PNG", - description: - "Binarizes the alpha channel by threshold: semi-transparent pixels become either fully transparent or fully opaque.", - category: "alpha", - params: [ - { - id: "threshold", - label: "Alpha threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => hardenAlpha(img, num(p, "threshold")), - }, - { - id: "despeckle-alpha-png", - title: "Despeckle PNG", - description: - "Opening: removes lone semi-transparent pixels and small specks.", - category: "alpha", - params: [ - { - id: "radius", - label: "Cleanup radius, px", - type: "slider", - min: 1, - max: 3, - step: 1, - default: 1, - }, - ], - run: (img, p) => openingImage(img, num(p, "radius")), - }, - { - id: "close-holes-png", - title: "Close holes PNG", - description: "Closing: fills lone transparent dots inside the object.", - category: "alpha", - params: [ - { - id: "radius", - label: "Closing radius, px", - type: "slider", - min: 1, - max: 3, - step: 1, - default: 1, - }, - ], - run: (img, p) => closingImage(img, num(p, "radius")), - }, - { - 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", - params: [ - { - id: "targetColor", - label: "Color to remove", - type: "color", - default: "#00ff00", - }, - { - id: "tolerance", - label: "Similarity threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 10, - }, - ], - run: (img, p) => - removeColorToAlpha(img, str(p, "targetColor"), num(p, "tolerance")), - preview: (img, p) => - colorMask(img, str(p, "targetColor"), num(p, "tolerance")), - }, - { - id: "watermark-image-png", - title: "Watermark Image PNG", - description: - "Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.", - category: "alpha", - domOnly: true, - needsOverlaySource: true, - params: [ - { - id: "scale", - label: "Mark width, % of canvas", - type: "slider", - min: 5, - max: 100, - step: 1, - default: 30, - }, - { - id: "opacity", - label: "Opacity, %", - type: "slider", - min: 5, - max: 100, - step: 5, - default: 60, - }, - { - id: "position", - label: "Position", - type: "select", - default: "bottom-right", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - { - id: "margin", - label: "Margin, px", - type: "slider", - min: 0, - max: 200, - step: 1, - default: 24, - }, - ], - run: (img, p) => { - const mark = getOverlay(); - if (!mark) throw new ToolError("errors.noWatermark"); - return drawImageWatermark(img, { - mark, - scalePercent: num(p, "scale"), - opacityPercent: num(p, "opacity"), - position: str(p, "position") as Position9, - margin: num(p, "margin"), - }); - }, - }, - ]; -} diff --git a/web/src/lib/v1/registry/analyze.ts b/web/src/lib/v1/registry/analyze.ts deleted file mode 100644 index 019d677..0000000 --- a/web/src/lib/v1/registry/analyze.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { ToolEntry } from "../registry"; -import { - extractByColor, - isGrayscaleish, - luma01, - rarityPredicate, - renderPredicateMask, -} from "../../core/masks"; -import { - hasTransparency, - isGrayscale, - orientationOf, -} from "../../core/analyze"; -import { looksLikePng, base64ToBytes, stripDataUri } from "../../core/textio"; -import { clonePixelImage, type PixelImage } from "../../core/types"; -import { encode } from "../../core/io"; -import { t } from "../i18n/t"; -import { num, str } from "../registry-helpers"; - -type MaskToolSpec = { - id: string; - title: string; - description: string; - defaultMode: "binary" | "highlight"; - predicate: ( - img: PixelImage, - p: Record, - ) => (r: number, g: number, b: number, a: number) => boolean; - extraParams?: ToolEntry["params"]; -}; - -const MASK_TOOLS: MaskToolSpec[] = [ - { - id: "show-transparent-png", - title: "Show Transparent Areas PNG", - description: - "Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.", - defaultMode: "highlight", - predicate: (_img, _p) => (_r, _g, _b, a) => a < 255, - }, - { - id: "show-grayscale-pixels-png", - title: "Show Grayscale Pixels PNG", - description: - "Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - isGrayscaleish(r, g, b, num(p, "tolerance")), - extraParams: [ - { - id: "tolerance", - label: "Channel tolerance", - type: "slider", - min: 0, - max: 64, - step: 1, - default: 0, - }, - ], - }, - { - id: "show-color-pixels-png", - title: "Show Color Pixels PNG", - description: - "Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - !isGrayscaleish(r, g, b, num(p, "tolerance")), - extraParams: [ - { - id: "tolerance", - label: "Channel tolerance", - type: "slider", - min: 0, - max: 64, - step: 1, - default: 8, - }, - ], - }, - { - id: "light-pixel-mask-png", - title: "Light Pixel Mask PNG", - description: "Selects pixels brighter than the luminance threshold.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - luma01(r, g, b) >= num(p, "threshold") / 100, - extraParams: [ - { - id: "threshold", - label: "Luminance threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 70, - }, - ], - }, - { - id: "dark-pixel-mask-png", - title: "Dark Pixel Mask PNG", - description: "Selects pixels darker than the luminance threshold.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - luma01(r, g, b) <= num(p, "threshold") / 100, - extraParams: [ - { - id: "threshold", - label: "Luminance threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 30, - }, - ], - }, - { - id: "unique-color-mask-png", - title: "Unique Color Mask PNG", - description: - "Selects colors that occur no more than the given number of times — rare and one-off pixels.", - defaultMode: "binary", - predicate: (img, p) => rarityPredicate(img, num(p, "rarity")), - extraParams: [ - { - id: "rarity", - label: "Max occurrences", - type: "slider", - min: 1, - max: 50, - step: 1, - default: 1, - }, - ], - }, -]; - -function maskEntries(): ToolEntry[] { - return MASK_TOOLS.map((spec) => ({ - id: spec.id, - title: spec.title, - description: spec.description, - category: "analyze" as const, - params: [ - ...(spec.extraParams ?? []), - { - id: "mode", - label: "Mask mode", - type: "select" as const, - default: spec.defaultMode, - options: [ - { value: "binary", label: "Black & white mask" }, - { value: "highlight", label: "Color highlight" }, - ], - }, - { - id: "color", - label: "Highlight color", - type: "color", - default: "#ff00aa", - }, - { - id: "opacity", - label: "Highlight opacity, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 70, - }, - ], - run: (img, p) => - renderPredicateMask(img, spec.predicate(img, p), { - mode: str(p, "mode") === "highlight" ? "highlight" : "binary", - color: str(p, "color"), - opacityPercent: num(p, "opacity"), - }), - })); -} - -export function analyzeEntries(): ToolEntry[] { - return [ - ...maskEntries(), - { - 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", - params: [ - { - id: "color", - label: "Color to keep", - type: "color", - default: "#00ff88", - }, - { - id: "tolerance", - label: "Similarity tolerance, %", - type: "slider", - min: 0, - max: 50, - step: 1, - default: 10, - }, - ], - run: (img, p) => - extractByColor(img, str(p, "color"), num(p, "tolerance")), - }, - { - id: "verify-is-png", - title: "Verify If Image Is a PNG", - description: - "Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.", - category: "analyze", - sourceMode: "text", - params: [], - resultType: "text", - textToText: (text) => - looksLikePng(base64ToBytes(stripDataUri(text))) - ? "verifyYes" - : "verifyNo", - }, - { - id: "png-info", - title: "PNG info", - description: - "Shows dimensions, alpha presence and the number of unique colors of the uploaded image.", - category: "analyze", - params: [], - resultType: "info", - run: (img) => clonePixelImage(img), - }, - { - id: "png-is-grayscale", - title: "Check: is PNG grayscale?", - description: "Reports whether the image consists only of shades of gray.", - category: "analyze", - params: [], - resultType: "text", - toText: (img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo"), - }, - { - id: "png-file-size", - title: "PNG File Size", - description: - "Encodes the image as PNG and reports the resulting file size.", - category: "analyze", - domOnly: true, - params: [], - resultType: "text", - toText: async (img) => { - const blob = await encode(img, "image/png"); - const kb = blob.size / 1024; - const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1); - return t("tools.png-file-size.results.line", { - kb: kbText, - }); - }, - }, - { - id: "png-is-transparent", - title: "Check: is PNG transparent?", - description: - "Reports whether the image contains transparent or semi-transparent pixels.", - category: "analyze", - params: [], - resultType: "text", - toText: (img) => - hasTransparency(img) ? "transparentYes" : "transparentNo", - }, - { - id: "png-orientation", - title: "PNG orientation", - description: "Reports whether it is portrait, landscape or square.", - category: "analyze", - params: [], - resultType: "text", - toText: (img) => { - switch (orientationOf(img)) { - case "portrait": - return "orientationPortrait"; - case "landscape": - return "orientationLandscape"; - default: - return "orientationSquare"; - } - }, - }, - ]; -} diff --git a/web/src/lib/v1/registry/color.ts b/web/src/lib/v1/registry/color.ts deleted file mode 100644 index 32a6e6e..0000000 --- a/web/src/lib/v1/registry/color.ts +++ /dev/null @@ -1,501 +0,0 @@ -import type { ToolEntry } from "../registry"; -import { - autoContrast, - brightnessContrast, - changeHue, - extractChannel, - gammaCorrection, - grayscale, - invert, - posterize, - sepia, - setOpacity, - swapChannels, - temperature, - tint, - thresholdBlackWhite, - twoColors, - type ChannelSwapPair, - type RgbChannel, -} from "../../core/color"; -import { renderSpace, SPACES, type SpaceId } from "../../core/channels"; -import { quantizeImage, ditherImage, mapToNearest } from "../../core/quantize"; -import { parseHexList } from "../../core/palette"; -import { num, str } from "../registry-helpers"; - -type SpaceEntry = { - id: SpaceId; - suffix: string; - title: string; - description: string; -}; - -const CHANNEL_SPACES: SpaceEntry[] = [ - { - id: "hsl", - suffix: "hsl", - title: "Split PNG into HSL", - description: - "Decomposes the image into Hue, Saturation and Lightness components.", - }, - { - id: "hsv", - suffix: "hsv", - title: "Split PNG into HSV", - description: - "Decomposes the image into Hue, Saturation and Value (brightness) components.", - }, - { - id: "hsi", - suffix: "hsi", - title: "Split PNG into HSI", - description: - "Decomposes the image into Hue, Saturation and Intensity components.", - }, - { - id: "cmyk", - suffix: "cmyk", - title: "Convert PNG to CMYK Colors", - description: - "Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.", - }, - { - id: "ycbcr", - suffix: "ycbcr", - title: "Convert PNG to YCbCr Colors", - description: - "Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.", - }, - { - id: "lab", - suffix: "lab", - title: "Convert PNG to LAB Colors", - description: - "Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.", - }, -]; - -function channelEntries(): ToolEntry[] { - return CHANNEL_SPACES.map((space) => { - const components = SPACES[space.id].components; - return { - id: `png-to-${space.suffix}`, - title: space.title, - description: space.description, - category: "color" as const, - params: [ - { - id: "component", - label: "Component", - type: "select" as const, - default: components[0], - options: components.map((c) => ({ - value: c, - label: c.toUpperCase(), - })), - }, - { - id: "display", - label: "Display mode", - type: "select" as const, - default: "gray", - options: [ - { value: "gray", label: "Grayscale" }, - { value: "color", label: "Space as RGB" }, - ], - }, - ], - run: (img, p) => - renderSpace( - img, - space.id, - str(p, "component"), - str(p, "display") === "color" ? "color" : "gray", - ), - } satisfies ToolEntry; - }); -} - -export function colorEntries(): ToolEntry[] { - return [ - ...channelEntries(), - { - id: "grayscale-png", - title: "Grayscale PNG", - description: - "Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.", - category: "color", - params: [], - run: (img) => grayscale(img), - }, - { - id: "invert-colors-png", - title: "Invert colors PNG", - description: - "Inverts each color channel (255 − value). Alpha is unchanged.", - category: "color", - params: [], - run: (img) => invert(img), - }, - { - id: "adjust-brightness-contrast-png", - title: "Brightness & contrast PNG", - description: - "Adjusts brightness and contrast in the range from −100 to +100. Zero means no change.", - category: "color", - params: [ - { - id: "brightness", - label: "Brightness", - type: "slider", - min: -100, - max: 100, - step: 1, - default: 0, - }, - { - id: "contrast", - label: "Contrast", - type: "slider", - min: -100, - max: 100, - step: 1, - default: 0, - }, - ], - run: (img, p) => - brightnessContrast(img, num(p, "brightness"), num(p, "contrast")), - }, - { - id: "change-png-opacity", - title: "Change PNG opacity", - description: - "Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.", - category: "color", - params: [ - { - id: "percent", - label: "Opacity, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 100, - }, - ], - run: (img, p) => setOpacity(img, num(p, "percent")), - }, - { - id: "sepia-png", - title: "Sepia effect", - description: - "Tints the image into the warm brown tones of classic sepia.", - category: "color", - params: [], - run: (img) => sepia(img), - }, - { - id: "change-png-hue", - title: "Change hue PNG", - description: - "Shifts the hue around the circle. Saturation and lightness are preserved.", - category: "color", - params: [ - { - id: "degrees", - label: "Hue shift, °", - type: "slider", - min: -180, - max: 180, - step: 1, - default: 0, - }, - ], - run: (img, p) => changeHue(img, num(p, "degrees")), - }, - { - id: "extract-channel-png", - title: "Extract channel PNG", - description: - "Keeps only the chosen channel — red, green or blue — as shades of gray.", - category: "color", - params: [ - { - id: "channel", - label: "Channel", - type: "select", - default: "red", - options: [ - { value: "red", label: "Red" }, - { value: "green", label: "Green" }, - { value: "blue", label: "Blue" }, - ], - }, - ], - run: (img, p) => extractChannel(img, str(p, "channel") as RgbChannel), - }, - { - id: "swap-channels-png", - title: "Swap channels PNG", - description: - "Swaps two color channels — a quick way to get unusual coloring.", - category: "color", - params: [ - { - id: "pair", - label: "Channel pair", - type: "select", - default: "r-g", - options: [ - { value: "r-g", label: "Red ↔ Green" }, - { value: "r-b", label: "Red ↔ Blue" }, - { value: "g-b", label: "Green ↔ Blue" }, - ], - }, - ], - run: (img, p) => swapChannels(img, str(p, "pair") as ChannelSwapPair), - }, - { - id: "black-and-white-png", - title: "Black & white threshold PNG", - description: - "Hard binarization by luminance: every pixel becomes black or white.", - category: "color", - params: [ - { - id: "threshold", - label: "Brightness threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => thresholdBlackWhite(img, num(p, "threshold")), - }, - { - id: "posterize-png", - title: "Posterize PNG", - description: - "Reduces the number of levels per channel — a poster effect.", - category: "color", - params: [ - { - id: "levels", - label: "Levels per channel", - type: "slider", - min: 2, - max: 16, - step: 1, - default: 4, - }, - ], - run: (img, p) => posterize(img, num(p, "levels")), - }, - { - id: "two-colors-png", - title: "Two colors PNG", - description: - "Recolors the image into two chosen colors by luminance threshold.", - category: "color", - params: [ - { - id: "lightColor", - label: "Light areas color", - type: "color", - default: "#ffffff", - }, - { - id: "darkColor", - label: "Dark areas color", - type: "color", - default: "#000000", - }, - { - id: "threshold", - label: "Brightness threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => - twoColors( - img, - str(p, "lightColor"), - str(p, "darkColor"), - num(p, "threshold"), - ), - }, - { - id: "gamma-png", - title: "Gamma correction PNG", - description: - "Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.", - category: "color", - params: [ - { - id: "value", - label: "Gamma", - type: "slider", - min: 0.1, - max: 3, - step: 0.05, - default: 1, - }, - ], - run: (img, p) => gammaCorrection(img, num(p, "value")), - }, - { - id: "auto-contrast-png", - title: "Auto contrast PNG", - description: - "Stretches each channel's range across the full available brightness range.", - category: "color", - params: [], - run: (img) => autoContrast(img), - }, - { - id: "temperature-png", - title: "Temperature PNG", - description: - "Positive values make the image warmer (more orange), negative ones cooler (more blue).", - category: "color", - params: [ - { - id: "percent", - label: "Temperature", - type: "slider", - min: -100, - max: 100, - step: 1, - default: 0, - }, - ], - run: (img, p) => temperature(img, num(p, "percent")), - }, - { - id: "tint-png", - title: "Tint PNG", - description: - "Multiplies color channels by the chosen tint with the given strength.", - category: "color", - params: [ - { - id: "color", - label: "Tint color", - type: "color", - default: "#ffb060", - }, - { - id: "strength", - label: "Strength, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 30, - }, - ], - run: (img, p) => tint(img, str(p, "color"), num(p, "strength")), - }, - { - id: "quantize-png", - title: "Quantize PNG", - description: - "Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.", - category: "color", - params: [ - { - id: "colors", - label: "Colors (k)", - type: "slider", - min: 2, - max: 64, - step: 1, - default: 16, - }, - ], - run: (img, p) => quantizeImage(img, num(p, "colors")).image, - }, - { - id: "decrease-color-count-png", - title: "Decrease Color Count PNG", - description: - "Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.", - category: "color", - params: [ - { - id: "maxColors", - label: "Max colors", - type: "select", - default: "16", - options: [ - { value: "2", label: "2" }, - { value: "4", label: "4" }, - { value: "8", label: "8" }, - { value: "16", label: "16" }, - { value: "32", label: "32" }, - { value: "64", label: "64" }, - { value: "128", label: "128" }, - { value: "256", label: "256" }, - ], - }, - ], - run: (img, p) => quantizeImage(img, num(p, "maxColors")).image, - }, - { - id: "custom-palette-png", - title: "Custom Palette PNG", - description: - "Maps every pixel to the nearest color from your comma-separated hex list.", - category: "color", - params: [ - { - id: "colors", - label: "Palette (comma-separated hex)", - type: "text", - default: "#000000,#ffffff", - }, - ], - run: (img, p) => mapToNearest(img, parseHexList(str(p, "colors"))), - }, - { - id: "dithering-png", - title: "Dithering PNG", - description: - "Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.", - category: "color", - params: [ - { - id: "colors", - label: "Colors (k)", - type: "slider", - min: 2, - max: 16, - step: 1, - default: 4, - }, - { - id: "pattern", - label: "Pattern", - type: "select", - default: "floyd-steinberg", - options: [ - { value: "floyd-steinberg", label: "Floyd–Steinberg" }, - { value: "bayer", label: "Bayer 4×4" }, - ], - }, - ], - run: (img, p) => - ditherImage( - img, - num(p, "colors"), - str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg", - ), - }, - ]; -} diff --git a/web/src/lib/v1/registry/convert.ts b/web/src/lib/v1/registry/convert.ts deleted file mode 100644 index 736771e..0000000 --- a/web/src/lib/v1/registry/convert.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { ToolEntry } from "../registry"; -import { clonePixelImage } from "../../core/types"; -import { flattenOntoColor } from "../../core/alpha"; -import { toBase64, toDataUrl, decodeSvgText, encode } from "../../core/io"; -import { pixelsToHex, hexToPixels } from "../../core/text"; -import { - imageToByteRows, - bytesToImage, - imageToRgbValues, - rgbValuesToImage, -} from "../../core/textio"; -import { - COMPRESSION_LEVELS, - findMaxColorsWithin, - type CompressionLevel, -} from "../../core/compress"; -import { quantizeImage } from "../../core/quantize"; -import { num, str } from "../registry-helpers"; - -function decodeToPng( - id: string, - title: string, - description: string, -): ToolEntry { - return { - id, - title, - description, - category: "convert", - params: [], - run: (img) => clonePixelImage(img), - }; -} - -export function convertEntries(): ToolEntry[] { - return [ - decodeToPng( - "jpg-to-png", - "Convert JPG to PNG", - "Opens a JPEG and saves it as lossless PNG. Transparency, if present, is preserved.", - ), - decodeToPng( - "webp-to-png", - "Convert WebP to PNG", - "Re-encodes a WebP image into universal PNG.", - ), - decodeToPng( - "gif-to-png", - "Convert GIF to PNG", - "Extracts the first frame of a GIF animation and saves it as PNG.", - ), - decodeToPng( - "bmp-to-png", - "Convert BMP to PNG", - "Re-encodes BMP into compact lossless PNG.", - ), - decodeToPng( - "ico-to-png", - "Convert ICO to PNG", - "Turns an .ico icon into a regular PNG of the chosen size.", - ), - { - id: "png-to-bmp", - title: "Convert PNG to BMP", - description: - "Saves the image as 24-bit BMP without an alpha channel: transparency is replaced with a black background.", - category: "convert", - params: [], - run: (img) => flattenOntoColor(img, "#000000"), - output: { mime: "image/bmp", ext: "bmp" }, - }, - { - id: "png-to-base64", - title: "PNG to Base64", - description: - "Encodes the image into a base64 string for embedding in code or styles.", - category: "convert", - params: [], - resultType: "text", - toText: (img) => toBase64(img), - }, - { - id: "base64-to-png", - title: "Base64 to PNG", - description: - "Decodes a base64 string or data-uri back into an image. Paste the string on the left.", - category: "convert", - sourceMode: "text", - params: [], - run: (img) => clonePixelImage(img), - }, - { - id: "png-to-data-uri", - title: "PNG to Data URI", - description: - "Builds a full data-uri (data:image/png;base64,…) for embedding in HTML/CSS.", - category: "convert", - params: [], - resultType: "text", - toText: (img) => toDataUrl(img), - }, - { - id: "data-uri-to-png", - title: "Data URI to PNG", - description: "Decodes data:image/…;base64,… back into an image file.", - category: "convert", - sourceMode: "text", - params: [], - run: (img) => clonePixelImage(img), - }, - { - id: "png-to-hex", - title: "PNG to HEX pixels", - description: - "Shows all pixels as rrggbbaa hex values — row by row, space separated.", - category: "convert", - params: [], - resultType: "text", - toText: (img) => pixelsToHex(img), - }, - { - id: "hex-to-png", - title: "HEX pixels to PNG", - description: - "Assembles an image from rrggbbaa hex values (space separated). Set the width — the height is computed automatically.", - category: "convert", - sourceMode: "text", - params: [ - { - id: "width", - label: "Image width", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 1, - }, - ], - runFromText: (text, p) => - hexToPixels(text, Math.trunc(Number(p["width"]))), - run: (img) => clonePixelImage(img), - }, - { - id: "png-to-bytes", - title: "PNG to Bytes", - description: - "Lists every pixel as four decimal bytes (R G B A), one image row per line.", - category: "convert", - params: [], - resultType: "text", - toText: (img) => imageToByteRows(img), - }, - { - id: "bytes-to-png", - title: "Bytes to PNG", - description: - "Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.", - category: "convert", - sourceMode: "text", - params: [ - { - id: "width", - label: "Image width", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 32, - }, - ], - runFromText: (text, p) => - bytesToImage( - text, - Math.trunc(num(p as Record, "width")), - ), - run: (img) => clonePixelImage(img), - }, - { - id: "png-to-rgb-values", - title: "PNG to RGB Values", - description: - "Lists every pixel as rgba(r, g, b, a), one image row per line.", - category: "convert", - params: [], - resultType: "text", - toText: (img) => imageToRgbValues(img), - }, - { - id: "rgb-values-to-png", - title: "RGB Values to PNG", - description: - "Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.", - category: "convert", - sourceMode: "text", - params: [ - { - id: "width", - label: "Image width", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 32, - }, - ], - runFromText: (text, p) => - rgbValuesToImage( - text, - Math.trunc(num(p as Record, "width")), - ), - run: (img) => clonePixelImage(img), - }, - { - 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", - params: [ - { - id: "background", - label: "Backdrop color", - type: "color", - default: "#ffffff", - }, - { - id: "quality", - label: "JPEG quality", - type: "slider", - min: 1, - max: 100, - step: 1, - default: 90, - }, - ], - output: { - mime: "image/jpeg", - ext: "jpg", - qualityParamId: "quality", - }, - run: (img, p) => flattenOntoColor(img, str(p, "background")), - }, - { - 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", - params: [ - { - id: "quality", - label: "WebP quality", - type: "slider", - min: 1, - max: 100, - step: 1, - default: 90, - }, - ], - output: { - mime: "image/webp", - ext: "webp", - qualityParamId: "quality", - }, - run: (img) => clonePixelImage(img), - }, - { - id: "compress-png", - title: "Compress PNG", - description: - "Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.", - category: "convert", - params: [ - { - id: "level", - label: "Compression level", - type: "select", - default: "balanced", - options: [ - { value: "light", label: "Light (192 colors)" }, - { value: "balanced", label: "Balanced (96 colors)" }, - { value: "strong", label: "Strong (44 colors)" }, - { value: "extreme", label: "Extreme (16 colors)" }, - ], - }, - ], - run: (img, p) => { - const level = str(p, "level") as CompressionLevel; - const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced; - return quantizeImage(img, k).image; - }, - }, - { - id: "reduce-to-size-png", - title: "Reduce PNG to Size", - description: - "Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.", - category: "convert", - domOnly: true, - params: [ - { - id: "targetKB", - label: "Target size, KB", - type: "slider", - min: 5, - max: 2000, - step: 5, - default: 100, - }, - { - id: "maxColors", - label: "Max colors to try", - type: "slider", - min: 2, - max: 256, - step: 1, - default: 256, - }, - ], - run: async (img, p) => { - const targetBytes = num(p, "targetKB") * 1024; - const maxK = num(p, "maxColors"); - const encodeSize = async (k: number): Promise => { - const blob = await encode(quantizeImage(img, k).image, "image/png"); - return blob.size; - }; - const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize); - return quantizeImage(img, k).image; - }, - }, - { - id: "svg-to-png", - title: "SVG to PNG", - description: - "Decodes SVG markup into a raster image. Paste the SVG code on the left.", - category: "convert", - sourceMode: "text", - params: [ - { - id: "width", - label: "Result width, px", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 512, - }, - ], - runFromText: (text, p) => - decodeSvgText(text, Math.trunc(num(p, "width"))), - run: (img) => clonePixelImage(img), - }, - ]; -} diff --git a/web/src/lib/v1/registry/filters.ts b/web/src/lib/v1/registry/filters.ts deleted file mode 100644 index 91df6eb..0000000 --- a/web/src/lib/v1/registry/filters.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { ToolEntry } from "../registry"; -import { gaussianBlur, sharpen as sharpenImage } from "../../core/convolution"; -import { vignette } from "../../core/effects"; -import { - pixelate, - shuffleBlocks, - addNoise, - silhouette, -} from "../../core/pixel-fx"; -import { jpegRoundtrip } from "../../core/io"; -import { num, str } from "../registry-helpers"; - -export function filterEntries(): ToolEntry[] { - return [ - { - id: "blur-png", - title: "Blur PNG", - description: - "Gaussian blur: three passes of separable box blur — fast at any radius. Transparent edges do not darken.", - category: "filters", - params: [ - { - id: "radius", - label: "Radius, px", - type: "slider", - min: 1, - max: 32, - step: 1, - default: 4, - }, - ], - run: (img, p) => gaussianBlur(img, num(p, "radius")), - }, - { - id: "sharpen-png", - title: "Sharpen PNG", - description: - "Emphasizes edges with a sharpening kernel; strength sets the blend with the original. 0% means no change.", - category: "filters", - params: [ - { - id: "strength", - label: "Strength, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => sharpenImage(img, num(p, "strength")), - }, - { - id: "vignette-png", - title: "Vignette PNG", - description: - "Smoothly darkens the edges of the image, leaving the center untouched.", - category: "filters", - params: [ - { - id: "strength", - label: "Darkening strength, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 50, - }, - ], - run: (img, p) => vignette(img, num(p, "strength")), - }, - { - id: "pixelate-png", - title: "Pixelate PNG", - description: - "Averages every blockSize×blockSize area into one color — classic mosaic.", - category: "filters", - params: [ - { - id: "blockSize", - label: "Block size, px", - type: "slider", - min: 2, - max: 64, - step: 1, - default: 8, - }, - ], - run: (img, p) => pixelate(img, num(p, "blockSize")), - }, - { - id: "randomize-pixels-png", - title: "Randomize Pixels PNG", - description: - "Shuffles blocks of the image between positions. Same seed gives the same arrangement.", - category: "filters", - params: [ - { - id: "blockSize", - label: "Block size, px", - type: "slider", - min: 1, - max: 64, - step: 1, - default: 8, - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999, - step: 1, - default: 42, - }, - ], - run: (img, p) => shuffleBlocks(img, num(p, "blockSize"), num(p, "seed")), - }, - { - 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", - params: [ - { - id: "amount", - label: "Amount, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 25, - }, - { - id: "mode", - label: "Noise type", - type: "select", - default: "mono", - options: [ - { value: "mono", label: "Monochrome grain" }, - { value: "color", label: "Color noise" }, - ], - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999, - step: 1, - default: 1234, - }, - ], - run: (img, p) => - addNoise( - img, - num(p, "amount"), - str(p, "mode") === "color" ? "color" : "mono", - num(p, "seed"), - ), - }, - { - id: "silhouette-png", - title: "Silhouette PNG", - description: - "Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.", - category: "filters", - params: [ - { - id: "color", - label: "Silhouette color", - type: "color", - default: "#111318", - }, - { - id: "threshold", - label: "Visibility threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 10, - }, - ], - run: (img, p) => - silhouette(img, str(p, "color"), num(p, "threshold") * 2.55), - }, - { - id: "jpeg-artifacts-png", - title: "JPEG artifacts", - description: - "Simulates low-quality JPEG re-compression — visible blocks and smeared colors.", - category: "filters", - params: [ - { - id: "quality", - label: "JPEG quality", - type: "slider", - min: 1, - max: 50, - step: 1, - default: 10, - }, - ], - run: (img, p) => jpegRoundtrip(img, num(p, "quality")), - }, - ]; -} diff --git a/web/src/lib/v1/registry/generate.ts b/web/src/lib/v1/registry/generate.ts deleted file mode 100644 index 8cdca19..0000000 --- a/web/src/lib/v1/registry/generate.ts +++ /dev/null @@ -1,950 +0,0 @@ -import type { ToolEntry } from "../registry"; -import { ToolError } from "../../core/errors"; -import { solidImage, noiseImage, gradientImage } from "../../core/generate"; -import { - colorSpectrum, - drawGrid, - randomColorBlocks, -} from "../../core/gen-tools"; -import { - renderTextToImage, - renderEmoji, - type TextFont, -} from "../../core/domText"; -import { changeCanvasSize } from "../../core/geometry"; -import { - renderWheel, - renderSwatches, - renderBlend, - complementarySet, - triadicSet, - tetradicSet, - analogousSet, - monochromaticSet, - shadeSet, - mixColors, - parseHexList, - stepColors, - sortPalette, - type SortKey, -} from "../../core/palette"; -import { num, str, bool } from "../registry-helpers"; - -function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { - const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); - if (!match) { - throw new ToolError("errors.badHex", { value: hex }); - } - const d = match[1]; - return [ - parseInt(d.slice(0, 2), 16), - parseInt(d.slice(2, 4), 16), - parseInt(d.slice(4, 6), 16), - alpha, - ]; -} - -function paletteParams(baseDefault: string) { - return [ - { - id: "baseColor", - label: "Base color", - type: "color" as const, - default: baseDefault, - }, - { - id: "width", - label: "Width", - type: "slider" as const, - min: 128, - max: 1024, - step: 16, - default: 512, - }, - { - id: "layout", - label: "Layout", - type: "select" as const, - default: "grid", - options: [ - { value: "grid", label: "Grid" }, - { value: "strip", label: "Strip" }, - ], - }, - ]; -} - -export function generateEntries(): ToolEntry[] { - return [ - { - id: "create-empty-png", - title: "Create empty PNG", - description: - "Generates a canvas of the chosen size — transparent or filled with color.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "transparent", - label: "Transparent", - type: "checkbox", - default: true, - }, - { - id: "color", - label: "Color", - type: "color", - default: "#ffffff", - }, - ], - generate: (p) => - solidImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - p["transparent"] === true ? [0, 0, 0, 0] : hexToRgba(str(p, "color")), - ), - }, - { - id: "single-color-png", - title: "Create solid color PNG", - description: "Generates a rectangle of the given size and color.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 256, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 256, - }, - { - id: "color", - label: "Color", - type: "color", - default: "#ff0000", - }, - ], - generate: (p) => - solidImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - hexToRgba(str(p, "color")), - ), - }, - { - id: "random-noise-png", - title: "Create random noise PNG", - description: - "Generates an image with random pixels. The seed fixes the result: one seed — one image.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999999, - step: 1, - default: 1, - }, - ], - generate: (p) => - noiseImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - num(p, "seed"), - ), - }, - { - id: "text-to-png", - title: "Text to PNG", - description: - "Creates a PNG image from text: the canvas is sized to fit the label plus padding.", - category: "generate", - sourceMode: "none", - domOnly: true, - params: [ - { - id: "text", - label: "Text", - type: "text", - default: "Hello!", - placeholder: "Your text", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 8, - max: 300, - step: 1, - default: 96, - }, - { - id: "font", - label: "Font", - type: "select", - default: "sans", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { - id: "bold", - label: "Bold", - type: "checkbox", - default: true, - }, - { - id: "color", - label: "Text color", - type: "color", - default: "#111318", - }, - { - id: "transparentBg", - label: "Transparent background", - type: "checkbox", - default: false, - }, - { - id: "backgroundColor", - label: "Background color", - type: "color", - default: "#ffffff", - }, - { - id: "padding", - label: "Padding, px", - type: "slider", - min: 0, - max: 200, - step: 2, - default: 24, - }, - ], - generate: (p) => - renderTextToImage({ - text: str(p, "text"), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - backgroundColor: str(p, "backgroundColor"), - transparentBg: bool(p, "transparentBg"), - padding: num(p, "padding"), - }), - }, - { - id: "emoji-to-png", - title: "Emoji to PNG", - description: - "Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.", - category: "generate", - sourceMode: "none", - domOnly: true, - params: [ - { - id: "emoji", - label: "Emoji / symbol", - type: "text", - default: "\u{1F600}", - }, - { - id: "size", - label: "Size", - type: "slider", - min: 32, - max: 1024, - step: 16, - default: 256, - }, - ], - generate: (p) => renderEmoji(str(p, "emoji"), Math.trunc(num(p, "size"))), - }, - { - id: "placeholder-png", - title: "Create Placeholder PNG", - description: - "Generates a placeholder rectangle with its dimensions printed in the center.", - category: "generate", - sourceMode: "none", - domOnly: true, - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 400, - }, - { - id: "backgroundColor", - label: "Background", - type: "color", - default: "#dfe2e8", - }, - { - id: "color", - label: "Text color", - type: "color", - default: "#5c6470", - }, - { - id: "showText", - label: "Print dimensions", - type: "checkbox", - default: true, - }, - ], - generate: (p) => { - const w = Math.trunc(num(p, "width")); - const h = Math.trunc(num(p, "height")); - const label = renderTextToImage({ - text: `${w} × ${h}`, - fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)), - font: "sans", - bold: true, - color: str(p, "color"), - backgroundColor: str(p, "backgroundColor"), - transparentBg: false, - padding: 0, - }); - return changeCanvasSize(label, w, h, "center"); - }, - }, - { - id: "linear-gradient-png", - title: "Create gradient PNG", - description: - "Generates a smooth transition between two colors, horizontally or vertically.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "fromColor", - label: "Start color", - type: "color", - default: "#000000", - }, - { - id: "toColor", - label: "End color", - type: "color", - default: "#ffffff", - }, - { - id: "direction", - label: "Direction", - type: "select", - default: "horizontal", - options: [ - { value: "horizontal", label: "Horizontal" }, - { value: "vertical", label: "Vertical" }, - ], - }, - ], - generate: (p) => - gradientImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - hexToRgba(str(p, "fromColor")), - hexToRgba(str(p, "toColor")), - str(p, "direction") === "vertical" ? "vertical" : "horizontal", - ), - }, - { - id: "color-spectrum-png", - title: "Color Spectrum PNG", - description: - "Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 1024, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 128, - }, - { - id: "direction", - label: "Direction", - type: "select", - default: "horizontal", - options: [ - { value: "horizontal", label: "Horizontal" }, - { value: "vertical", label: "Vertical" }, - ], - }, - { - id: "saturation", - label: "Saturation, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 100, - }, - { - id: "lightness", - label: "Lightness, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - generate: (p) => - colorSpectrum( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - str(p, "direction") === "vertical" ? "vertical" : "horizontal", - num(p, "saturation"), - num(p, "lightness"), - ), - }, - { - id: "random-colors-png", - title: "Random Color Blocks PNG", - description: - "Fills the canvas with random vivid color blocks. Deterministic by seed.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "blockSize", - label: "Block size, px", - type: "slider", - min: 4, - max: 256, - step: 2, - default: 64, - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999999, - step: 1, - default: 7, - }, - ], - generate: (p) => - randomColorBlocks( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - num(p, "blockSize"), - num(p, "seed"), - ), - }, - { - id: "draw-grid-png", - title: "Draw Grid PNG", - description: - "Draws a grid with custom columns, rows and line width on a transparent or white background.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "cols", - label: "Columns", - type: "slider", - min: 1, - max: 64, - step: 1, - default: 8, - }, - { - id: "rows", - label: "Rows", - type: "slider", - min: 1, - max: 64, - step: 1, - default: 8, - }, - { - id: "lineWidth", - label: "Line width, px", - type: "slider", - min: 1, - max: 40, - step: 1, - default: 2, - }, - { - id: "color", - label: "Line color", - type: "color", - default: "#111318", - }, - { - id: "transparentBg", - label: "Transparent background", - type: "checkbox", - default: true, - }, - ], - generate: (p) => - drawGrid( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - num(p, "cols"), - num(p, "rows"), - num(p, "lineWidth"), - str(p, "color"), - bool(p, "transparentBg"), - ), - }, - { - id: "color-wheel-png", - title: "Color Wheel PNG", - description: - "Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Size", - type: "slider", - min: 128, - max: 1024, - step: 16, - default: 512, - }, - { - id: "lightness", - label: "Lightness, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - generate: (p) => - renderWheel(Math.trunc(num(p, "width")), num(p, "lightness")), - }, - { - id: "complementary-png", - title: "Complementary Palette PNG", - description: - "Two opposite colors on the color wheel — the base and its complement.", - category: "generate", - sourceMode: "none", - params: paletteParams("#2563eb"), - generate: (p) => - renderSwatches( - complementarySet(str(p, "baseColor")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "triadic-png", - title: "Triadic Palette PNG", - description: "Three colors evenly spaced 120° apart on the color wheel.", - category: "generate", - sourceMode: "none", - params: paletteParams("#ff0000"), - generate: (p) => - renderSwatches( - triadicSet(str(p, "baseColor")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "tetradic-png", - title: "Tetradic Palette PNG", - description: - "Four colors in two complementary pairs, 90° apart on the wheel.", - category: "generate", - sourceMode: "none", - params: paletteParams("#8000ff"), - generate: (p) => - renderSwatches( - tetradicSet(str(p, "baseColor")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "analogous-png", - title: "Analogous Palette PNG", - description: - "Neighboring hues around the base color — calm, related color scheme.", - category: "generate", - sourceMode: "none", - params: [ - ...paletteParams("#22c55e"), - { - id: "spread", - label: "Hue spread, °", - type: "slider", - min: 10, - max: 90, - step: 5, - default: 30, - }, - { - id: "count", - label: "Colors", - type: "slider", - min: 3, - max: 9, - step: 1, - default: 5, - }, - ], - generate: (p) => - renderSwatches( - analogousSet(str(p, "baseColor"), num(p, "spread"), num(p, "count")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "monochromatic-png", - title: "Monochromatic Palette PNG", - description: - "Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.", - category: "generate", - sourceMode: "none", - params: [ - ...paletteParams("#0ea5e9"), - { - id: "count", - label: "Colors", - type: "slider", - min: 2, - max: 9, - step: 1, - default: 5, - }, - { - id: "range", - label: "Lightness range, %", - type: "slider", - min: 10, - max: 90, - step: 5, - default: 40, - }, - ], - generate: (p) => - renderSwatches( - monochromaticSet( - str(p, "baseColor"), - num(p, "count"), - num(p, "range"), - ), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "shades-png", - title: "Shade Ramp PNG", - description: "A ramp of the base color getting darker step by step.", - category: "generate", - sourceMode: "none", - params: [ - ...paletteParams("#f59e0b"), - { - id: "count", - label: "Colors", - type: "slider", - min: 2, - max: 9, - step: 1, - default: 5, - }, - { - id: "depth", - label: "Darkening depth, %", - type: "slider", - min: 10, - max: 90, - step: 5, - default: 50, - }, - ], - generate: (p) => - renderSwatches( - shadeSet(str(p, "baseColor"), num(p, "count"), num(p, "depth")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "mix-colors-png", - title: "Mix Colors PNG", - description: - "Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "colors", - label: "Colors (comma-separated hex)", - type: "text", - default: "#ff0000,#00ff00,#0000ff", - }, - { - id: "width", - label: "Width", - type: "slider", - min: 128, - max: 1024, - step: 16, - default: 512, - }, - ], - generate: (p) => - renderSwatches( - [mixColors(parseHexList(str(p, "colors")))], - num(p, "width"), - "strip", - ), - }, - { - id: "blend-two-png", - title: "Blend Two Colors PNG", - description: "A continuous horizontal gradient between two colors.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "colorA", - label: "Color A", - type: "color", - default: "#000000", - }, - { - id: "colorB", - label: "Color B", - type: "color", - default: "#ffffff", - }, - { - id: "width", - label: "Width", - type: "slider", - min: 128, - max: 1024, - step: 16, - default: 512, - }, - ], - generate: (p) => - renderBlend(str(p, "colorA"), str(p, "colorB"), num(p, "width")), - }, - { - id: "step-colors-png", - title: "Color Steps PNG", - description: "A discrete set of evenly spaced steps between two colors.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "colorA", - label: "Color A", - type: "color", - default: "#000000", - }, - { - id: "colorB", - label: "Color B", - type: "color", - default: "#ffffff", - }, - { - id: "steps", - label: "Steps", - type: "slider", - min: 2, - max: 12, - step: 1, - default: 6, - }, - ...paletteParams("#808080").filter((q) => q.id !== "baseColor"), - ], - generate: (p) => - renderSwatches( - stepColors(str(p, "colorA"), str(p, "colorB"), num(p, "steps")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "sort-colors-png", - title: "Sort Colors PNG", - description: - "Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "colors", - label: "Colors (comma-separated hex)", - type: "text", - default: "#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff", - }, - { - id: "order", - label: "Sort by", - type: "select", - default: "hue", - options: [ - { value: "hue", label: "Hue" }, - { value: "luma", label: "Brightness" }, - { value: "sat", label: "Saturation" }, - ], - }, - ...paletteParams("#ffffff").filter((q) => q.id !== "baseColor"), - ], - generate: (p) => - renderSwatches( - sortPalette( - parseHexList(str(p, "colors")), - str(p, "order") as SortKey, - ), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - ]; -} diff --git a/web/src/lib/v1/registry/geometry.ts b/web/src/lib/v1/registry/geometry.ts deleted file mode 100644 index 8b081c6..0000000 --- a/web/src/lib/v1/registry/geometry.ts +++ /dev/null @@ -1,645 +0,0 @@ -import { - rotateFreeImage, - skewImage, - transformImage, - zoomImage, -} from "../../core/affine"; -import { ToolError } from "../../core/errors"; -import { - centerByAlpha, - changeCanvasSize, - crop, - cropToRatio, - expandCanvas, - flip, - forceOrientation, - padToRatio, - resize, - rotate90, - symmetricCopy, - tile, - trimToContent, - type Anchor9, -} from "../../core/geometry"; -import type { ToolEntry } from "../registry"; -import { num, str } from "../registry-helpers"; - -export function geometryEntries(): ToolEntry[] { - return [ - { - id: "resize-png", - title: "Resize PNG", - description: - "Scales the image with bilinear interpolation. With aspect kept, one side defines the scale; if both are set, the image fits inside them.", - category: "geometry", - params: [ - { - id: "width", - label: "Width (0 = auto)", - type: "number", - min: 0, - max: 20000, - step: 1, - default: 0, - }, - { - id: "height", - label: "Height (0 = auto)", - type: "number", - min: 0, - max: 20000, - step: 1, - default: 0, - }, - { - id: "keepAspect", - label: "Keep aspect ratio", - type: "checkbox", - default: true, - }, - ], - run: (img, p) => { - const keepAspect = p["keepAspect"] === true; - let w = Math.trunc(num(p, "width")); - let h = Math.trunc(num(p, "height")); - if (keepAspect) { - if (w > 0 && h > 0) { - const scale = Math.min(w / img.width, h / img.height); - w = Math.max(1, Math.round(img.width * scale)); - h = Math.max(1, Math.round(img.height * scale)); - } else if (w > 0) { - h = Math.max(1, Math.round((img.height / img.width) * w)); - } else if (h > 0) { - w = Math.max(1, Math.round((img.width / img.height) * h)); - } - } - if (w <= 0 || h <= 0) { - throw new ToolError("errors.resizeSize"); - } - return resize(img, w, h); - }, - }, - { - id: "crop-png", - title: "Crop PNG", - description: - "Cuts out a rectangular area. Coordinates and sizes may go beyond the image — the area is clipped to the intersection.", - category: "geometry", - params: [ - { - id: "x", - label: "X (from left)", - type: "number", - min: -0, - max: 2000, - step: 1, - default: 0, - }, - { - id: "y", - label: "Y (from top)", - type: "number", - min: 0, - max: 1200, - step: 1, - default: 0, - }, - { - id: "width", - label: "Area width", - type: "number", - min: 0, - max: 2000, - step: 1, - default: 0, - }, - { - id: "height", - label: "Area height", - type: "number", - min: 0, - max: 1200, - step: 1, - default: 0, - }, - ], - run: (img, p) => { - const w = Math.trunc(num(p, "width")); - const h = Math.trunc(num(p, "height")); - if (w <= 0 || h <= 0) { - throw new ToolError("errors.cropSize"); - } - return crop( - img, - Math.trunc(num(p, "x")), - Math.trunc(num(p, "y")), - w, - h, - ); - }, - }, - { - id: "rotate-png", - title: "Rotate PNG", - description: - "Rotates by 90°, 180° or 270° clockwise without quality loss.", - category: "geometry", - params: [ - { - id: "angle", - label: "Rotation angle", - type: "select", - default: "90", - options: [ - { value: "90", label: "90° clockwise" }, - { value: "180", label: "180°" }, - { value: "270", label: "270° clockwise" }, - ], - }, - ], - run: (img, p) => rotate90(img, Number(str(p, "angle")) / 90), - }, - { - id: "flip-png", - title: "Flip PNG", - description: "Mirrors horizontally or vertically without quality loss.", - category: "geometry", - params: [ - { - id: "axis", - label: "Flip axis", - type: "select", - default: "horizontal", - options: [ - { - value: "horizontal", - label: "Horizontal (left to right)", - }, - { - value: "vertical", - label: "Vertical (top to bottom)", - }, - ], - }, - ], - run: (img, p) => - flip(img, str(p, "axis") === "vertical" ? "vertical" : "horizontal"), - }, - { - id: "add-padding-png", - title: "Add padding to PNG", - description: - "Expands the canvas on all sides by the chosen number of pixels.", - category: "geometry", - params: [ - { - id: "padding", - label: "Padding, px", - type: "number", - min: 1, - max: 2000, - step: 1, - default: 10, - }, - { - id: "transparent", - label: "Transparent padding", - type: "checkbox", - default: true, - }, - { - id: "color", - label: "Padding color", - type: "color", - default: "#ffffff", - }, - ], - run: (img, p) => - expandCanvas( - img, - num(p, "padding"), - num(p, "padding"), - num(p, "padding"), - num(p, "padding"), - p["transparent"] === true ? undefined : str(p, "color"), - ), - }, - { - id: "add-border-png", - title: "Add border to PNG", - description: - "Draws a colored frame of the chosen thickness around the image.", - category: "geometry", - params: [ - { - id: "thickness", - label: "Border thickness, px", - type: "number", - min: 1, - max: 500, - step: 1, - default: 5, - }, - { - id: "color", - label: "Border color", - type: "color", - default: "#000000", - }, - ], - run: (img, p) => - expandCanvas( - img, - num(p, "thickness"), - num(p, "thickness"), - num(p, "thickness"), - num(p, "thickness"), - str(p, "color"), - ), - }, - { - id: "fit-on-background-png", - title: "Fit PNG onto background", - description: - "Places the image centered on a canvas of the given size with a transparent or colored background.", - category: "geometry", - params: [ - { - id: "width", - label: "Canvas width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Canvas height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "transparent", - label: "Transparent background", - type: "checkbox", - default: false, - }, - { - id: "color", - label: "Background color", - type: "color", - default: "#ffffff", - }, - ], - run: (img, p) => { - const width = Math.trunc(num(p, "width")); - const height = Math.trunc(num(p, "height")); - if (width <= 0 || height <= 0) { - throw new ToolError("errors.sizePositive"); - } - const left = Math.max(0, Math.floor((width - img.width) / 2)); - const top = Math.max(0, Math.floor((height - img.height) / 2)); - return expandCanvas( - img, - left, - top, - Math.max(0, width - img.width - left), - Math.max(0, height - img.height - top), - p["transparent"] === true ? undefined : str(p, "color"), - ); - }, - }, - { - id: "tile-png", - title: "Tile PNG", - description: - "Repeats the image in a grid of the chosen columns and rows.", - category: "geometry", - params: [ - { - id: "columns", - label: "Columns", - type: "number", - min: 1, - max: 50, - step: 1, - default: 2, - }, - { - id: "rows", - label: "Rows", - type: "number", - min: 1, - max: 50, - step: 1, - default: 2, - }, - ], - run: (img, p) => tile(img, num(p, "columns"), num(p, "rows")), - }, - { - id: "center-by-alpha-png", - title: "Center PNG by content", - description: - "Finds the opaque part of the image and centers it on the original canvas.", - category: "geometry", - params: [], - run: (img) => centerByAlpha(img), - }, - { - id: "skew-png", - title: "Skew PNG", - description: - "Shifts content horizontally and vertically — a perspective effect.", - category: "geometry", - params: [ - { - id: "degX", - label: "Skew X, °", - type: "slider", - min: -80, - max: 80, - step: 1, - default: 0, - }, - { - id: "degY", - label: "Skew Y, °", - type: "slider", - min: -80, - max: 80, - step: 1, - default: 0, - }, - ], - run: (img, p) => skewImage(img, num(p, "degX"), num(p, "degY")), - }, - { - id: "rotate-free-png", - title: "Rotate by custom angle", - description: - "Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.", - category: "geometry", - params: [ - { - id: "angle", - label: "Angle, °", - type: "slider", - min: -180, - max: 180, - step: 1, - default: 15, - }, - ], - run: (img, p) => rotateFreeImage(img, num(p, "angle")), - }, - { - id: "zoom-png", - title: "Zoom PNG", - description: - "Magnifies content toward the center. The canvas keeps its size — edges are cropped.", - category: "geometry", - params: [ - { - id: "scale", - label: "Scale, %", - type: "slider", - min: 100, - max: 500, - step: 10, - default: 200, - }, - ], - run: (img, p) => zoomImage(img, num(p, "scale")), - }, - { - id: "trim-empty-space-png", - title: "Trim Empty Space PNG", - description: - "Crops transparent borders around the content. Pixels with alpha above the threshold count as content.", - category: "geometry", - params: [ - { - id: "threshold", - label: "Alpha threshold", - type: "slider", - min: 0, - max: 254, - step: 1, - default: 0, - }, - ], - run: (img, p) => trimToContent(img, num(p, "threshold")), - }, - { - id: "change-canvas-size-png", - title: "Change Canvas Size PNG", - description: - "Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.", - category: "geometry", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "anchor", - label: "Anchor", - type: "select", - default: "center", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - ], - run: (img, p) => - changeCanvasSize( - img, - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - str(p, "anchor") as Anchor9, - ), - }, - { - id: "change-aspect-ratio-png", - title: "Change Aspect Ratio PNG", - description: - "Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.", - category: "geometry", - params: [ - { - id: "ratio", - label: "Target ratio", - type: "select", - default: "1:1", - options: [ - { value: "1:1", label: "1:1" }, - { value: "4:3", label: "4:3" }, - { value: "3:4", label: "3:4" }, - { value: "3:2", label: "3:2" }, - { value: "2:3", label: "2:3" }, - { value: "16:9", label: "16:9" }, - { value: "9:16", label: "9:16" }, - ], - }, - { - id: "mode", - label: "Mode", - type: "select", - default: "crop", - options: [ - { value: "crop", label: "Crop to fill" }, - { value: "pad", label: "Pad to fit" }, - ], - }, - ], - run: (img, p) => { - const [rw, rh] = str(p, "ratio").split(":").map(Number); - const ratio = rw / rh; - return str(p, "mode") === "pad" - ? padToRatio(img, ratio) - : cropToRatio(img, ratio); - }, - }, - { - id: "swap-orientation-png", - title: "Swap Orientation PNG", - description: - "Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.", - category: "geometry", - params: [ - { - id: "target", - label: "Target orientation", - type: "select", - default: "portrait", - options: [ - { value: "portrait", label: "Portrait" }, - { value: "landscape", label: "Landscape" }, - ], - }, - ], - run: (img, p) => - forceOrientation( - img, - str(p, "target") === "landscape" ? "landscape" : "portrait", - ), - }, - { - id: "symmetric-copy-png", - title: "Symmetric Copy PNG", - description: - "Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.", - category: "geometry", - params: [ - { - id: "axis", - label: "Mirror line", - type: "select", - default: "vertical", - options: [ - { - value: "vertical", - label: "Vertical (double width)", - }, - { - value: "horizontal", - label: "Horizontal (double height)", - }, - ], - }, - { - id: "keepSide", - label: "Keep side", - type: "select", - default: "left", - options: [ - { value: "left", label: "Left" }, - { value: "right", label: "Right" }, - { value: "top", label: "Top" }, - { value: "bottom", label: "Bottom" }, - ], - }, - ], - run: (img, p) => - symmetricCopy( - img, - str(p, "axis") === "horizontal" ? "horizontal" : "vertical", - str(p, "keepSide") as "left" | "right" | "top" | "bottom", - ), - }, - { - id: "shift-png", - title: "Shift PNG", - description: "Moves content by the given X and Y offset.", - category: "geometry", - params: [ - { - id: "offsetX", - label: "Offset X, px", - type: "number", - min: -5000, - max: 5000, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, px", - type: "number", - min: -5000, - max: 5000, - step: 1, - default: 0, - }, - { - id: "color", - label: "Background color", - type: "color", - default: "#ffffff", - }, - ], - run: (img, p) => - transformImage( - img, - [ - 1, - 0, - 0, - 1, - -Math.trunc(num(p, "offsetX")), - -Math.trunc(num(p, "offsetY")), - ], - img.width, - img.height, - str(p, "color"), - ), - }, - ]; -} diff --git a/web/src/lib/v1/registry/text.ts b/web/src/lib/v1/registry/text.ts deleted file mode 100644 index 379dbd1..0000000 --- a/web/src/lib/v1/registry/text.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { ToolEntry } from "../registry"; -import { drawTextBlock, drawTextTile, type TextFont } from "../../core/domText"; -import { formatStamp } from "../../core/datefmt"; -import type { Position9 } from "../../core/textdraw"; -import { num, str, bool } from "../registry-helpers"; - -export function textEntries(): ToolEntry[] { - return [ - { - id: "add-text-png", - title: "Add text to PNG", - description: - "Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.", - category: "text", - domOnly: true, - params: [ - { - id: "text", - label: "Text", - type: "text", - default: "Hello!", - placeholder: "Your text", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 8, - max: 200, - step: 1, - default: 48, - }, - { - id: "color", - label: "Text color", - type: "color", - default: "#ffffff", - }, - { - id: "font", - label: "Font", - type: "select", - default: "sans", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { id: "bold", label: "Bold", type: "checkbox", default: true }, - { - id: "position", - label: "Position", - type: "select", - default: "bottom-right", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - { - id: "margin", - label: "Margin, px", - type: "slider", - min: 0, - max: 200, - step: 1, - default: 24, - }, - { - id: "plate", - label: "Backing plate", - type: "checkbox", - default: false, - }, - { - id: "plateColor", - label: "Plate color", - type: "color", - default: "#000000", - }, - { - id: "plateOpacity", - label: "Plate opacity, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 60, - }, - ], - run: (img, p) => - drawTextBlock(img, { - text: str(p, "text"), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - opacityPercent: 100, - position: str(p, "position") as Position9, - margin: num(p, "margin"), - plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined, - plateOpacityPercent: num(p, "plateOpacity"), - }), - }, - { - id: "date-stamp-png", - title: "Date stamp PNG", - description: - "Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.", - category: "text", - domOnly: true, - params: [ - { - id: "format", - label: "Format", - type: "text", - default: "YYYY-MM-DD", - placeholder: "YYYY-MM-DD hh:mm", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 8, - max: 200, - step: 1, - default: 32, - }, - { - id: "color", - label: "Text color", - type: "color", - default: "#ffffff", - }, - { - id: "font", - label: "Font", - type: "select", - default: "mono", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { id: "bold", label: "Bold", type: "checkbox", default: false }, - { - id: "position", - label: "Position", - type: "select", - default: "bottom-right", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - { - id: "margin", - label: "Margin, px", - type: "slider", - min: 0, - max: 200, - step: 1, - default: 20, - }, - { - id: "plate", - label: "Backing plate", - type: "checkbox", - default: true, - }, - { - id: "plateColor", - label: "Plate color", - type: "color", - default: "#000000", - }, - { - id: "plateOpacity", - label: "Plate opacity, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 55, - }, - ], - run: (img, p) => - drawTextBlock(img, { - text: formatStamp(new Date(), str(p, "format")), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - opacityPercent: 100, - position: str(p, "position") as Position9, - margin: num(p, "margin"), - plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined, - plateOpacityPercent: num(p, "plateOpacity"), - }), - }, - { - id: "watermark-tile-png", - title: "Watermark Tile PNG", - description: - "Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.", - category: "text", - domOnly: true, - params: [ - { - id: "text", - label: "Text", - type: "text", - default: "DRAFT", - placeholder: "Watermark text", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 12, - max: 160, - step: 1, - default: 56, - }, - { - id: "color", - label: "Text color", - type: "color", - default: "#ffffff", - }, - { - id: "opacity", - label: "Opacity, %", - type: "slider", - min: 5, - max: 100, - step: 5, - default: 30, - }, - { - id: "angle", - label: "Angle, °", - type: "slider", - min: -90, - max: 90, - step: 1, - default: -30, - }, - { - id: "stepX", - label: "Step X, px", - type: "slider", - min: 40, - max: 600, - step: 10, - default: 220, - }, - { - id: "stepY", - label: "Step Y, px", - type: "slider", - min: 40, - max: 600, - step: 10, - default: 180, - }, - { - id: "font", - label: "Font", - type: "select", - default: "sans", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { - id: "bold", - label: "Bold", - type: "checkbox", - default: true, - }, - ], - run: (img, p) => - drawTextTile(img, { - text: str(p, "text"), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - opacityPercent: num(p, "opacity"), - stepX: num(p, "stepX"), - stepY: num(p, "stepY"), - angleDeg: num(p, "angle"), - }), - }, - ]; -} diff --git a/web/src/lib/v1/tools/auto-run.ts b/web/src/lib/v1/tools/auto-run.ts deleted file mode 100644 index 1eb92ec..0000000 --- a/web/src/lib/v1/tools/auto-run.ts +++ /dev/null @@ -1,26 +0,0 @@ -export function createAutoRunner(delayMs = 300) { - let token = 0; - let timer: ReturnType | undefined; - - return { - next(): number { - cancelTimer(); - return ++token; - }, - isCurrent(t: number): boolean { - return t === token; - }, - schedule(run: () => void): () => void { - cancelTimer(); - timer = setTimeout(run, delayMs); - return cancelTimer; - }, - }; - - function cancelTimer() { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - } -} diff --git a/web/src/lib/v1/tools/executor.test.ts b/web/src/lib/v1/tools/executor.test.ts deleted file mode 100644 index 1fef28e..0000000 --- a/web/src/lib/v1/tools/executor.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { executeStep } from "./executor"; -import { makeImage } from "../../core/test-helpers"; -import type { PixelImage } from "../../core/types"; - -function cloneOf(img: PixelImage): PixelImage { - return { - width: img.width, - height: img.height, - data: new Uint8ClampedArray(img.data), - }; -} - -describe("executeStep: прямой путь (среда без Worker)", () => { - it("возвращает результат инструмента", async () => { - const img = makeImage(1, 1, [[1, 2, 3, 4]]); - const out = await executeStep( - { id: "stub", run: (i) => cloneOf(i) }, - img, - {}, - ); - expect(out).toEqual(img); - }); - - it("пробрасывает ошибку инструмента как есть", async () => { - const boom = () => { - throw new Error("бум"); - }; - await expect( - executeStep( - { id: "stub", run: boom }, - makeImage(1, 1, [[0, 0, 0, 255]]), - {}, - ), - ).rejects.toThrow("бум"); - }); - - it("инструмент без run даёт понятную ошибку", async () => { - await expect( - executeStep({ id: "stub" }, makeImage(1, 1, [[0, 0, 0, 255]]), {}), - ).rejects.toThrow("errors.noImageRun"); - }); -}); diff --git a/web/src/lib/v1/tools/executor.ts b/web/src/lib/v1/tools/executor.ts deleted file mode 100644 index a8a9011..0000000 --- a/web/src/lib/v1/tools/executor.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { PixelImage } from "../../core/types"; -import { ToolError } from "../../core/errors"; - -type MaybeRunnable = { - id: string; - domOnly?: boolean; - run?: ( - img: PixelImage, - params: Record, - ) => Promise | PixelImage; -}; - -export async function executeStep( - tool: MaybeRunnable, - img: PixelImage, - params: Record, -): Promise { - if (!tool.run) { - throw new Error("errors.noImageRun"); - } - if (tool.domOnly) { - return await runDirect(tool, img, params); - } - if (typeof Worker === "undefined") { - return await runDirect(tool, img, params); - } - const worker = ensureWorker(); - if (worker === null) { - return await runDirect(tool, img, params); - } - try { - return await runInWorker(worker, tool.id, img, params); - } catch (workerError) { - disableWorker(); - void workerError; - return await runDirect(tool, img, params); - } -} - -async function runDirect( - tool: MaybeRunnable, - img: PixelImage, - params: Record, -): Promise { - if (!tool.run) { - throw new Error("errors.noImageRun"); - } - return await tool.run(img, params); -} - -let worker: Worker | null = null; -let workerTried = false; -let nextRequestId = 1; -const pending = new Map< - number, - { resolve: (img: PixelImage) => void; reject: (e: Error) => void } ->(); - -function ensureWorker(): Worker | null { - if (workerTried) return worker; - workerTried = true; - try { - const candidate = new Worker( - new URL("./executor.worker.ts", import.meta.url), - { - type: "module", - }, - ); - candidate.onmessage = (event: MessageEvent) => { - const payload = event.data as { - id: number; - ok: boolean; - width?: number; - height?: number; - data?: Uint8ClampedArray; - error?: string; - errorKey?: string; - errorVars?: Record; - }; - const entry = pending.get(payload.id); - if (!entry) return; - pending.delete(payload.id); - if (payload.ok && payload.data && payload.width && payload.height) { - entry.resolve({ - width: payload.width, - height: payload.height, - data: new Uint8ClampedArray(payload.data), - }); - } else if (payload.errorKey) { - entry.reject(new ToolError(payload.errorKey, payload.errorVars)); - } else { - entry.reject(new Error(payload.error ?? "errors.workerFailed")); - } - }; - candidate.onerror = () => { - worker = null; - for (const entry of pending.values()) { - entry.reject(new Error("errors.workerUnavailable")); - } - pending.clear(); - }; - worker = candidate; - } catch { - worker = null; - } - return worker; -} - -function disableWorker(): void { - if (worker) { - worker.terminate(); - } - worker = null; -} - -function runInWorker( - workerInstance: Worker, - toolId: string, - img: PixelImage, - params: Record, -): Promise { - return new Promise((resolve, reject) => { - const id = nextRequestId++; - pending.set(id, { resolve, reject }); - workerInstance.postMessage({ - id, - toolId, - image: { - width: img.width, - height: img.height, - data: new Uint8ClampedArray(img.data), - }, - params, - }); - }); -} diff --git a/web/src/lib/v1/tools/executor.worker.ts b/web/src/lib/v1/tools/executor.worker.ts deleted file mode 100644 index f6e34ea..0000000 --- a/web/src/lib/v1/tools/executor.worker.ts +++ /dev/null @@ -1,47 +0,0 @@ -/// -import type { PixelImage } from "../../core/types"; -import { ToolError } from "../../core/errors"; -import { getTool, sanitizeParams } from "../registry"; - -type WorkerRequest = { - id: number; - toolId: string; - image: { width: number; height: number; data: Uint8ClampedArray }; - params: Record; -}; - -self.onmessage = (event: MessageEvent) => { - void handle(event.data); -}; - -async function handle(request: WorkerRequest): Promise { - try { - const tool = getTool(request.toolId); - if (!tool?.run) { - throw new Error("errors.noImageRun"); - } - const image: PixelImage = { - width: request.image.width, - height: request.image.height, - data: new Uint8ClampedArray(request.image.data), - }; - const output = await tool.run(image, sanitizeParams(tool, request.params)); - const payload = { - id: request.id, - ok: true, - width: output.width, - height: output.height, - data: output.data, - }; - (self as unknown as Worker).postMessage(payload, [output.data.buffer]); - } catch (e) { - const toolError = e instanceof ToolError ? e : undefined; - (self as unknown as Worker).postMessage({ - id: request.id, - ok: false, - error: e instanceof Error ? e.message : String(e), - errorKey: toolError?.key, - errorVars: toolError?.vars, - }); - } -} diff --git a/web/src/lib/v1/tools/overlay-store.svelte.ts b/web/src/lib/v1/tools/overlay-store.svelte.ts deleted file mode 100644 index 0a2e98c..0000000 --- a/web/src/lib/v1/tools/overlay-store.svelte.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PixelImage } from "$lib/core/types"; - -/** - * Картинка-знак для watermark-image-png. Живёт только в состоянии страницы: - * сознательно не сохраняется в pipeline/localStorage — после восстановления - * цепочки знак нужно выбрать заново. - */ -let overlay = $state(null); - -export function getOverlay(): PixelImage | null { - return overlay; -} - -export function setOverlay(img: PixelImage): void { - overlay = img; -} - -export function clearOverlay(): void { - overlay = null; -} diff --git a/web/src/lib/v1/tools/pipeline.test.ts b/web/src/lib/v1/tools/pipeline.test.ts deleted file mode 100644 index f33c699..0000000 --- a/web/src/lib/v1/tools/pipeline.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createStep, parseDocument } from "./pipeline"; - -describe("createStep", () => { - it("создаёт шаг с дефолтными значениями инструмента", () => { - const step = createStep("grayscale-png"); - expect(step.toolId).toBe("grayscale-png"); - expect(step.id.length).toBeGreaterThan(0); - }); - - it("бросает ошибку для неизвестного инструмента", () => { - expect(() => createStep("no-such-tool")).toThrow(/errors\.toolNotFound/); - }); -}); - -describe("parseDocument", () => { - const valid = JSON.stringify({ - version: 1, - steps: [ - { id: "a", toolId: "grayscale-png", values: {} }, - { - id: "b", - toolId: "adjust-brightness-contrast-png", - values: { brightness: 5000, contrast: "мусор" }, - }, - ], - }); - - it("разбирает корректный документ и санитизирует значения", () => { - const steps = parseDocument(valid); - expect(steps).toHaveLength(2); - expect(steps[0].id).toBe("a"); - expect(steps[1].values).toEqual({ brightness: 100, contrast: 0 }); - }); - - it.each([ - "не json", - "{}", - JSON.stringify({ version: 99, steps: [] }), - JSON.stringify({ version: 1 }), - ])("отклоняет мусор: %s", (raw) => { - expect(() => parseDocument(raw as string)).toThrow(); - }); - - it("пропускает шаги с неизвестным инструментом", () => { - const raw = JSON.stringify({ - version: 1, - steps: [ - { id: "x", toolId: "nope", values: {} }, - { id: "y", toolId: "flip-png", values: { axis: "vertical" } }, - ], - }); - const steps = parseDocument(raw); - expect(steps.map((s) => s.id)).toEqual(["y"]); - }); -}); diff --git a/web/src/lib/v1/tools/pipeline.ts b/web/src/lib/v1/tools/pipeline.ts deleted file mode 100644 index 67c94e6..0000000 --- a/web/src/lib/v1/tools/pipeline.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { getTool, sanitizeParams } from "../registry"; -import { ToolError } from "../../core/errors"; - -export type PipelineStep = { - id: string; - toolId: string; - values: Record; -}; - -export type PipelineDocument = { version: number; steps: PipelineStep[] }; - -export const PIPELINE_VERSION = 1; -const STORAGE_KEY = "workspace-pipeline"; - -export function createStep(toolId: string): PipelineStep { - const tool = getTool(toolId); - if (!tool) { - throw new ToolError("errors.toolNotFound", { id: toolId }); - } - return { id: newStepId(), toolId, values: {} }; -} - -export function newStepId(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); - } - return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`; -} - -export function parseDocument(raw: string): PipelineStep[] { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new ToolError("errors.badJson"); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new ToolError("errors.badPipelineShape"); - } - const doc = parsed as { version?: unknown; steps?: unknown }; - if (doc.version !== PIPELINE_VERSION) { - throw new ToolError("errors.pipelineVersion", { - version: String(doc.version), - }); - } - if (!Array.isArray(doc.steps)) { - throw new ToolError("errors.noSteps"); - } - const steps: PipelineStep[] = []; - for (const rawStep of doc.steps) { - const step = validateStep(rawStep); - if (step) { - steps.push(step); - } - } - return steps; -} - -function validateStep(raw: unknown): PipelineStep | null { - if (typeof raw !== "object" || raw === null) return null; - const candidate = raw as { id?: unknown; toolId?: unknown; values?: unknown }; - if (typeof candidate.toolId !== "string") return null; - const tool = getTool(candidate.toolId); - if ( - !tool || - typeof candidate.values !== "object" || - candidate.values === null - ) - return null; - const values = candidate.values as Record; - return { - id: - typeof candidate.id === "string" && candidate.id.length > 0 - ? candidate.id - : newStepId(), - toolId: candidate.toolId, - values: sanitizeParams(tool, values), - }; -} - -export function saveSteps(steps: PipelineStep[]): void { - if (typeof localStorage === "undefined") return; - try { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ version: PIPELINE_VERSION, steps }), - ); - } catch { - // переполнение квоты или приватный режим — сохранение необязательно для работы - } -} - -export function loadStoredSteps(): PipelineStep[] { - if (typeof localStorage === "undefined") return []; - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - return parseDocument(raw); - } catch { - return []; - } -} diff --git a/web/src/lib/v1/tools/tool-icons.ts b/web/src/lib/v1/tools/tool-icons.ts deleted file mode 100644 index de21ab4..0000000 --- a/web/src/lib/v1/tools/tool-icons.ts +++ /dev/null @@ -1,184 +0,0 @@ -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 = { - "resize-png": Scaling, - "crop-png": Crop, - "rotate-png": RotateCw, - "flip-png": FlipHorizontal2, - "grayscale-png": Contrast, - "invert-colors-png": Blend, - "adjust-brightness-contrast-png": Sun, - "convert-png-to-jpg": FileImage, - "convert-png-to-webp": FileImage, - "remove-color-from-png": Scissors, - "png-info": Info, - "jpg-to-png": FileImage, - "webp-to-png": FileImage, - "gif-to-png": Film, - "bmp-to-png": FileImage, - "ico-to-png": AppWindow, - "png-to-bmp": FileOutput, - "png-to-base64": Binary, - "base64-to-png": ClipboardPaste, - "png-to-data-uri": Link2, - "data-uri-to-png": ClipboardPaste, - "png-to-hex": Hash, - "hex-to-png": Hash, - "change-png-opacity": Droplet, - "sepia-png": Palette, - "change-png-hue": Rainbow, - "extract-channel-png": Layers, - "swap-channels-png": Repeat2, - "black-and-white-png": Contrast, - "posterize-png": Layers, - "two-colors-png": Blend, - "invert-alpha-png": Droplets, - "remove-alpha-channel-png": Square, - "set-alpha-channel-png": Droplet, - "extract-alpha-mask-png": Layers, - "round-corners-png": Frame, - "add-padding-png": Maximize2, - "add-border-png": Frame, - "fit-on-background-png": ImageIcon, - "tile-png": Grid3x3, - "center-by-alpha-png": Crosshair, - "create-empty-png": FilePlus2, - "single-color-png": PaintBucket, - "random-noise-png": Dices, - "linear-gradient-png": Sun, - "png-is-grayscale": SearchCheck, - "png-is-transparent": Eye, - "png-orientation": Ruler, - "blur-png": Droplets, - "sharpen-png": Focus, - "remove-background-png": Scissors, - "add-stroke-png": Square, - "add-text-png": Type, - "date-stamp-png": CalendarDays, - "png-to-hsl": Blend, - "png-to-hsv": Droplet, - "png-to-hsi": Focus, - "png-to-cmyk": PaintBucket, - "png-to-ycbcr": Layers, - "png-to-lab": Ruler, - "show-transparent-png": Eye, - "show-grayscale-pixels-png": Contrast, - "show-color-pixels-png": Rainbow, - "light-pixel-mask-png": Sun, - "dark-pixel-mask-png": Moon, - "unique-color-mask-png": Dices, - "extract-color-from-png": Focus, - "quantize-png": Contrast, - "decrease-color-count-png": Scaling, - "custom-palette-png": Palette, - "dithering-png": Dices, - "feather-edges-png": Droplets, - "clean-edges-png": Scissors, - "pixelate-png": Grid3x3, - "randomize-pixels-png": Dices, - "add-noise-png": Hash, - "silhouette-png": Contrast, - "trim-empty-space-png": Crop, - "change-canvas-size-png": Frame, - "change-aspect-ratio-png": Scaling, - "swap-orientation-png": RotateCw, - "symmetric-copy-png": FlipHorizontal2, - "compress-png": Minimize2, - "reduce-to-size-png": Scaling, - "png-file-size": Info, - "png-to-bytes": Binary, - "bytes-to-png": Binary, - "png-to-rgb-values": Hash, - "rgb-values-to-png": Hash, - "verify-is-png": SearchCheck, - "text-to-png": Type, - "emoji-to-png": FaceSlightlySmiling, - "placeholder-png": Frame, - "color-spectrum-png": Rainbow, - "random-colors-png": Dices, - "draw-grid-png": Grid3x3, - "circle-mask-png": Circle, - "square-mask-png": Square, - "star-mask-png": Star, - "wavy-mask-png": WavesHorizontal, - "watermark-tile-png": Stamp, - "watermark-image-png": FileImage, - "color-wheel-png": Rainbow, - "complementary-png": Contrast, - "triadic-png": Hash, - "tetradic-png": Grid3x3, - "analogous-png": Blend, - "monochromatic-png": Droplets, - "shades-png": Sun, - "mix-colors-png": PaintBucket, - "blend-two-png": Layers, - "step-colors-png": Scaling, - "sort-colors-png": SearchCheck, - "find-contour-png": Scan, - "make-thicker-png": ZoomIn, - "make-thinner-png": ZoomOut, - "harden-alpha-png": Sun, - "despeckle-alpha-png": SearchCheck, - "close-holes-png": Hammer, - "skew-png": Repeat2, - "rotate-free-png": RotateCw, - "zoom-png": ZoomIn, - "shift-png": Crosshair, - "vignette-png": Sun, - "jpeg-artifacts-png": Layers, - "gamma-png": Contrast, - "auto-contrast-png": Contrast, - "temperature-png": Sun, - "tint-png": Droplet, - "svg-to-png": FileImage, -}; diff --git a/web/src/lib/v1/tools/tool-popularity.ts b/web/src/lib/v1/tools/tool-popularity.ts deleted file mode 100644 index 543df33..0000000 --- a/web/src/lib/v1/tools/tool-popularity.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const TOOL_POPULARITY: Record = { - "crop-png": 92, - "resize-png": 90, - "remove-background-png": 95, - "blur-png": 85, - "sharpen-png": 80, - "convert-png-to-jpg": 82, - "jpg-to-png": 78, - "rotate-png": 75, - "flip-png": 70, - "convert-png-to-webp": 68, - "webp-to-png": 62, - "grayscale-png": 60, - "adjust-brightness-contrast-png": 58, - "invert-colors-png": 52, - "remove-color-from-png": 65, - "change-png-opacity": 45, - "sepia-png": 40, - "change-png-hue": 38, - "add-padding-png": 42, - "add-border-png": 38, - "two-colors-png": 35, - "black-and-white-png": 42, - "posterize-png": 30, - "round-corners-png": 36, - "fit-on-background-png": 34, - "extract-channel-png": 24, - "swap-channels-png": 22, - "invert-alpha-png": 26, - "remove-alpha-channel-png": 30, - "set-alpha-channel-png": 24, - "extract-alpha-mask-png": 20, - "tile-png": 28, - "center-by-alpha-png": 26, - "png-info": 44, - "png-is-transparent": 18, - "png-is-grayscale": 14, - "png-orientation": 16, - "png-to-base64": 40, - "base64-to-png": 32, - "png-to-data-uri": 34, - "data-uri-to-png": 26, - "png-to-hex": 10, - "hex-to-png": 8, - "gif-to-png": 30, - "bmp-to-png": 18, - "ico-to-png": 20, - "png-to-bmp": 12, - "create-empty-png": 25, - "single-color-png": 20, - "random-noise-png": 12, - "linear-gradient-png": 22, -}; diff --git a/web/src/routes/v1/+layout.svelte b/web/src/routes/v1/+layout.svelte deleted file mode 100644 index 23c138b..0000000 --- a/web/src/routes/v1/+layout.svelte +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - -

-
- easy-png-tools - -
- -
- {@render children()} -
- -
-

{t("header.footerNote")}

-
-
- - diff --git a/web/src/routes/v1/+page.svelte b/web/src/routes/v1/+page.svelte deleted file mode 100644 index 91668c8..0000000 --- a/web/src/routes/v1/+page.svelte +++ /dev/null @@ -1,119 +0,0 @@ - - - - - {selected ? `${selected.title} — easy-png-tools` : t("home.defaultTitle")} - - - -{#if !selected} -
-

{t("home.heroTitle")}

-

{t("home.heroLead")}

- openTool(id)} /> - {#if lastToolId !== null && getTool(lastToolId)} - {@const restoreId = lastToolId} -
- -
- {/if} -
-{:else} -
- - {#key selectedId} - - {/key} -
-{/if} - - diff --git a/web/src/routes/v1/demo/+page.svelte b/web/src/routes/v1/demo/+page.svelte deleted file mode 100644 index 3f33aa2..0000000 --- a/web/src/routes/v1/demo/+page.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - - Mockup: цепочка инструментов — easy-png-tools - - - -
- -
- - diff --git a/web/src/routes/v1/list-tools/+page.svelte b/web/src/routes/v1/list-tools/+page.svelte deleted file mode 100644 index 91cc747..0000000 --- a/web/src/routes/v1/list-tools/+page.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - {t("catalog.pageTitle")} - - - -

{t("catalog.heading")}

-

{t("catalog.lead", { count: TOOLS.length })}

- -{#each CATEGORIES as category (category)} - {@const categoryTools = TOOLS.filter((tool) => tool.category === category)} - {#if categoryTools.length > 0} -
-

- {t(`categories.${category}`)} -

-
- {#each categoryTools as tool (tool.id)} -
- -
- {/each} -
-
- {/if} -{/each} - - diff --git a/web/src/routes/v1/tools/[id]/+page.svelte b/web/src/routes/v1/tools/[id]/+page.svelte deleted file mode 100644 index 53334db..0000000 --- a/web/src/routes/v1/tools/[id]/+page.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - {tool ? toolTitle(tool) : t("toolPage.fallbackTitle")} — easy-png-tools - {#if tool} - - {/if} - - -{#if tool} - -{/if} diff --git a/web/src/routes/v1/tools/[id]/+page.ts b/web/src/routes/v1/tools/[id]/+page.ts deleted file mode 100644 index 2be2a08..0000000 --- a/web/src/routes/v1/tools/[id]/+page.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { t } from "$lib/v1/i18n/t"; -import { getTool, TOOLS } from "$lib/v1/registry"; -import { error } from "@sveltejs/kit"; -import type { EntryGenerator, PageLoad } from "./$types"; - -export const entries: EntryGenerator = () => - TOOLS.map((tool) => ({ id: tool.id })); - -export const load: PageLoad = ({ params }) => { - const tool = getTool(params.id); - if (!tool) { - error(404, t("errors.notFound")); - } - return { id: tool.id }; -}; diff --git a/web/stylelint.config.js b/web/stylelint.config.js index 80c75a7..0ef4e3c 100644 --- a/web/stylelint.config.js +++ b/web/stylelint.config.js @@ -1,12 +1,11 @@ // Stylelint config — design-token enforcement for easy-png-tools. // FWHM: keeps the design system a single source of truth. Colors and sizes must // come from prefixed tokens; direct color values are allowed only for the two -// brand tokens. app_v1.css is the legacy design and is ignored (removed later). +// brand tokens. export default { extends: ["stylelint-config-standard"], ignoreFiles: [ - "src/app_v1.css", "**/node_modules/**", "**/build/**", "**/.svelte-kit/**", @@ -52,8 +51,6 @@ export default { }, overrides: [ { - // Applies to every CSS file EXCEPT app_v1.css (already in ignoreFiles) — - // the legacy design is exempt and will be removed later. files: ["src/**/*.css"], rules: { // Rule: only --brand-main and --brand-alt may set a color