feat: remove v1 routes and libs

This commit is contained in:
2026-09-25 16:39:58 +05:00
parent 473b62ef53
commit 16d4288912
91 changed files with 181 additions and 11960 deletions
+3 -10
View File
@@ -20,9 +20,8 @@
`docs/quality-gates.md`. `docs/quality-gates.md`.
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок: - Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг `pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
выполняется, даже если предыдущий упал. `app_v1.css` и legacy-ветка исключены выполняется, даже если предыдущий упал. Новые изменения не должны добавлять
из новых правил; их долг чинится только по заведённым задачам, а новые предупреждения без записи в baseline.
изменения не должны добавлять новые предупреждения без записи в baseline.
## Правила кода ## Правила кода
@@ -49,8 +48,7 @@
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила, Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
которые линтер не проверяет: которые линтер не проверяет:
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в - Новый дизайн живёт в `web/src/app.css` и применяется ко всем production-маршрутам.
`web/src/app_v1.css` (маршруты `/v1/*`).
- Все повторяющиеся визуальные элементы — отдельные компоненты в - Все повторяющиеся визуальные элементы — отдельные компоненты в
`web/src/lib/components/`, даже «просто div с двумя стилями». `web/src/lib/components/`, даже «просто div с двумя стилями».
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из - Дизайн-токены (цвета, размеры, длительности, z-index) — только из
@@ -69,10 +67,5 @@
дату архивирования и срок удаления; архив — только справка, не источник дату архивирования и срок удаления; архив — только справка, не источник
требований. требований.
## Изоляция веток old ↔ new
Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
каждый импорт до файла и запрещает смешивание.
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить. - Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
Изменения делать небольшими (< ~500 строк), атомарными. Изменения делать небольшими (< ~500 строк), атомарными.
+10 -10
View File
@@ -6,7 +6,7 @@
## Порядок чтения ## Порядок чтения
1. `AGENTS.md` — команды, обязательные инварианты и границы old/new. 1. `AGENTS.md` — команды и обязательные инварианты.
2. Этот файл — выбрать актуальный документ по теме. 2. Этот файл — выбрать актуальный документ по теме.
3. `docs/plan-codebase-quality.md` — текущий план приведения качества в порядок 3. `docs/plan-codebase-quality.md` — текущий план приведения качества в порядок
и последовательность следующих шагов. и последовательность следующих шагов.
@@ -23,8 +23,7 @@
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active | | `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active | | `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
| `docs/roadmap.md` | Общие направления развития | active | | `docs/roadmap.md` | Общие направления развития | active |
| `docs/plan-redesign.md` | Новый дизайн и границы `/v1` | active | | `docs/plan-redesign.md` | Новый дизайн production UI | active |
| `docs/plan-preview-i18n.md` | План i18n нового UI | active |
| `docs/plan-seo.md` | SEO/GEO-план | active | | `docs/plan-seo.md` | SEO/GEO-план | active |
| `docs/plan-platform.md` | PWA/API/WASM-план | active | | `docs/plan-platform.md` | PWA/API/WASM-план | active |
| `docs/checklist-manual-testing.md` | Ручная приёмка preview | 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 check` проходит без ошибок и предупреждений;
- `pnpm --dir web lint:all` завершается с 0 errors и 0 i18n warnings; остаются 4 - `pnpm --dir web test` — 39 test files / 422 tests;
unused design tokens от token audit; - `pnpm --dir web test:rules` — 4 test files / 74 tests;
- `pnpm verify` проходит; unit/e2e результаты считать актуальными только после - `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
отдельного прогона, исторические числа в `docs/archive/` не являются baseline. design tokens от token audit;
- `pnpm verify` проходит; production E2E — 105 passed, 5 skipped.
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
+4 -5
View File
@@ -12,12 +12,11 @@
4. Найти существующие типы, константы, helpers и тестовые паттерны до создания 4. Найти существующие типы, константы, helpers и тестовые паттерны до создания
нового определения. нового определения.
5. Определить границу изменения: 5. Определить границу изменения:
- новый UI — `web/src/routes/**` и `web/src/lib/components/**`; - production UI — `web/src/routes/**` и `web/src/lib/components/**`;
- legacy — `web/src/routes/v1/**` и `web/src/lib/v1/**`;
- shared — `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts`. - shared — `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts`.
Изменение shared-кода требует проверки обеих веток. Смешивание old/new нельзя Изменение shared-кода требует проверки production-потребителей. Не добавлять
обходить относительными импортами или исключениями линтера. исключения линтера ради отдельных модулей без записи в baseline.
## 2. Порядок изменения ## 2. Порядок изменения
@@ -73,7 +72,7 @@ E2E запускается отдельно от быстрых проверок
- обязательное поведение агента — `AGENTS.md`; - обязательное поведение агента — `AGENTS.md`;
- индекс и карта документов — `docs/README.md`; - индекс и карта документов — `docs/README.md`;
- качество и точные команды — `docs/quality-gates.md`; - качество и точные команды — `docs/quality-gates.md`;
- тестовая архитектура — `docs/testing-strategy.md` после Q3; - тестовая архитектура — `docs/testing-strategy.md` после Q4;
- lint implementation — `web/eslint-plugins/README.md`; - lint implementation — `web/eslint-plugins/README.md`;
- продуктовые решения — backlog и активные планы. - продуктовые решения — backlog и активные планы.
@@ -1,3 +1,7 @@
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в
> backlog. Использование: только историческая справка
# План: i18n в preview (новый UI) # План: i18n в preview (новый UI)
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI, > Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
+7 -10
View File
@@ -4,9 +4,9 @@
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые - [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
значения, совпадение `{placeholder}`-переменных между всеми локалями) — значения, совпадение `{placeholder}`-переменных между всеми локалями) —
правило `i18n/dict-consistency`, детали в §Фаза 8 правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`.
`docs/plan-preview-i18n.md`. Правило реализовано; Q2 перевёл i18n baseline Правило реализовано; Q2 перевёл i18n baseline к zero-warning через
к zero-warning через policy-aware fallback, не меняя `/v1`. policy-aware fallback.
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть - [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
что за `SonarLint` или написать кастомный линтер) что за `SonarLint` или написать кастомный линтер)
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n - [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
@@ -14,7 +14,7 @@
- [ ] консистентность комментариев - если есть описание к одному инструменту - - [ ] консистентность комментариев - если есть описание к одному инструменту -
оно должно быть у всех или объяснено, почему этот инструмент такой оно должно быть у всех или объяснено, почему этот инструмент такой
особенный и заслуживает комментарий особенный и заслуживает комментарий
- [ ] удалить legacy `tools.*.params` из root i18n после проверки потребителей; - [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого Q2 держит pattern-based policy-исключение до этого Q2 держит pattern-based policy-исключение
## Мелочи всякие ## Мелочи всякие
@@ -53,8 +53,7 @@
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone) - [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width - - [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку 100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность - [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
(range?)
- [ ] проверить настройки range slider на crop-png - слишком большие значения - [ ] проверить настройки range slider на crop-png - слишком большие значения
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса - [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
значения значения
@@ -75,8 +74,7 @@
- [x] добавить i18n в preview - [x] добавить i18n в preview
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) — - [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
`tPlural()` и формат `PluralForms`, детали в §Фаза 9 `tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog
`docs/plan-preview-i18n.md`
## SEO/GEO ## SEO/GEO
@@ -103,8 +101,7 @@
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md` Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
(фазы 3–8) — здесь только новые треки. (фазы 3–8) — здесь только новые треки.
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1; - [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
C19 отложен — не блокер)
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier - [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
(после wasm-фаз 5–7) (после wasm-фаз 5–7)
+61 -76
View File
@@ -1,10 +1,10 @@
# План: линтеры, тесты и качество кодовой базы # План: линтеры, тесты и качество кодовой базы
> Статус: **draft — на ревью**. > Статус: **draft — Q0–Q3 готовы к ревью; Q4 — следующий этап**.
> >
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
> инфраструктурных скриптов. До подтверждения плана кодовые изменения не > инфраструктурных скриптов. Q0–Q3 выполнены; Q4–Q8 выполняются отдельными
> выполняются. > атомарными шагами.
> >
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
> проверок, стабилизировать тестовые контракты и только после этого расширять > проверок, стабилизировать тестовые контракты и только после этого расширять
@@ -14,51 +14,40 @@
1. Сначала stabilизировать существующие проверки и тестовые контракты, а не 1. Сначала stabilизировать существующие проверки и тестовые контракты, а не
добавлять новые сложные lint-правила. добавлять новые сложные lint-правила.
2. Старый UI (`src/lib/v1/`, `src/routes/v1/`, `app_v1.css`) больше не является 2. Старый UI удалён: production работает на корневых маршрутах, а `/v1/**` и
обязательной поддерживаемой версией: текущий production на корневых маршрутах v1-specific код больше не являются частью приложения.
уже достаточно стабилен для использования. 3. Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты.
3. Пока `/v1` существует, старый и новый UI остаются изолированными. Не тратить 4. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и
время на исправления, тесты и документацию, которые нужны только v1. smoke-сценариями production-потребителей.
4. Удаление v1 — реальный следующий cleanup-задача (Q3). Не ждать, пока legacy 5. Изменения делаются небольшими атомарными частями, в соответствии с
начнёт мешать, и не тратить время на его поддержку; если задача упирается в
v1, Q3 выполняется перед ней.
5. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и
smoke-сценариями для обеих веток, пока ветка v1 не удалена.
6. Изменения делаются небольшими атомарными частями, в соответствии с
`AGENTS.md`. `AGENTS.md`.
7. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме, 6. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме,
получает fixtures и тесты, и только после очистки baseline становится получает fixtures и тесты, и только после очистки baseline становится
blocking. blocking.
8. Детали реализации правил остаются в `web/eslint-plugins/README.md`; 7. Детали реализации правил остаются в `web/eslint-plugins/README.md`;
`AGENTS.md` содержит краткие обязательные политики, понятные агенту без `AGENTS.md` содержит краткие обязательные политики, понятные агенту без
дополнительных поисков, и ссылки на подробные документы. дополнительных поисков, и ссылки на подробные документы.
### Политика legacy v1 ### Политика удаления legacy v1
- v1 можно удалить; это разрешённый cleanup, а не обязательная работа перед - Q3 — отдельная атомарная cleanup-задача; поддержка v1 не возвращается.
каждой задачей. - Старые маршруты и UI-файлы удаляются без redirects и без совместимого
- Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты без maintenance-режима.
прямой зависимости текущей задачи от legacy-кода. - В рабочей кодовой базе не оставлять UI-v1-пути, исключения или документацию,
- Если задача требует изменений в v1 или общих модулях, сначала выполнить Q3, которые выглядят как действующий источник совместимости.
если это не противоречит атомарности текущей задачи, и продолжить работу - Исторические упоминания допустимы только в `docs/archive/`.
только с production-веткой. - Будущий API-контракт `POST /v1/process` из `docs/plan-platform.md` не связан с
- Если порядок работ изменится, Q3 можно перенести как целый cleanup, но не удалённым UI-v1 и остаётся в планах.
возвращать постоянную поддержку v1.
- После удаления проверить маршруты, импорты, i18n, тесты, документацию и
конфигурацию lint; старые ссылки не оставлять как «источник совместимости».
## 2. Что уже есть и что мешает ## 2. Текущее состояние quality-контура
- `i18n/dict-consistency` реализован и подключён как `warn-only` - `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование,
(`web/eslint.config.js:177-188`). Проверки ключей, пустых значений и docs и production build; deploy выполняет его перед сборкой.
placeholders есть, но текущий baseline ещё содержит расхождения. - `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt —
- `lint:all` запускает ESLint, Stylelint и token audit 4 unused design tokens от token audit.
(`web/scripts/lint-all.mjs:31-54`), но не запускает typecheck, unit-тесты, - В e2e остаются фиксированные числа, точные тексты, CSS-классы и
Prettier или Playwright. `alt`-селекторы: `web/e2e/catalog.spec.ts:4-55`,
- CI workflow сейчас проверяет только production build `web/e2e/text-and-verdicts.spec.ts:52-101`.
(`.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`.
- Backlog и реализация расходятся по генераторам: продуктовая заметка требует - Backlog и реализация расходятся по генераторам: продуктовая заметка требует
автоматический запуск без `Generate`-кнопки (`docs/backlog.md:115-122`), а автоматический запуск без `Generate`-кнопки (`docs/backlog.md:115-122`), а
тест ожидает кнопку (`web/e2e/generators.spec.ts:8-16`). тест ожидает кнопку (`web/e2e/generators.spec.ts:8-16`).
@@ -137,9 +126,10 @@ pnpm --dir web test:e2e
- `BASE_LOCALE` — единственный источник registry-English fallback для - `BASE_LOCALE` — единственный источник registry-English fallback для
`tools.*.title` и `tools.*.description`; остальные локали требуют физические `tools.*.title` и `tools.*.description`; остальные локали требуют физические
ключи. ключи.
- Legacy `tools.*.params` исключён pattern-based policy до отдельной очистки - Временно неиспользуемые `tools.*.params` исключены pattern-based policy до
мёртвого словаря; пустые значения и placeholders не подавляются. отдельной очистки мёртвого словаря; пустые значения и placeholders не
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые legacy подавляются.
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые
option-группы из `ru.ts`. option-группы из `ru.ts`.
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается - Исправлен tie-break placeholder parity: при равенстве голосов выбирается
`BASE_LOCALE`. `BASE_LOCALE`.
@@ -148,22 +138,21 @@ pnpm --dir web test:e2e
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим - 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 - Удалены `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific
i18n/tests и оставшиеся v1-ссылки. i18n/tests и fixtures.
- Убрать v1 из isolation/lint/test-конфигураций и документации; не оставлять - Удалены isolation plugin, его тест и все v1-исключения из lint-конфигураций.
исключения только ради удалённого кода. - Убраны v1-ссылки из production-кода и активных документов; исторические
- Обновить shared `core`/`theme` проверки: после удаления v1 проверять только упоминания остаются в `docs/archive/`.
production-потребителя. - Проверены production build, verify и отдельный E2E smoke production-маршрутов.
- Прогнать `pnpm verify` и отдельный e2e smoke production-маршрутов.
- Не выполнять удаление как побочный эффект другого PR; это отдельная атомарная
задача.
**Готово, когда:** production build и verify зелёные, v1-маршруты и файлы **Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют;
отсутствуют, а ссылки и конфиги не содержат legacy-путей. следующий этап — Q4.
### Q4. Тестовый контракт e2e ### Q4. Тестовый контракт e2e
@@ -243,8 +232,6 @@ file-result-инструментам.
- Очистить или использовать unused design tokens; после очистки решить, должен - Очистить или использовать unused design tokens; после очистки решить, должен
ли unused audit быть fail-режимом. ли unused audit быть fail-режимом.
- Добавить тесты для token audit и PostCSS HCT-плагина. - Добавить тесты для token audit и PostCSS HCT-плагина.
- Проверить/исправить расхождение документации isolation rule и реально
передаваемых options.
- Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов. - Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов.
- Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и - Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и
hardcoded colors наравне со Svelte styles. hardcoded colors наравне со Svelte styles.
@@ -291,8 +278,8 @@ Blanket-запрет всех string literals не использовать.
1. Какой набор команд считается полным quality gate и что означает успешный 1. Какой набор команд считается полным quality gate и что означает успешный
результат при warnings. результат при warnings.
2. Старый/new scope и политику v1: v1 можно удалить, но пока он существует, не 2. Production-scope и политику удаления v1: новые изменения относятся только к
расширять на него новые правила и не тратить время на его поддержку. production UI; исторические UI-v1-упоминания остаются в архиве.
3. Политику e2e: semantic locators, допустимый `data-testid`, запрет 3. Политику e2e: semantic locators, допустимый `data-testid`, запрет
бессмысленных counts/classes/alt/exact prose. бессмысленных counts/classes/alt/exact prose.
4. Политику i18n: новые пользовательские тексты через i18n; registry source, 4. Политику i18n: новые пользовательские тексты через i18n; registry source,
@@ -301,7 +288,7 @@ Blanket-запрет всех string literals не использовать.
исправление и удаление теста происходят вместе. исправление и удаление теста происходят вместе.
6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на 6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на
работу. работу.
7. Shared-модули `core`/`theme` требуют проверки обеих веток. 7. Shared-модули `core`/`theme` требуют проверки production-потребителей.
Синтаксические списки ESLint-правил и подробные токены остаются в Синтаксические списки ESLint-правил и подробные токены остаются в
`web/eslint-plugins/README.md`. `web/eslint-plugins/README.md`.
@@ -309,7 +296,7 @@ Blanket-запрет всех string literals не использовать.
### Политика документации для агентов ### Политика документации для агентов
`AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем `AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем
только обязательный рабочий контракт: команды, границы old/new, ключевые только обязательный рабочий контракт: команды, production-scope, ключевые
инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять
причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка; причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка;
длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в
@@ -351,15 +338,15 @@ history или пересоздают как новый активный док
### Какие документы подготовить ### Какие документы подготовить
| Документ | Назначение | Когда создавать/обновлять | | Документ | Назначение | Когда создавать/обновлять |
| ------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | | ------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа | | `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа |
| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка, работа с legacy и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` | | `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` |
| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI | | `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/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры |
| `docs/architecture-overview.md` | Карта `core`, registry, executor, нового UI и `/v1`, shared-модули и зависимости | Q5 или перед крупным рефакторингом; не дублировать существующие планы | | `docs/architecture-overview.md` | Карта `core`, registry, executor, production UI и shared-модулей | Q5 или перед крупным рефакторингом; не дублировать существующие планы |
| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами | | `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами |
| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики | | `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики |
Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию
backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и
@@ -392,11 +379,9 @@ refactor, новые i18n-правила и исправление продук
## 7. Решения, которые нужно принять на ревью ## 7. Решения, которые нужно принять на ревью
- Оставляем ли dictionary linter warn-only до очистки или сразу вводим blocking - Какие semantic locators и допустимые `data-testid` закрепить в Q4?
baseline?
- Считаем ли `registry.title/description` физическими i18n-ключами или
допустимым английским fallback?
- Подтверждаем ли auto-run генераторов без кнопки Generate?
- Что именно должен делать Reset при включённом auto-run?
- Какие browser/e2e проверки должны быть обязательными для PR, а какие — - Какие browser/e2e проверки должны быть обязательными для PR, а какие —
отдельным ночным прогоном? отдельным ночным прогоном?
- Подтверждаем ли auto-run генераторов без кнопки Generate?
- Что именно должен делать Reset при включённом auto-run?
- Какие i18n-сценарии проверяются в e2e, а какие остаются unit-проверками?
+2 -3
View File
@@ -43,7 +43,7 @@
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере, - Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
CLI и API (harness фазы 7 расширяется третьим рантаймом). CLI и API (harness фазы 7 расширяется третьим рантаймом).
## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер) ## 2. Фаза 10 — PWA (после S1)
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 + - [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
maskable, theme-color из дизайн-токенов, `display: standalone`. maskable, theme-color из дизайн-токенов, `display: standalone`.
@@ -64,8 +64,7 @@
## 3. Порядок и связи ## 3. Порядок и связи
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются). - WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19 - PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7.
- Синергия с дистрибуцией: «works offline, installable» и публичный API — - Синергия с дистрибуцией: «works offline, installable» и публичный API —
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
GEO-текстов (`llms.txt`, описания категорий). GEO-текстов (`llms.txt`, описания категорий).
+50 -116
View File
@@ -1,44 +1,26 @@
# План: переезд на новый дизайн (refs) — параллельная сборка # План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**. > Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**, Q3
> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21 > (удаление старого UI) **выполнен**. Остался C20 (шаг 6, проверка нового
> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не > дизайна) — **следующий этап**. C21 (удалить refs) остаётся отдельной уборкой.
> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт, > Общий tech-debt baseline и следующие quality-задачи зафиксированы в
> аудит против рефа прекращён. Общий tech-debt baseline зафиксирован в > `docs/plan-codebase-quality.md`. Покупка домена НЕ была блокером C17: домен
> `docs/plan-codebase-quality.md`; legacy cleanup остаётся отложенным. Backlog > нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
> №14 (переезд старых файлов в `old/`) — **выполнено**. Покупка домена НЕ была
> блокером C17: домен нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие
> `noindex`.
> >
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву), > Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере. > `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
> >
> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка > **Историческая стратегия перехода:** сначала новый UI собирался отдельно,
> общих компонентов на месте с алиасами токенов) — **параллельная сборка**: > затем стал production, после чего legacy-ветка была удалена Q3. Текущие
> новый дизайн строится отдельно, изолируется от старого, проверяется целиком, > критерии относятся только к production UI; старые решения и детали перехода
> затем становится основным и старый удаляется. Старое приложение остаётся > остаются в архивных планах.
> рабочим и шippable на всём протяжении перехода.
## 0. Почему меняем стратегию ## 0. Почему меняем стратегию
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые токены - Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить
как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие компоненты независимо; теперь production использует только новые маршруты и `app.css`.
редактируются вживую → регресс старого UI во время работы, новый дизайн нельзя - Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout
принять изолированно. для удалённого UI-v1 больше не нужны.
- **Новый подход (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.
## 1. Источники и их оценка ## 1. Источники и их оценка
@@ -189,68 +171,29 @@
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@` > `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
> пригодится для сброса родительского layout. > пригодится для сброса родительского layout.
## 6. Маршрутизация параллельных страниц ## 6. Маршрутизация: историческая схема перехода
Новые страницы живут в **отдельной ветке `preview/*`**, не трогая старые: Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были
перенесены на production-маршруты. Эта схема нужна только для восстановления
контекста C17; текущая структура описана в разделе 7, отдельных preview- или
legacy-layouts в production больше нет.
```txt ## 7. Фазы выполнения
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, <AppShell>
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
```
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы не - **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и
изменяются (только перемещены в `(old)/`). интерактивные состояния приняты; детали перехода остаются в архивном
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты (move-файлы), `plan-design-fix.md`.
группа `(old)/` удаляется, `design2.css` подключается в корневом - **C17/C18 — production-переход.** Новые страницы перенесены на реальные
`+layout.svelte` (как новый основной слой), временный флаг/кнопка убираются. корневые маршруты; `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`, отсутствие остаточных
legacy-путей в production-коде и ревью в браузере для UI-изменений.
Каждая фаза заканчивается зелёными `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 — переключение и чистка (~полдня).
## 8. Смоук (после фазы 3, контроль на фазе 4) ## 8. Смоук (после фазы 3, контроль на фазе 4)
@@ -268,14 +211,10 @@ src/routes/preview/tools/[id]/+page.svelte
## 9. Открытые вопросы ## 9. Открытые вопросы
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17 - **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
разложил его в `components/` (подпапки fields/layout/ui). На C19 — только разложил его в `components/` (подпапки fields/layout/ui). На C21 — только
навести порядок внутри `components/`, переименование не требуется. навести порядок внутри `components/`, переименование не требуется.
- **Изоляция через layout:** `app.css` вынесен из корневого layout в - **Единый production CSS:** `app.css` подключается в корневом layout; отдельных
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`. legacy-layouts и переключателей версий больше нет.
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
(DevTools → вкладка Styles, нет правил из `app.css`).
- **Переключатель для проверки:** рекомендую `localStorage`-флаг + кнопка
«Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n- - **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
ключи, но допустить часть английских брендовых («приборная панель»). ключи, но допустить часть английских брендовых («приборная панель»).
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить — - **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
@@ -283,8 +222,8 @@ src/routes/preview/tools/[id]/+page.svelte
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После - **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код» (`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
не нужен и не создаётся. `refs/` и `refs-html/` можно удалить из репо на фазе не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка;
5 (по желанию), они не являются частью рантайма. их можно удалить в C21, они не являются частью рантайма.
## 10. План исполнения (мелкие коммиты) ## 10. План исполнения (мелкие коммиты)
@@ -362,10 +301,10 @@ src/routes/preview/tools/[id]/+page.svelte
### Шаг 5. Сделать новый дизайн основным ### Шаг 5. Сделать новый дизайн основным
> C17 — **выполнен** (`docs/archive/plan-c17.md`): старый `(old)/` → `/v1/`, > C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты
> preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки > перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов
> импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались: > после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и
> C19 (удаление старого, раскрытие `recommended` на весь код), C20, C21. > код. Остались C20 и C21.
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить - [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый `(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
@@ -387,26 +326,21 @@ src/routes/preview/tools/[id]/+page.svelte
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без - [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
предупреждений; `grep` по старым токенам/классам пуст. Доказательство предупреждений; `grep` по старым токенам/классам пуст. Доказательство
отсутствия регресса: старый `v1/` работает и не затронут. отсутствия регресса: production-маршруты и основные пользовательские
потоки работают.
### Шаг 7. Опциональная чистка (отложен, нет срока) ### Шаг 7. Cleanup
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
из конфигурации и активной документации.
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник - [ ] **[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/` (согласовать подпапки, финальное имя - [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
каталога). каталога).
> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской > C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит > После удаления проверяются только production-маршруты и общие модули.
> только когда нет горящих задач и хочется навести порядок.
> >
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать > Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита, > на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
+9 -9
View File
@@ -138,7 +138,7 @@
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
**domain-required** (после покупки и DNS-настройки). **domain-required** (после покупки и DNS-настройки).
#### S1-free: без домена (параллельно с C18–C19) #### S1-free: без домена (параллельно с quality-планом)
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на - [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд, существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
@@ -149,7 +149,7 @@
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть для lossy) для всех инструментов — механика `ToolEntry.output` уже есть
(backlog №7, райз «выбор формата/качества»). Продуктовая основа «image (backlog №7, райз «выбор формата/качества»). Продуктовая основа «image
tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
параллельно с C18–C19. параллельно с текущими quality-задачами.
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов - [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в
png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`, png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`,
@@ -166,8 +166,8 @@
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), `build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`),
учёт опционального `BASE_PATH`. учёт опционального `BASE_PATH`.
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до - [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
C19; после — вернуть минимальный allow-all). cleanup — вернуть минимальный allow-all.
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по - [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
актуальному маршруту), OG-теги (title/description/type/url/image), актуальному маршруту), OG-теги (title/description/type/url/image),
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`** twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
@@ -177,7 +177,7 @@
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами; > зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
> canonical/OG присутствуют на всех страницах; e2e зелёные. > canonical/OG присутствуют на всех страницах; e2e зелёные.
### S2. Контентный слой (параллельно C18–C19, волнами по категориям) ### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`, - [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде `howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
@@ -328,10 +328,10 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и - [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain: настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`. canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2 - **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
(домен) — блокер только для S1-domain (canonical + sitemap + снятие блокируют). R2 (домен) — блокер только для S1-domain (canonical + sitemap +
`noindex`). После индексации переименование = переезд с 301, потеря позиций и снятие `noindex`). После индексации переименование = переезд с 301, потеря
ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17. позиций и ссылок — не делать.
## 7. Что осознанно НЕ делаем ## 7. Что осознанно НЕ делаем
+3 -3
View File
@@ -55,10 +55,10 @@ baseline; после закрытия известных ошибок его м
- Token audit пока оставляет unused-token warnings отдельным неблокирующим - Token audit пока оставляет unused-token warnings отдельным неблокирующим
сообщением. сообщением.
- `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным - `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным
предупреждением token audit; это следующий debt Q6. предупреждением token audit; это следующий debt Q7.
- `dict-consistency` использует base-locale fallback для registry metadata и - `dict-consistency` использует base-locale fallback для registry metadata и
pattern-based policy для legacy `tools.*.params`; это не молчаливый pattern-based policy для временно неиспользуемых `tools.*.params`; это не
`allowPaths`-список. молчаливый `allowPaths`-список.
## CI ## CI
+9 -9
View File
@@ -2,13 +2,13 @@
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт, > **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
> каталог переведён в типизированный `registry` (122 текущих инструмента), новый > каталог переведён в типизированный `registry` (122 текущих инструмента), новый
> дизайн по `plan-redesign.md` стал основным на корневых маршрутах (C17/C18 > дизайн по `plan-redesign.md` стал единственным production UI на корневых
> выполнены), старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19 > маршрутах; UI-v1 удалён в Q3. Tech debt не закрыт полностью; текущий baseline
> в бэклоге). Tech debt не закрыт полностью; текущий baseline и следующие > и следующие quality-задачи зафиксированы в `docs/plan-codebase-quality.md`.
> quality-задачи зафиксированы в `docs/plan-codebase-quality.md`. Следующий > Следующий этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO
> этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO S1-free > S1-free (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`,
> (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`, S1-domain). Фазы > S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
> 2–8 — будущие. Фазы 9–10 спланированы в `docs/plan-platform.md`. > `docs/plan-platform.md`.
## 0. Решения ## 0. Решения
@@ -162,8 +162,8 @@ wasm.
### Фаза 10 — PWA — см. `docs/plan-platform.md` ### Фаза 10 — PWA — см. `docs/plan-platform.md`
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
устройство»), установка как приложение. Wasm-фаз не требует, идёт после C17/C19 устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из
и S1 из `plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2. `plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
## 3. Правила сравнения (для фаз 4–7) ## 3. Правила сравнения (для фаз 4–7)
+12 -47
View File
@@ -8,12 +8,11 @@
- `web/eslint-plugins/` — локальные ESLint-плагины. - `web/eslint-plugins/` — локальные ESLint-плагины.
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов. - `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
- `isolation/` — правило изоляции old/new UI.
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов). - `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`). - `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`). - `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь - `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`. токенов) и мини-словари `lib/i18n/`.
- `web/scripts/`: - `web/scripts/`:
- `lint-all.mjs` — оркестратор `lint:all`. - `lint-all.mjs` — оркестратор `lint:all`.
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`. - `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
@@ -28,16 +27,9 @@
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов, `@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
`prefer: 'type-imports'`). `prefer: 'type-imports'`).
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` + - Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
`eslint-plugin-svelte`) навешаны **только на новый код** `eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
(`src/lib/components/**`, `src/routes/**`), чтобы старый код не засыпался `src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
предсуществующими ошибками. Старый код (C17) — `src/lib/v1/**`, устаревшие пути в `eslint.config.js`.
`src/routes/v1/**`. Когда старый дизайн удалён (C19), scoped-блок убирается и
`recommended` включается на весь код.
- **Scoped-пути двигаются вместе с папками** (см. `plan-redesign.md` §10): не
оставлять устаревшие пути в `eslint.config.js` (C17 уже двигал
`routes/preview/**` → `routes/**` и `components/kit/**` → `components/**`; на
C19 при наведении порядка в `components/` paths останутся `components/**` +
`routes/**`).
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через - Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
`let`). `let`).
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**` - `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
@@ -45,7 +37,6 @@
`__fixtures__/` дописать их туда же. `__fixtures__/` дописать их туда же.
- design-tokens правила применяются к тем же scoped-путям - design-tokens правила применяются к тем же scoped-путям
(`src/lib/components/**`, `src/routes/**`). (`src/lib/components/**`, `src/routes/**`).
- isolation-правило включено на весь `**/*.{ts,svelte}`.
## Правила плагина design-tokens ## Правила плагина design-tokens
@@ -66,10 +57,11 @@
## Правило плагина i18n (`i18n/dict-consistency`) ## Правило плагина i18n (`i18n/dict-consistency`)
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md` Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый **всеми** остальными локалями (не только с BASE). Список локалей берётся из
словарь подхватывается без правки правила. `LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
правила.
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку. - **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
- Диагностика живёт там, где фикс: каждый файл репортит **только свои** - Диагностика живёт там, где фикс: каждый файл репортит **только свои**
@@ -92,9 +84,9 @@
отсутствовать только в базовой локали; в текущей конфигурации это registry отсутствовать только в базовой локали; в текущей конфигурации это registry
metadata `tools.*.title` и `tools.*.description`. Пустые значения и metadata `tools.*.title` и `tools.*.description`. Пустые значения и
placeholders для присутствующих ключей всё равно проверяются. placeholders для присутствующих ключей всё равно проверяются.
- `ignoreMissingPatterns: string[]` — паттерны legacy-ключей, которые временно - `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
не проверяются на missing; в текущей конфигурации это старый `tools.*.params`, проверяются на missing; в текущей конфигурации это неиспользуемые
не используемый новым UI. Эта опция не отключает empty/placeholder checks. `tools.*.params`. Эта опция не отключает empty/placeholder checks.
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в - Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в `__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
@@ -150,9 +142,6 @@ hct-авторство.
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами; `no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из `no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
`__fixtures__/src/app.css` (не из реального `src/app.css`). `__fixtures__/src/app.css` (не из реального `src/app.css`).
- `__tests__/no-mixed-imports.test.ts` — isolation-правило через `Linter` API с
`cwd` на `__fixtures__`: правило резолвит импорты по реальным файлам, поэтому
цели импортов обязаны существовать на диске.
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` — - Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
`process.cwd()` не трогается, реальные `src/` не читаются. `process.cwd()` не трогается, реальные `src/` не читаются.
@@ -161,27 +150,3 @@ hct-авторство.
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
добавление/правка стабов — тоже работа с валидным кодом. добавление/правка стабов — тоже работа с валидным кодом.
## Isolation-правило (`isolation/no-mixed-imports`)
**Полная взаимная изоляция** старого (v1) и нового (корень) UI. В отличие от
`no-restricted-imports`, правило **резолвит** каждый импорт (и `$lib/...`, и
относительные `./`/`../`) до реального файла и классифицирует стороны по
фактическому пути, поэтому относительным импортом правило не обойти.
- Старое: `routes/v1/**`, `lib/v1/**`.
- Новое: `routes/**` без `v1/**`; корневой `lib` целиком (`lib/**`) без
`lib/v1/**` и без общих пакетов.
- Общее (разрешено обоим) — только служебные пакеты: `core/`, `theme`. v1 не
высовывается: из `lib/v1/**` наружу разрешены импорты лишь в общие пакеты и в
собственный форк i18n (`lib/v1/i18n/**`).
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
glob-паттернов в настройке, а не код правила.
- Изоляция уже достигнута: старый `lib/v1/registry.ts`/`registry-helpers.ts` не
тянут `registry-schema` (пилоты add-border/add-stroke работают через
`params`), `registry.ts` не импортирует `ToolSchema`; новый `list-tools`
использует `$lib/tool-icons.ts`, а не старые `lib/v1/tools/tool-icons.ts`.
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
`app_v1.css`) — известный техдолг, его НЕ чинить и не игнорировать правилами.
@@ -1 +0,0 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -1,2 +0,0 @@
// Fixture stub for lint-rule tests: content is ignored (the test supplies the
// source code), this file only has to EXIST for import resolution.
@@ -1 +0,0 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
+4 -5
View File
@@ -3,11 +3,10 @@
// Two strategies, picked per rule in the test files: // Two strategies, picked per rule in the test files:
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the // - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`. // design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
// - Linter API + fixtures — needed when a rule reads real files. The isolation // - Linter API + fixtures — needed when a rule reads real files, such as
// rule resolves imports via fs (targets must exist on disk) and // no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
// no-undefined-in-svelte reads <cwd>/src/app.css for the token dictionary. // dictionary. `verifyInFixtures` runs a rule with cwd fixed to
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both // `__fixtures__/`, without touching process.cwd() or the real src/ tree.
// work without touching process.cwd() or the real src/ tree.
import { Linter } from "eslint"; import { Linter } from "eslint";
import type { Rule } from "eslint"; import type { Rule } from "eslint";
import path from "node:path"; import path from "node:path";
@@ -1,133 +0,0 @@
// Tests for the isolation rule (web/eslint-plugins/isolation/no-mixed-imports.js).
//
// The rule RESOLVES import specifiers to real files (via $lib alias and
// relative ../ paths), so every import target must exist on disk. It therefore
// runs through the Linter API with cwd pinned to the committed fixture tree
// (__fixtures__/src) — see __tests__/helpers.ts.
//
// Side map (mirrors the defaults in no-mixed-imports.js):
// old -> routes/v1/**, lib/v1/**
// new -> routes/** (minus v1), lib/** (minus lib/v1/** and shared)
// shared-> everything else: core/, theme
import { describe, expect, it } from "vitest";
import noMixedImports from "../isolation/no-mixed-imports.js";
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
const isolationConfig: FlatConfig = [
{
files: ["**/*.{ts,svelte}"],
plugins: {
isolation: {
rules: { "no-mixed-imports": asRuleModule(noMixedImports) },
},
},
rules: { "isolation/no-mixed-imports": "error" },
},
];
const messagesFor = (relFile: string, code: string) =>
verifyInFixtures(isolationConfig, code, relFile);
/** Assert a single "noMixed" violation with the given sides. */
function expectViolation(
relFile: string,
code: string,
side: string,
target: string,
) {
const messages = messagesFor(relFile, code);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("noMixed");
// ESLint interpolates `data` into the message text (no `data` field on the
// returned message), so assert on the rendered message instead.
expect(messages[0].message).toContain(`"${side}" file imports "${target}"`);
}
/** Assert the import is allowed (no violations). */
function expectClean(relFile: string, code: string) {
expect(messagesFor(relFile, code)).toEqual([]);
}
describe("isolation/no-mixed-imports", () => {
it("flags an old file importing a new component", () => {
expectViolation(
"lib/v1/old.ts",
'import { c } from "../components/CheckerCanvas.svelte";',
"old",
"new",
);
});
it("flags a new route importing an old module through the $lib alias", () => {
expectViolation(
"routes/+page.svelte",
'import { o } from "$lib/v1/old";',
"new",
"old",
);
});
it("flags a new component importing old code via a relative path", () => {
expectViolation(
"lib/components/CheckerCanvas.svelte",
'import { o } from "../v1/old.ts";',
"new",
"old",
);
});
it("allows old -> shared (core via relative path)", () => {
expectClean("lib/v1/old.ts", 'import { e } from "../core/errors";');
});
it("flags old -> root i18n ($lib/i18n is the new branch now)", () => {
expectViolation(
"routes/v1/+layout.svelte",
'import { t } from "$lib/i18n/t";',
"old",
"new",
);
});
it("allows old -> own i18n copy and theme via $lib", () => {
expectClean(
"routes/v1/+layout.svelte",
'import { t } from "$lib/v1/i18n/t";',
);
expectClean(
"routes/v1/+layout.svelte",
'import { g } from "$lib/theme.svelte";',
);
});
it("allows new -> new (root i18n and components)", () => {
expectClean("routes/+page.svelte", 'import { t } from "$lib/i18n/t";');
expectClean(
"routes/+page.svelte",
'import { b } from "$lib/components/ui/Button.svelte";',
);
});
it("flags new i18n importing old and leaves core shared", () => {
expectViolation(
"lib/i18n/t.ts",
'import { o } from "$lib/v1/old";',
"new",
"old",
);
expectClean(
"lib/core/errors.ts",
'import { c } from "$lib/components/CheckerCanvas.svelte";',
);
});
it("keeps the old i18n copy on the old side", () => {
expectClean("lib/v1/i18n/t.ts", 'import { o } from "$lib/v1/old";');
expectViolation(
"lib/v1/i18n/t.ts",
'import { c } from "$lib/components/CheckerCanvas.svelte";',
"old",
"new",
);
});
});
-33
View File
@@ -1,33 +0,0 @@
/**
* Local ESLint plugin "isolation".
*
* Guarantees full isolation between the old (v1, archived) UI branch and the
* new one. Unlike no-restricted-imports (which matches the literal import
* specifier string only), these rules RESOLVE the specifier to a real file
* (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then
* classify both sides by their actual location on disk.
*
* Why this is needed:
* - old code imports new things via RELATIVE paths (e.g. `../registry-schema`),
* which `$lib/...` glob patterns cannot catch;
* - the same target can be written many ways (`$lib/x`, `../x`, `../../x`),
* so enumerating literal strings is fragile.
*
* Configuration (per rule options):
* - `root`: path from the lint cwd to the source root (default "src");
* - `alias`: mapping from import aliases to dirs relative to root
* (default { "$lib": "lib" });
* - `old`, `new`: glob patterns (gitignore-style, `!` = negation) for the two
* branches. Files matched by neither are "shared" and unrestricted.
*/
import noMixedImports from "./no-mixed-imports.js";
export default {
meta: {
name: "isolation",
version: "1.0.0",
},
rules: {
"no-mixed-imports": noMixedImports,
},
};
@@ -1,196 +0,0 @@
/**
* Isolation rule: forbid mixing the "old" (v1) and the "new" branches.
*
* The rule RESOLVES every import specifier to a real file (handles both the
* `$lib/...` alias and relative `./` / `../` paths), classifies the source
* file and each import target by their actual location, and reports whenever an
* "old" file imports a "new" one or vice versa. Files that match neither side
* are "shared" (core/, theme, assets, tests, ...) and can import freely.
*
* Options (object, all optional):
* - root: path from lint cwd to the source dir (default "src");
* - alias: { $lib: "lib" } — alias -> dir relative to root;
* - old, new: gitignore-style glob lists for the two branches. Both default
* to the current layout so that moving a module between branches is a
* one-line config change (e.g. everything old will live under `old/`).
*/
import fs from "node:fs";
import path from "node:path";
// NEW declared as an allowlist: `lib/**` descends into v1/ and the shared
// packages (core/, theme), so negations carve them back out; `routes/**`
// needs `!routes/v1/**` for the old branch. Last-match-wins
// (see matchesGlobList).
const DEFAULT_OLD = ["routes/v1/**", "lib/v1/**"];
const DEFAULT_NEW = [
"routes/**",
"!routes/v1/**",
"lib/**",
"!lib/v1/**",
"!lib/core/**",
"!lib/theme.svelte.ts",
];
/** Escape everything except glob metacharacters, then handle **, *, ?. */
function globToRegExp(glob) {
let out = "^";
for (let i = 0; i < glob.length; i++) {
const ch = glob[i];
if (ch === "*" && glob[i + 1] === "*") {
out += ".*";
i++;
} else if (ch === "*") {
out += "[^/]*";
} else if (ch === "?") {
out += "[^/]";
} else if ("\\^$.|?*+()[]{}".includes(ch)) {
out += `\\${ch}`;
} else {
out += ch;
}
}
return new RegExp(out + "$");
}
/** gitignore-style: last-match-wins, `!` negates earlier positives. */
function matchesGlobList(patterns, rel) {
let included = false;
for (const raw of patterns) {
const negated = raw.startsWith("!");
const pattern = negated ? raw.slice(1) : raw;
if (globToRegExp(pattern).test(rel)) {
if (negated) return false;
included = true;
}
}
return included;
}
/** First existing candidate when resolving a bare path (no extension known). */
function firstExisting(base) {
const candidates = [
base,
`${base}.ts`,
`${base}.svelte`,
`${base}.svelte.ts`,
`${base}/index.ts`,
`${base}/index.svelte`,
];
for (const c of candidates) {
try {
if (fs.statSync(c).isFile()) return c;
} catch {
// keep trying
}
}
return null;
}
function toPortable(p) {
return p.replace(/\\/g, "/");
}
export default {
meta: {
type: "problem",
docs: {
description:
"Forbid imports between the old (v1) UI branch and the new branch.",
category: "Best Practices",
},
schema: [
{
type: "object",
properties: {
root: { type: "string" },
alias: { type: "object", additionalProperties: { type: "string" } },
old: { type: "array", items: { type: "string" } },
new: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
],
messages: {
noMixed:
'Isolation violation: "{{side}}" file imports "{{target}}" ({{targetRel}}).',
},
},
create(context) {
const options = context.options[0] || {};
const cwd = context.cwd;
const srcRoot = path.resolve(cwd, options.root || "src");
const alias = options.alias || { $lib: "lib" };
const oldPatterns = options.old || DEFAULT_OLD;
const newPatterns = options.new || DEFAULT_NEW;
/** Classify a path (already absolute or portable rel to srcRoot). */
function classify(absOrRel, relativeToRoot) {
const rel = relativeToRoot
? absOrRel
: toPortable(path.relative(srcRoot, absOrRel));
// new wins over old if a path is matched by both (e.g. components/kit
// is != lib/components/** via negation, but keep precedence safe).
if (matchesGlobList(newPatterns, rel)) return "new";
if (matchesGlobList(oldPatterns, rel)) return "old";
return "shared";
}
/** Resolve a specifier to a portable rel path from srcRoot, or null. */
function resolveToRel(spec, currentDir) {
if (spec.startsWith("$")) {
const dot = spec.indexOf("/");
const mapKey = spec.slice(0, dot === -1 ? spec.length : dot);
const dir = alias[mapKey];
if (!dir) return null;
const rest = dot === -1 ? "" : spec.slice(dot + 1);
const base = path.resolve(srcRoot, dir, rest);
const found = firstExisting(base);
if (!found) return null;
return toPortable(path.relative(srcRoot, found));
}
if (spec.startsWith("./") || spec.startsWith("../")) {
const base = path.resolve(currentDir, spec);
const found = firstExisting(base);
if (!found) return null;
const rel = path.relative(srcRoot, found);
if (rel.startsWith("..")) return null; // outside srcRoot
return toPortable(rel);
}
return null; // package import (svelte, vitest, @lucide, ...)
}
const filename = context.filename || context.physicalFilename;
const currentAbs = path.resolve(filename);
const currentRel = toPortable(path.relative(srcRoot, currentAbs));
const currentSide = classify(currentRel, true);
const currentDir = path.dirname(currentAbs);
if (currentSide === "shared") return {};
function checkSource(node) {
const spec = node.source?.value;
if (typeof spec !== "string") return;
const targetRel = resolveToRel(spec, currentDir);
if (!targetRel) return;
const targetSide = classify(targetRel, true);
if (
(currentSide === "old" && targetSide === "new") ||
(currentSide === "new" && targetSide === "old")
) {
context.report({
node,
messageId: "noMixed",
data: { side: currentSide, target: targetSide, targetRel },
});
}
}
return {
ImportDeclaration: checkSource,
ImportExpression: checkSource,
ExportNamedDeclaration: checkSource,
ExportAllDeclaration: checkSource,
};
},
};
+1 -36
View File
@@ -5,16 +5,11 @@ import globals from "globals";
import svelteParser from "svelte-eslint-parser"; import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import designTokens from "./eslint-plugins/index.js"; import designTokens from "./eslint-plugins/index.js";
import isolationPlugin from "./eslint-plugins/isolation/index.js";
import conventionsPlugin from "./eslint-plugins/conventions/index.js"; import conventionsPlugin from "./eslint-plugins/conventions/index.js";
import i18nPlugin from "./eslint-plugins/i18n/index.js"; import i18nPlugin from "./eslint-plugins/i18n/index.js";
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас. // Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
// Когда старый дизайн удалим (C19), этот scoped-блок убирается и recommended
// включается на весь код (см. план-redesign §10, шаг 6).
const newCode = ["**/src/lib/components/**", "**/src/routes/**"]; const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
const oldCode = ["**/src/lib/v1/**", "**/src/routes/v1/**"];
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier). // Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
const jsRecommended = Array.isArray(js.configs.recommended) const jsRecommended = Array.isArray(js.configs.recommended)
@@ -29,10 +24,6 @@ const newSvelteFiles = [
"**/src/lib/components/**/*.svelte", "**/src/lib/components/**/*.svelte",
"**/src/routes/**/*.svelte", "**/src/routes/**/*.svelte",
]; ];
const oldSvelteFiles = [
"**/src/lib/v1/**/*.svelte",
"**/src/routes/v1/**/*.svelte",
];
// FIXME: // FIXME:
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts // The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
@@ -82,21 +73,17 @@ export default tseslint.config(
"eslint-plugins/__tests__/interface-props.test.ts", "eslint-plugins/__tests__/interface-props.test.ts",
"eslint-plugins/__tests__/no-string-union-alias.test.ts", "eslint-plugins/__tests__/no-string-union-alias.test.ts",
"eslint-plugins/__tests__/helpers.ts", "eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
"eslint-plugins/__tests__/dict-consistency.test.ts", "eslint-plugins/__tests__/dict-consistency.test.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts", "eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts", "eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts", "eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/de.ts", "eslint-plugins/__fixtures__/src/lib/i18n/de.ts",
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
"eslint-plugins/__fixtures__/src/lib/core/errors.ts", "eslint-plugins/__fixtures__/src/lib/core/errors.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/t.ts", "eslint-plugins/__fixtures__/src/lib/i18n/t.ts",
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts", "eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
"eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte", "eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte",
"eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte", "eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte",
"eslint-plugins/__fixtures__/src/routes/+page.svelte", "eslint-plugins/__fixtures__/src/routes/+page.svelte",
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
], ],
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40, maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
}, },
@@ -134,11 +121,9 @@ export default tseslint.config(
}, },
}, },
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках. // Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
// Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн // Применяется к новому коду редизайна.
// удалят, расширить glob на весь код, исключив (old)/.
{ {
files: newSvelteFiles, files: newSvelteFiles,
ignores: oldSvelteFiles,
plugins: { plugins: {
"design-tokens": designTokens, "design-tokens": designTokens,
}, },
@@ -151,10 +136,8 @@ export default tseslint.config(
}, },
// Конвенция Svelte 5: пропсы через локальный `interface Props` + // Конвенция Svelte 5: пропсы через локальный `interface Props` +
// `let {...}: Props = $props()` (плагин conventions/interface-props). // `let {...}: Props = $props()` (плагин conventions/interface-props).
// Только Svelte-файлы нового кода (см. newSvelteFiles).
{ {
files: newSvelteFiles, files: newSvelteFiles,
ignores: oldSvelteFiles,
plugins: { plugins: {
conventions: conventionsPlugin, conventions: conventionsPlugin,
}, },
@@ -166,7 +149,6 @@ export default tseslint.config(
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода. // (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
{ {
files: newCode, files: newCode,
ignores: oldCode,
plugins: { plugins: {
conventions: conventionsPlugin, conventions: conventionsPlugin,
}, },
@@ -198,17 +180,14 @@ export default tseslint.config(
...jsRecommended.map((cfg) => ({ ...jsRecommended.map((cfg) => ({
...cfg, ...cfg,
files: newCode, files: newCode,
ignores: oldCode,
})), })),
...tseslint.configs.recommended.map((cfg) => ({ ...tseslint.configs.recommended.map((cfg) => ({
...cfg, ...cfg,
files: newCode, files: newCode,
ignores: oldCode,
})), })),
...svelteRecommended.map((cfg) => ({ ...svelteRecommended.map((cfg) => ({
...cfg, ...cfg,
files: newSvelteFiles, files: newSvelteFiles,
ignores: oldSvelteFiles,
})), })),
], ],
@@ -220,20 +199,6 @@ export default tseslint.config(
"prefer-const": "off", "prefer-const": "off",
}, },
}, },
// ===== Изоляция старого UI (old) и нового preview =====================
// Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5).
// Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному
// пути (и $lib, и относительные) и ругается на old→new / new→old.
// Общее (core/, theme) разрешено обоим.
{
files: ["**/*.{ts,svelte}"],
plugins: {
isolation: isolationPlugin,
},
rules: {
"isolation/no-mixed-imports": "error",
},
},
// prettier — последним, чтобы гасить форматирующие правила из recommended. // prettier — последним, чтобы гасить форматирующие правила из recommended.
prettier, prettier,
); );
-283
View File
@@ -1,283 +0,0 @@
@layer app {
:root {
color-scheme: light;
--bg: #f6f7f9;
--surface: #ffffff;
--text: #1b1e24;
--text-muted: #5c6470;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-contrast: #ffffff;
--link: var(--accent);
--border: #dfe2e8;
--danger: #e5484d;
--danger-strong: #b3261e;
--check-a: #eceff3;
--check-b: #ffffff;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--radius-s: 6px;
--radius-m: 10px;
--text-xs: 0.8rem;
--text-s: 0.85rem;
--text-m: 0.95rem;
--text-l: 1.1rem;
--text-xl: 1.25rem;
--text-xxl: 1.75rem;
--leading-tight: 1.25;
--shadow-card: 0 4px 14px rgb(37 99 235 / 12%);
--transition-fast: 120ms ease;
--control-height: 2.4rem;
--control-max-width: 16rem;
--font-sans:
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
--font-mono: ui-monospace, "Cascadia Code", Consolas, monospace;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #12151a;
--surface: #1b2027;
--text: #e6e9ee;
--text-muted: #98a1ad;
--accent-hover: #4c85f0;
--link: #6ea0ff;
--border: #303842;
--danger: #ff7479;
--danger-strong: #ff8589;
--check-a: #232830;
--check-b: #171b21;
--shadow-card: 0 4px 14px rgb(0 0 0 / 45%);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-family: var(--font-sans);
color: var(--text);
background: var(--bg);
line-height: 1.55;
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100dvh;
}
h1 {
font-size: var(--text-xxl);
line-height: var(--leading-tight);
letter-spacing: -0.01em;
}
h2 {
font-size: var(--text-xl);
line-height: var(--leading-tight);
}
p,
label {
font-size: var(--text-m);
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button,
input,
select {
font: inherit;
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-s);
}
.text-muted {
color: var(--text-muted);
}
.text-caption {
font-size: var(--text-s);
}
.heading-section {
font-size: var(--text-l);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-m);
}
.control {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
max-width: var(--control-max-width);
}
input.control[type="number"] {
font-family: var(--font-mono);
width: 100%;
}
.error-banner {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--danger);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
color: var(--danger-strong);
font-size: var(--text-s);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.tool-stage {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
align-items: start;
width: 100%;
}
.tool-stage.single {
grid-template-columns: 1fr;
}
.tool-stage .cell {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tool-stage .cell + .cell {
border-left: 1px solid var(--border);
padding-left: var(--space-4);
}
.cell-media {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 14rem;
position: relative;
width: 100%;
}
.chain-stack {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-4);
}
.edge-legend {
position: absolute;
top: -0.65em;
padding: 0 var(--space-2);
background: var(--surface);
color: var(--text-muted);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.3;
white-space: nowrap;
z-index: 1;
}
.params-sep {
position: relative;
border-top: 1px solid var(--border);
margin: 0 var(--space-4);
padding-bottom: var(--space-3);
}
/* Панель этапа для инлайн-раскладки: исходник | параметры | результат */
.pane {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
}
.pane-legend {
top: -0.65em;
left: 50%;
transform: translateX(-50%);
}
.actions-row {
display: flex;
gap: var(--space-1);
align-items: stretch;
width: 100%;
}
.actions-row > button {
flex: 1;
min-width: 0;
}
@media (max-width: 48rem) {
.tool-stage {
grid-template-columns: 1fr;
}
.tool-stage .cell + .cell {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
}
}
-3
View File
@@ -3,9 +3,6 @@
* *
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге * Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться. * выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
* Этот файл — локальная копия подхода для нового UI; старый
* `lib/v1/categories` (массив + union вручную) не трогаем — он обслуживает
* старый UI в `/v1/*`.
* *
* Человекочитаемые названия живут в словарях i18n: секция categories, * Человекочитаемые названия живут в словарях i18n: секция categories,
* ключ = CategoryId. * ключ = CategoryId.
+1 -5
View File
@@ -1,8 +1,4 @@
// Новая типизированная схема параметров (для нового UI, старый UI не трогаем). // Типизированная схема параметров для нового UI.
//
// Параллельный источник правды к старому `ParamDef[]`: старый pipeline
// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/
// валидацию поверх `ToolSchema<P>`.
// //
// Связка типов на этапе компиляции (защита от рассинхрона): // Связка типов на этапе компиляции (защита от рассинхрона):
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает // - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
-24
View File
@@ -1,24 +0,0 @@
export type CategoryId =
| "convert"
| "alpha"
| "color"
| "geometry"
| "analyze"
| "generate"
| "filters"
| "text";
/**
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
* i18n: секция categories, ключ = CategoryId.
*/
export const CATEGORIES: readonly CategoryId[] = [
"convert",
"alpha",
"color",
"geometry",
"filters",
"text",
"analyze",
"generate",
];
@@ -1,50 +0,0 @@
<script lang="ts">
import { downloadBlob, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/v1/i18n/t";
import type { OutputFormat } from "$lib/v1/registry";
import Button from "./ui/Button.svelte";
interface Props {
image: PixelImage | null;
format?: OutputFormat;
baseName: string;
params: Record<string, unknown>;
onError?: (e: unknown) => void;
}
let { image, format, baseName, params, onError }: Props = $props();
let busy = $state(false);
async function download() {
if (!image || !format || busy) return;
busy = true;
try {
const raw = format.qualityParamId
? params[format.qualityParamId]
: undefined;
const quality =
typeof raw === "number"
? Math.min(Math.max(raw, 1), 100) / 100
: undefined;
const blob = await encode(image, format.mime, quality);
downloadBlob(blob, `${baseName}.${format.ext}`);
} catch (e) {
onError?.(e);
} finally {
busy = false;
}
}
</script>
<Button
variant="primary"
fullWidth
disabled={!image || !format}
{busy}
busyText={t("download.busy")}
onclick={download}
>
{t("download.file", { ext: format?.ext ?? "png" })}
</Button>
@@ -1,97 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { isSupportedImage, unsupportedImageError } from "$lib/core/io";
import { t } from "$lib/v1/i18n/t";
interface Props {
onFile: (file: File) => void;
onError?: (e: unknown) => void;
label?: string;
children: Snippet;
}
let {
onFile,
onError,
label = t("dropZone.overlayDefault"),
children,
}: Props = $props();
let depth = $state(0);
const dragging = $derived(depth > 0);
function enter() {
depth += 1;
}
function leave() {
depth = Math.max(0, depth - 1);
}
function over(event: DragEvent) {
event.preventDefault();
}
function end() {
depth = 0;
}
function drop(event: DragEvent) {
event.preventDefault();
depth = 0;
const file = event.dataTransfer?.files[0];
if (!file) return;
if (!isSupportedImage(file)) {
onError?.(unsupportedImageError(file));
return;
}
onFile(file);
}
</script>
<div
class="area"
class:dragging
role="region"
aria-label={label}
ondragenter={enter}
ondragleave={leave}
ondragover={over}
ondragend={end}
ondrop={drop}
>
{@render children()}
{#if dragging}
<div class="veil" aria-hidden="true">
<p>{label}</p>
</div>
{/if}
</div>
<style>
.area {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.veil {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
border: 2px dashed var(--accent);
border-radius: var(--radius-m);
color: var(--link);
font-weight: 600;
text-align: center;
pointer-events: none;
}
</style>
-120
View File
@@ -1,120 +0,0 @@
<script lang="ts">
import {
ACCEPTED_IMAGE_TYPES,
isSupportedImage,
unsupportedImageError,
} from "$lib/core/io";
import { t } from "$lib/v1/i18n/t";
interface Props {
onFile: (file: File) => void;
onError?: (e: unknown) => void;
label?: string;
}
let { onFile, onError, label = t("dropZone.pickDefault") }: Props = $props();
let input = $state<HTMLInputElement | undefined>();
let depth = $state(0);
const dragging = $derived(depth > 0);
function enter() {
depth += 1;
}
function leave() {
depth = Math.max(0, depth - 1);
}
function over(event: DragEvent) {
event.preventDefault();
}
function end() {
depth = 0;
}
function accept(file: File | undefined | null) {
if (!file) return;
if (!isSupportedImage(file)) {
onError?.(unsupportedImageError(file));
return;
}
onFile(file);
}
function openPicker() {
input?.click();
}
</script>
<div
class="dropzone panel"
class:dragging
role="button"
tabindex="0"
aria-label={label}
onclick={openPicker}
onkeydown={(e) => {
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]);
}}
>
<span aria-hidden="true" class="icon">⬇</span>
{label}
</div>
<input
bind:this={input}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
hidden
onchange={() => {
accept(input?.files?.[0]);
if (input) {
input.value = "";
}
}}
/>
<style>
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 14rem;
padding: var(--space-4);
border-style: dashed;
color: var(--text-muted);
text-align: center;
cursor: pointer;
user-select: none;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
}
.dropzone:hover,
.dropzone:focus-visible,
.dragging {
border-color: var(--link);
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
}
.icon {
font-size: 1.8rem;
line-height: 1;
}
</style>
@@ -1,55 +0,0 @@
<script lang="ts">
import type { ImageInfo } from "$lib/core/analyze";
import { LOCALE_TAGS } from "$lib/v1/i18n/dict";
import { getLocale } from "$lib/v1/i18n/locale.svelte";
import { t } from "$lib/v1/i18n/t";
interface Props {
info: ImageInfo | null;
}
let { info }: Props = $props();
</script>
{#if info}
<dl>
<div class="panel">
<dt>{t("infoPanel.dimensions")}</dt>
<dd>{info.width} × {info.height} px</dd>
</div>
<div class="panel">
<dt>{t("infoPanel.alpha")}</dt>
<dd>
{info.hasAlpha ? t("infoPanel.alphaYes") : t("infoPanel.alphaNo")}
</dd>
</div>
<div class="panel">
<dt>{t("infoPanel.colorCount")}</dt>
<dd>{info.colorCount.toLocaleString(LOCALE_TAGS[getLocale()])}</dd>
</div>
</dl>
{/if}
<style>
dl {
display: grid;
gap: var(--space-2);
margin: var(--space-3) 0;
}
dl > div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
}
dt {
color: var(--text-muted);
}
dd {
margin: 0;
font-family: var(--font-mono);
}
</style>
-117
View File
@@ -1,117 +0,0 @@
<script lang="ts">
import { t } from "$lib/v1/i18n/t";
import { optionLabel, paramLabel } from "$lib/v1/i18n/tool-strings";
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
import CheckboxField from "./ui/CheckboxField.svelte";
import ColorField from "./ui/ColorField.svelte";
import SelectField from "./ui/SelectField.svelte";
import SliderField from "./ui/SliderField.svelte";
import TextField from "./ui/TextField.svelte";
interface Props {
tool: ToolEntry;
params: ParamDef[];
values: Record<string, any>;
pipetteTargetId?: string | null;
onPipetteToggle?: (id: string) => void;
hasMask?: boolean;
showMask?: boolean;
}
let {
tool,
params,
values = $bindable(),
pipetteTargetId = null,
onPipetteToggle,
hasMask = false,
showMask = $bindable(false),
}: Props = $props();
</script>
<div class="params-grid">
{#if hasMask}
<CheckboxField
id="show-mask"
label={t("ui.showMask")}
bind:checked={showMask}
/>
{/if}
{#each params as param (param.id)}
<div class="field">
{#if param.type === "checkbox"}
<CheckboxField
id={param.id}
label={paramLabel(tool, param)}
bind:checked={values[param.id]}
/>
{:else if param.type === "number"}
<TextField
id={param.id}
label={paramLabel(tool, param)}
type="number"
min={param.min}
max={param.max}
step={param.step}
bind:value={values[param.id]}
/>
{:else if param.type === "slider"}
<SliderField
id={param.id}
label={paramLabel(tool, param)}
min={param.min}
max={param.max}
step={param.step}
default={param.default}
bind:value={values[param.id]}
/>
{:else if param.type === "select"}
<SelectField
id={param.id}
label={paramLabel(tool, param)}
options={param.options.map((o) => ({
value: o.value,
label: optionLabel(tool, param, o.value),
}))}
bind:value={values[param.id]}
/>
{:else if param.type === "color"}
<ColorField
id={param.id}
label={paramLabel(tool, param)}
bind:value={values[param.id]}
pipetteActive={pipetteTargetId === param.id}
onPipetteToggle={() => onPipetteToggle?.(param.id)}
/>
{:else if param.type === "text"}
<TextField
id={param.id}
label={paramLabel(tool, param)}
type="text"
placeholder={param.placeholder}
bind:value={values[param.id]}
/>
{/if}
</div>
{/each}
</div>
<style>
.params-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--space-5);
}
@media (min-width: 75rem) {
.params-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 50rem) {
.params-grid {
grid-template-columns: 1fr;
}
}
</style>
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
import { rgbToHex } from "$lib/core/color";
import type { PixelImage } from "$lib/core/types";
import PipetteLoupe from "./tool/PipetteLoupe.svelte";
interface Props {
image: PixelImage | null;
pipetteActive?: boolean;
onPickColor?: (hex: string) => void;
}
let { image, pipetteActive = false, onPickColor }: Props = $props();
let canvas = $state<HTMLCanvasElement | undefined>();
let hover = $state<{
clientX: number;
clientY: number;
px: number;
py: number;
hex: string;
} | null>(null);
$effect(() => {
if (!canvas || !image) return;
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.putImageData(
new ImageData(image.data, image.width, image.height),
0,
0,
);
});
function pixelAt(
event: MouseEvent,
): { px: number; py: number; hex: string } | null {
if (!canvas || !image) return null;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const px = Math.floor(
(event.clientX - rect.left) * (canvas.width / rect.width),
);
const py = Math.floor(
(event.clientY - rect.top) * (canvas.height / rect.height),
);
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height)
return null;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
return { px, py, hex: rgbToHex(r, g, b) };
}
function pickColor(event: MouseEvent) {
if (!pipetteActive || !onPickColor) return;
const pixel = pixelAt(event);
if (pixel) {
onPickColor(pixel.hex);
}
}
function trackHover(event: MouseEvent) {
if (!pipetteActive) {
hover = null;
return;
}
const pixel = pixelAt(event);
hover = pixel
? {
clientX: event.clientX,
clientY: event.clientY,
px: pixel.px,
py: pixel.py,
hex: pixel.hex,
}
: null;
}
function clearHover() {
hover = null;
}
</script>
{#if image}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<canvas
bind:this={canvas}
title="{image.width} × {image.height}"
class:pipette={pipetteActive}
onclick={pickColor}
onmousemove={trackHover}
onmouseleave={clearHover}
></canvas>
<p class="dims">{image.width} × {image.height} px</p>
{#if pipetteActive && hover && image}
<PipetteLoupe
{image}
px={hover.px}
py={hover.py}
hex={hover.hex}
clientX={hover.clientX}
clientY={hover.clientY}
/>
{/if}
{/if}
<style>
canvas {
display: block;
max-width: 100%;
max-height: 32rem;
background: repeating-conic-gradient(
var(--check-a) 0% 25%,
var(--check-b) 0% 50%
);
background-size: 16px 16px;
border: 1px solid var(--border);
}
canvas.pipette {
cursor: crosshair;
}
.dims {
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--text-s);
}
</style>
-543
View File
@@ -1,543 +0,0 @@
<script lang="ts">
import { imageInfo, type ImageInfo } from "$lib/core/analyze";
import { ToolError } from "$lib/core/errors";
import {
decodeFile,
isSupportedImage,
unsupportedImageError,
} from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/v1/i18n/t";
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
import {
defaultParams,
getTool,
sanitizeParams,
type ToolEntry,
} from "$lib/v1/registry";
import { createAutoRunner } from "$lib/v1/tools/auto-run";
import { executeStep } from "$lib/v1/tools/executor";
import { clearOverlay, setOverlay } from "$lib/v1/tools/overlay-store.svelte";
import {
loadStoredSteps,
newStepId,
saveSteps,
type PipelineStep,
} from "$lib/v1/tools/pipeline";
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
import ChainToolBlock from "./chain/ChainToolBlock.svelte";
import ToolSearch from "./search/ToolSearch.svelte";
import type { StageStatus } from "./stage/stage-props";
import ToolStage from "./stage/ToolStage.svelte";
import ToolStageClassic from "./stage/ToolStageClassic.svelte";
export type PresetStep = { toolId: string; values?: Record<string, unknown> };
let {
tool,
restoreChain = false,
stageVariant = "inline",
presetBaseValues,
presetChain,
}: {
tool: ToolEntry;
restoreChain?: boolean;
stageVariant?: "classic" | "inline";
presetBaseValues?: Record<string, unknown>;
presetChain?: PresetStep[];
} = $props();
const StageComponent = $derived(
stageVariant === "classic" ? ToolStageClassic : ToolStage,
);
type Status = StageStatus;
const isPreset = $derived(
(presetChain?.length ?? 0) > 0 ||
Object.keys(presetBaseValues ?? {}).length > 0,
);
let status = $state<Status>("idle");
let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null);
let previewResult = $state<PixelImage | null>(null);
let textResult = $state<string | null>(null);
let showMask = $state(false);
let info = $state<ImageInfo | null>(null);
let errorText = $state("");
let overlayImage = $state<PixelImage | null>(null);
// svelte-ignore state_referenced_locally
let values = $state<Record<string, any>>({
...defaultParams(tool),
...presetBaseValues,
});
// svelte-ignore state_referenced_locally
let chain = $state<PipelineStep[]>(
restoreChain
? loadStoredSteps()
: (presetChain ?? []).flatMap((preset) => {
const stepTool = getTool(preset.toolId);
return stepTool
? [
{
id: newStepId(),
toolId: preset.toolId,
values: { ...defaultParams(stepTool), ...preset.values },
},
]
: [];
}),
);
let chainResults = $state<(PixelImage | null)[]>([]);
let lastRunChainJson = "";
const isInfo = $derived(tool.resultType === "info");
const isSourceless = $derived(tool.sourceMode === "none");
const isTextSource = $derived(tool.sourceMode === "text");
const sanitized = $derived(sanitizeParams(tool, values));
const canChainBase = $derived(
(tool.resultType ?? "image") === "image" && tool.sourceMode !== "text",
);
const hasMask = $derived(typeof tool.preview === "function");
const shownBase = $derived(
showMask && previewResult ? previewResult : result,
);
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ""));
function addChainStep() {
chain.push({ id: newStepId(), toolId: "", values: {} });
}
function removeChainStep(index: number) {
chain.splice(index, 1);
chainResults = [];
}
function removeChain() {
chain.length = 0;
chainResults = [];
}
function toggleChain() {
if (chain.length > 0) {
removeChain();
} else {
addChainStep();
}
}
function applyChainTool(index: number, toolId: string) {
const stepTool = getTool(toolId);
if (!stepTool || !chain[index]) return;
chain[index].toolId = toolId;
chain[index].values = defaultParams(stepTool);
}
const runner = createAutoRunner();
let hasLastRun = false;
let lastRunSource: PixelImage | null = null;
let lastRunValuesJson = "";
let pipetteTargetId = $state<string | null>(null);
function handlePipetteToggle(id: string) {
pipetteTargetId = pipetteTargetId === id ? null : id;
}
function handlePickColor(hex: string) {
if (!pipetteTargetId) return;
values[pipetteTargetId] = hex;
pipetteTargetId = null;
}
async function handleFile(file: File) {
errorText = "";
status = "processing";
try {
source = await decodeFile(file);
values = defaultParams(tool);
result = null;
previewResult = null;
showMask = false;
pipetteTargetId = null;
info = isInfo ? imageInfo(source) : null;
if (isInfo) {
status = "loaded";
} else {
await runTool();
}
} catch (e) {
showError(e);
}
}
async function handleTextSubmit(text: string) {
if (!isTextSource) return;
errorText = "";
status = "processing";
if (tool.textToText) {
try {
result = null;
previewResult = null;
info = null;
textResult = await tool.textToText(text);
status = "loaded";
} catch (e) {
showError(e);
}
return;
}
if (!tool.runFromText) return;
try {
source = await tool.runFromText(text, defaultParams(tool));
values = defaultParams(tool);
result = null;
textResult = null;
previewResult = null;
showMask = false;
pipetteTargetId = null;
info = null;
await runTool();
} catch (e) {
showError(e);
}
}
async function runTool() {
if (isInfo) return;
if (!source && !isSourceless) return;
const token = runner.next();
hasLastRun = true;
lastRunSource = source;
lastRunValuesJson = JSON.stringify(sanitized);
lastRunChainJson = JSON.stringify(chain);
try {
let next: PixelImage | null = null;
let nextText: string | null = null;
if (tool.resultType === "text") {
nextText = await tool.toText!(source!, sanitized);
} else if (isSourceless) {
next = await tool.generate!(sanitized);
} else {
next = await executeStep(tool, source!, sanitized);
}
let nextPreview: PixelImage | null = null;
if (tool.preview && source) {
try {
nextPreview = await tool.preview(source, sanitized);
} catch {
nextPreview = null;
}
}
if (!runner.isCurrent(token)) return;
result = next;
previewResult = nextPreview;
textResult = nextText;
const collected: (PixelImage | null)[] = [];
let current: PixelImage | null = next ?? source;
for (let i = 0; i < chain.length; i++) {
const step = chain[i];
const stepTool = step.toolId === "" ? undefined : getTool(step.toolId);
if (!current || !stepTool?.run) {
collected.push(null);
continue;
}
try {
current = await executeStep(
stepTool,
current,
sanitizeParams(stepTool, step.values),
);
} catch (e) {
throw new Error(
t("toolPage.stepError", {
n: i + 2,
title: toolTitle(stepTool),
msg: errorMessage(e),
}),
);
}
if (!runner.isCurrent(token)) return;
collected.push(current);
}
chainResults = collected;
status = "loaded";
} catch (e) {
if (!runner.isCurrent(token)) return;
showError(e);
}
}
$effect(() => {
const valuesJson = JSON.stringify(sanitized);
const chainJson = JSON.stringify(chain);
if (
hasLastRun &&
source === lastRunSource &&
valuesJson === lastRunValuesJson &&
chainJson === lastRunChainJson
) {
return;
}
if (!source && !isSourceless) return;
if (isInfo) return;
return runner.schedule(() => void runTool());
});
$effect(() => {
if (isPreset) return;
const filled = chain.filter((step) => step.toolId !== "");
if (filled.length === 0 && !hasLastRun) return;
saveSteps(filled);
});
function errorMessage(e: unknown): string {
if (e instanceof ToolError) return t(e.key, e.vars);
return t(e instanceof Error ? e.message : String(e));
}
function showError(e: unknown) {
status = source ? "loaded" : "idle";
errorText = errorMessage(e);
}
function reset() {
source = null;
result = null;
previewResult = null;
textResult = null;
showMask = false;
pipetteTargetId = null;
info = null;
errorText = "";
status = "idle";
clearOverlay();
values = { ...defaultParams(tool), ...presetBaseValues };
}
async function handleOverlayFile(file: File) {
try {
setOverlay(await decodeFile(file));
} catch (e) {
errorText = errorMessage(e);
}
}
function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (!item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) {
event.preventDefault();
if (!isSupportedImage(file)) {
errorText = errorMessage(unsupportedImageError(file));
return;
}
handleFile(file);
}
return;
}
}
</script>
<svelte:window onpaste={handlePaste} />
<section>
<h1>{toolTitle(tool)}</h1>
<p class="description text-muted">{toolDescription(tool)}</p>
{#if errorText}
<div class="error-banner" role="alert">{errorText}</div>
{/if}
<StageComponent
mode="base"
{tool}
{source}
{result}
displayImage={shownBase}
{info}
{status}
{isInfo}
{isSourceless}
{isTextSource}
{textResult}
{sanitized}
{canChainBase}
hasChain={chain.length > 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}
/>
<div class="chain-stack">
{#each chain as step, index (step.id)}
{#if step.toolId === ""}
<div class="panel empty-slot">
<header>
<h3 class="heading-section">
{t("toolPage.stepHeading", { n: index + 2 })}
</h3>
<button
type="button"
class="remove-step"
aria-label={t("toolPage.removeStepAria")}
onclick={() => removeChainStep(index)}
>
✕
</button>
</header>
<ToolSearch
onSelect={(id) => applyChainTool(index, id)}
chainableOnly
/>
</div>
{:else if getTool(step.toolId)}
{@const stepTool = getTool(step.toolId)!}
{#if stageVariant === "inline"}
{@const StepIcon = TOOL_ICONS[stepTool.id]}
<ToolStage
mode="chain"
{index}
tool={stepTool}
bind:values={step.values}
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
result={chainResults[index] ?? null}
busy={status === "processing"}
isLast={index === chain.length - 1}
onRemove={() => removeChainStep(index)}
onError={showError}
onAddStep={addChainStep}
onRemoveChain={removeChain}
>
{#snippet header()}
<span class="edge-legend step-legend">
{#if StepIcon}
<span class="step-icon" aria-hidden="true">
<StepIcon size={14} strokeWidth={2} />
</span>
{/if}
{t("chain.stepLabel", {
n: index + 2,
title: toolTitle(stepTool),
})}
<button
type="button"
class="remove-step"
aria-label={t("toolPage.removeStepAria")}
title={t("toolPage.removeStepAria")}
onclick={() => removeChainStep(index)}
>
✕
</button>
</span>
{/snippet}
</ToolStage>
{:else}
<ChainToolBlock
{index}
tool={stepTool}
bind:values={step.values}
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
result={chainResults[index] ?? null}
busy={status === "processing"}
isLast={index === chain.length - 1}
onRemove={() => removeChainStep(index)}
onError={showError}
onAddStep={addChainStep}
onRemoveChain={removeChain}
/>
{/if}
{/if}
{/each}
</div>
</section>
<style>
h1 {
margin-bottom: var(--space-1);
}
.description {
max-width: 48rem;
margin-bottom: var(--space-4);
}
.error-banner {
margin-bottom: var(--space-3);
}
.step-legend {
left: var(--space-3);
top: -0.75em;
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-weight: 600;
z-index: 2;
}
.step-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
color: var(--link);
}
.empty-slot {
padding: var(--space-3);
}
.empty-slot header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.empty-slot h3 {
margin: 0;
}
.remove-step {
width: 1.6rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
line-height: 1;
cursor: pointer;
}
.remove-step:hover {
border-color: var(--danger);
color: var(--danger);
}
</style>
@@ -1,193 +0,0 @@
<script lang="ts">
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/v1/i18n/t";
import { toolTitle } from "$lib/v1/i18n/tool-strings";
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/v1/registry";
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
import DownloadButton from "../DownloadButton.svelte";
import Preview from "../Preview.svelte";
import ParamsCard from "../tool/ParamsCard.svelte";
import Button from "../ui/Button.svelte";
import EmptyState from "../ui/EmptyState.svelte";
interface Props {
index: number;
tool: ToolEntry;
values: Record<string, any>;
input: PixelImage | null;
result: PixelImage | null;
busy: boolean;
isLast: boolean;
onRemove: () => void;
onError: (e: unknown) => void;
onAddStep: () => void;
onRemoveChain: () => void;
}
let {
index,
tool,
values = $bindable(),
input,
result,
busy,
isLast,
onRemove,
onError,
onAddStep,
onRemoveChain,
}: Props = $props();
const format = $derived(outputOf(tool));
const safeParams = $derived(sanitizeParams(tool, values));
const StepIcon = $derived(TOOL_ICONS[tool.id]);
</script>
<div class="block">
<div class="panel tool-stage">
<span class="edge-legend step-legend">
{#if StepIcon}
<span class="step-icon" aria-hidden="true"
><StepIcon size={14} strokeWidth={2} /></span
>
{/if}
{t("chain.stepLabel", { n: index + 2, title: toolTitle(tool) })}
<button
type="button"
class="remove"
aria-label={t("chain.removeStepAria")}
title={t("chain.removeStepAria")}
onclick={onRemove}
>
✕
</button>
</span>
<div class="cell">
<span class="edge-legend cell-legend" aria-hidden="true"
>{t("chain.inputLegend")}</span
>
<div class="cell-media">
<Preview image={input} />
</div>
</div>
<div class="cell">
<span class="edge-legend cell-legend" aria-hidden="true"
>{t("chain.resultLegend")}</span
>
{#if busy && !result}
<div class="cell-media">
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
</div>
{:else}
<div class="cell-media">
<Preview image={result} />
{#if busy}
<span class="recalc" aria-live="polite"
>{t("resultCard.recalc")}</span
>
{/if}
</div>
<div class="actions-row">
<DownloadButton
image={result}
{format}
baseName="{index + 2}-{tool.id}"
params={safeParams}
{onError}
/>
<Button
variant="secondary"
fullWidth
onclick={isLast ? onAddStep : onRemoveChain}
>
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
</Button>
</div>
{/if}
</div>
</div>
{#if tool.params.length > 0}
<div class="params-sep">
<span class="edge-legend" aria-hidden="true"
>{t("chain.paramsLegend")}</span
>
</div>
<ParamsCard {tool} params={tool.params} bind:values />
{/if}
</div>
<style>
.block {
display: flex;
flex-direction: column;
}
.tool-stage {
position: relative;
padding-top: var(--space-3);
border-top: 0;
}
.step-legend {
left: var(--space-3);
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-weight: 600;
}
.step-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
color: var(--link);
}
.remove {
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
line-height: 1;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
.remove:hover {
border-color: var(--danger);
color: var(--danger);
}
.cell {
position: relative;
}
.cell-legend {
top: -0.9em;
left: 50%;
transform: translateX(-50%);
}
.recalc {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
color: var(--link);
font-size: var(--text-xs);
}
</style>
@@ -1,109 +0,0 @@
<script lang="ts">
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
interface Props {
toolId: string;
title: string;
description: string;
selected?: boolean;
href?: string;
onActivate?: () => void;
onHover?: () => void;
}
let {
toolId,
title,
description,
selected = false,
href,
onActivate,
onHover,
}: Props = $props();
const Icon = $derived(TOOL_ICONS[toolId]);
</script>
{#snippet content()}
<span class="icon-box" aria-hidden="true">
{#if Icon}<Icon size={24} strokeWidth={1.75} />{/if}
</span>
<span class="text">
<span class="title">{title}</span>
<span class="hint text-caption text-muted">{description}</span>
</span>
{/snippet}
{#if href}
<a {href} class="card" class:selected onmousemove={onHover}>
{@render content()}
</a>
{:else}
<div
class="card"
class:selected
role="option"
aria-selected={selected}
tabindex="-1"
onclick={onActivate}
onmousemove={onHover}
onkeydown={(e) => {
if (e.key === "Enter") onActivate?.();
}}
>
{@render content()}
</div>
{/if}
<style>
.card {
display: flex;
align-items: flex-start;
gap: var(--space-2);
min-width: 0;
padding: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-s);
cursor: pointer;
color: inherit;
text-decoration: none;
}
.card:hover,
.card.selected {
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
.icon-box {
flex: none;
width: 2.6rem;
height: 2.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
color: var(--link);
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-weight: 600;
font-size: var(--text-m);
}
.hint {
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -1,176 +0,0 @@
<script lang="ts">
import { LOCALE_TAGS } from "$lib/v1/i18n/dict";
import { getLocale } from "$lib/v1/i18n/locale.svelte";
import { normalizeForSearch, scoreDoc } from "$lib/v1/i18n/matching";
import { t } from "$lib/v1/i18n/t";
import {
toolDescription,
toolSearchDoc,
toolTitle,
} from "$lib/v1/i18n/tool-strings";
import { isChainable, TOOLS } from "$lib/v1/registry";
import ToolCard from "./ToolCard.svelte";
interface Props {
onSelect: (toolId: string) => void;
/** true — только инструменты, пригодные в шаг цепочки (слот добавления шага). */
chainableOnly?: boolean;
}
let { onSelect, chainableOnly = false }: Props = $props();
const candidates = $derived(
chainableOnly ? TOOLS.filter(isChainable) : TOOLS,
);
let query = $state("");
let activeIndex = $state(0);
let listOpen = $state(false);
type Match = {
id: string;
title: string;
description: string;
popularity: number;
score: number;
};
const matches = $derived.by(() => {
const q = normalizeForSearch(query.trim());
const found: Match[] = [];
for (const tool of candidates) {
const s = scoreDoc(toolSearchDoc(tool), q);
if (s !== null && s > 0) {
found.push({
id: tool.id,
title: toolTitle(tool),
description: toolDescription(tool),
popularity: tool.popularity ?? 50,
score: s,
});
}
}
const collator = new Intl.Collator(LOCALE_TAGS[getLocale()]);
found.sort(
(a, b) =>
b.score - a.score ||
b.popularity - a.popularity ||
collator.compare(a.title, b.title),
);
return found.slice(0, 12);
});
function choose(id: string) {
listOpen = false;
query = "";
activeIndex = 0;
onSelect(id);
}
function onKeydown(event: KeyboardEvent) {
if (!listOpen || matches.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
activeIndex = (activeIndex + 1) % matches.length;
} else if (event.key === "ArrowUp") {
event.preventDefault();
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
} else if (event.key === "Enter") {
event.preventDefault();
const match = matches[Math.min(activeIndex, matches.length - 1)];
if (match) choose(match.id);
} else if (event.key === "Escape") {
listOpen = false;
}
}
</script>
<div class="search">
<input
type="search"
bind:value={query}
onfocus={() => (listOpen = true)}
oninput={() => {
listOpen = true;
activeIndex = 0;
}}
onkeydown={onKeydown}
placeholder={t("search.placeholder")}
aria-label={t("search.aria")}
role="combobox"
aria-expanded={listOpen}
aria-controls="tool-search-list"
/>
{#if listOpen && query.trim().length > 0 && matches.length === 0}
<p class="none text-muted">{t("search.nothingFound")}</p>
{:else if listOpen && matches.length > 0}
<div id="tool-search-list" class="cards" role="listbox">
{#each matches as match, index (match.id)}
<ToolCard
toolId={match.id}
title={match.title}
description={match.description}
selected={index === activeIndex}
onActivate={() => choose(match.id)}
onHover={() => (activeIndex = index)}
/>
{/each}
</div>
{/if}
</div>
<style>
.search {
position: relative;
width: 100%;
max-width: 36rem;
margin: 0 auto;
}
input {
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-m);
background: var(--surface);
font-size: var(--text-xl);
text-align: center;
}
input:focus {
border-color: var(--link);
outline: none;
}
.cards {
position: absolute;
left: 0;
right: 0;
top: calc(100% + var(--space-1));
z-index: 40;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-1);
margin: 0;
padding: var(--space-1);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-m);
box-shadow: var(--shadow-card);
max-height: 26rem;
overflow-y: auto;
text-align: left;
}
@media (max-width: 40rem) {
.cards {
grid-template-columns: 1fr;
}
}
.none {
margin: var(--space-1) 0 0;
text-align: center;
font-size: var(--text-s);
}
</style>
@@ -1,362 +0,0 @@
<script lang="ts">
import type { ImageInfo } from "$lib/core/analyze";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/v1/i18n/t";
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/v1/registry";
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
import type { Snippet } from "svelte";
import DownloadButton from "../DownloadButton.svelte";
import Preview from "../Preview.svelte";
import OverlayCard from "../tool/OverlayCard.svelte";
import ParamsCard from "../tool/ParamsCard.svelte";
import ResultCard from "../tool/ResultCard.svelte";
import SourceCard from "../tool/SourceCard.svelte";
import TextInputCard from "../tool/TextInputCard.svelte";
import Button from "../ui/Button.svelte";
import EmptyState from "../ui/EmptyState.svelte";
import type { StageStatus } from "./stage-props";
interface Props {
mode: "base" | "chain";
header?: Snippet;
tool: ToolEntry;
source?: PixelImage | null;
displayImage?: PixelImage | null;
info?: ImageInfo | null;
status?: StageStatus;
isInfo?: boolean;
isSourceless?: boolean;
isTextSource?: boolean;
textResult?: string | null;
sanitized?: Record<string, any>;
canChainBase?: boolean;
hasChain?: boolean;
hasMask?: boolean;
pipetteTargetId?: string | null;
values: Record<string, any>;
showMask?: boolean;
handleFile?: (file: File) => Promise<void> | void;
onTextSubmit?: (text: string) => Promise<void> | void;
onSourceError?: (e: unknown) => void;
reset?: () => void;
toggleChain?: () => void;
handlePipetteToggle?: (id: string) => void;
handlePickColor?: (hex: string) => void;
showError?: (e: unknown) => void;
overlayImage?: PixelImage | null;
onOverlayFile?: (file: File) => void;
onOverlayError?: (e: unknown) => void;
onOverlayClear?: () => void;
index?: number;
input?: PixelImage | null;
result?: PixelImage | null;
busy?: boolean;
isLast?: boolean;
onRemove?: () => void;
onError?: (e: unknown) => void;
onAddStep?: () => void;
onRemoveChain?: () => void;
}
let {
mode,
header,
tool,
source = null,
displayImage = null,
info = null,
status = "idle",
isInfo = false,
isSourceless = false,
isTextSource = false,
textResult = null,
sanitized = {},
canChainBase = false,
hasChain = false,
hasMask = false,
pipetteTargetId = null,
values = $bindable({}),
showMask = $bindable(false),
handleFile,
onTextSubmit,
onSourceError,
reset,
toggleChain,
handlePipetteToggle,
handlePickColor,
showError,
overlayImage = null,
onOverlayFile,
onOverlayError,
onOverlayClear,
index = 0,
input = null,
result = null,
busy = false,
isLast = false,
onRemove,
onError,
onAddStep,
onRemoveChain,
}: Props = $props();
const isChain = $derived(mode === "chain");
const format = $derived(outputOf(tool));
const safeParams = $derived(sanitizeParams(tool, values ?? {}));
const StepIcon = $derived(TOOL_ICONS[tool.id]);
const showParams = $derived(
mode === "chain"
? tool.params.length > 0
: (source !== null || isSourceless) &&
!isInfo &&
(tool.params.length > 0 || hasMask),
);
const leftLegend = $derived(
isChain ? t("chain.inputLegend") : t("toolPage.legendSource"),
);
const midLegend = $derived(
isChain ? t("chain.paramsLegend") : t("toolPage.legendParams"),
);
const rightLegend = $derived(
isChain
? t("chain.resultLegend")
: isInfo
? t("toolPage.legendSummary")
: t("toolPage.legendResult"),
);
</script>
<div
class="panel stage-grid"
class:no-params={!showParams}
class:single={!isChain && !!isSourceless}
>
{#if header}
{@render header()}
{/if}
{#if isChain}
<section class="pane">
<span class="edge-legend pane-legend" aria-hidden="true"
>{leftLegend}</span
>
<div class="pane-body">
<Preview image={input} />
</div>
</section>
{:else if !isSourceless}
<section class="pane">
<span class="edge-legend pane-legend" aria-hidden="true"
>{leftLegend}</span
>
<div class="pane-body">
{#if isTextSource && !source}
<TextInputCard onSubmit={(text) => onTextSubmit?.(text)} />
{:else}
<SourceCard
{source}
onFile={(f) => handleFile?.(f)}
onError={(e) => onSourceError?.(e)}
onReset={() => reset?.()}
pipetteActive={!!pipetteTargetId}
onPickColor={(hex) => handlePickColor?.(hex)}
/>
{#if tool.needsOverlaySource && source}
<OverlayCard
overlay={overlayImage}
onFile={(f) => onOverlayFile?.(f)}
onError={(e) => onOverlayError?.(e)}
onClear={() => onOverlayClear?.()}
/>
{/if}
{/if}
</div>
</section>
{/if}
{#if showParams}
<section class="pane pane-params">
<span class="edge-legend pane-legend" aria-hidden="true">{midLegend}</span
>
<div class="pane-body">
<ParamsCard
{tool}
params={tool.params}
bind:values
{pipetteTargetId}
onPipetteToggle={(id) => handlePipetteToggle?.(id)}
{hasMask}
bind:showMask
/>
</div>
</section>
{/if}
<section class="pane">
<span class="edge-legend pane-legend" aria-hidden="true">{rightLegend}</span
>
<div class="pane-body">
{#if isChain}
{#if busy && !result}
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
{:else}
<Preview image={result} />
{#if busy}
<span class="recalc" aria-live="polite"
>{t("resultCard.recalc")}</span
>
{/if}
<div class="actions-row">
<DownloadButton
image={result}
{format}
baseName="{index + 2}-{tool.id}"
params={safeParams}
onError={(e) => showError?.(e)}
/>
<Button
variant="secondary"
fullWidth
onclick={isLast ? onAddStep : onRemoveChain}
>
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
</Button>
</div>
{/if}
{:else}
<ResultCard
{tool}
sourceLoaded={isSourceless ? true : !!source}
{status}
{result}
{displayImage}
{info}
{isInfo}
params={sanitized}
{textResult}
onChainToggle={canChainBase ? toggleChain : undefined}
{hasChain}
onDownloadError={(e) => showError?.(e)}
/>
{/if}
</div>
</section>
</div>
<style>
.stage-grid {
position: relative;
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.pane {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 14rem;
}
.pane-body {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
}
.pane + .pane {
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
/* Параметры в узкой колонке: вертикальные поля с переносами.
:global под локальным якорем даёт специфичность выше scoped-правил полей. */
.pane-params :global(.root) {
padding: 0;
height: 100%;
}
.pane-params :global(.params-grid) {
grid-template-columns: 1fr;
}
.pane-params :global(.field) {
flex-direction: column;
align-items: stretch;
gap: var(--space-1);
}
.pane-params :global(.field > label) {
max-width: none;
}
.pane-params :global(.control-slot),
.pane-params :global(.row) {
align-items: stretch;
max-width: none;
width: 100%;
flex-wrap: wrap;
row-gap: var(--space-1);
}
.pane-params :global(.hint) {
align-self: auto;
text-align: left;
}
.pane-params :global(input[type="range"]) {
order: -1;
flex: 1 1 100%;
min-width: 0;
width: 100%;
}
.pane-params :global(.row > output) {
margin-left: auto;
}
.pane-params :global(input.control),
.pane-params :global(select.control) {
max-width: none;
width: 100%;
}
.recalc {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
color: var(--link);
font-size: var(--text-xs);
}
@media (min-width: 75rem) {
.stage-grid:not(.no-params):not(.single) {
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 18rem) minmax(
0,
1fr
);
}
.stage-grid.no-params:not(.single),
.stage-grid.single.no-params {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.stage-grid.single:not(.no-params) {
grid-template-columns: clamp(13rem, 17vw, 18rem) minmax(0, 1fr);
}
.pane + .pane {
border-top: none;
padding-top: 0;
border-left: 1px solid var(--border);
padding-left: var(--space-4);
}
}
</style>
@@ -1,120 +0,0 @@
<script lang="ts">
import { t } from "$lib/v1/i18n/t";
import ParamsCard from "../tool/ParamsCard.svelte";
import ResultCard from "../tool/ResultCard.svelte";
import SourceCard from "../tool/SourceCard.svelte";
import TextInputCard from "../tool/TextInputCard.svelte";
import type { StageProps } from "./stage-props";
let {
tool,
source,
result,
displayImage,
info,
status,
isInfo,
isSourceless,
isTextSource,
textResult,
sanitized,
canChainBase,
hasChain,
hasMask,
showMask = $bindable(false),
values = $bindable(),
pipetteTargetId,
handleFile,
onTextSubmit,
onSourceError,
reset,
toggleChain,
handlePipetteToggle,
handlePickColor,
showError,
errorMessage,
}: StageProps = $props();
</script>
<div class="panel tool-block">
<div class="tool-stage" class:single={isSourceless}>
{#if !isSourceless}
<span class="edge-legend source-legend" aria-hidden="true"
>{t("toolPage.legendSource")}</span
>
<div class="cell">
{#if isTextSource && !source}
<TextInputCard onSubmit={onTextSubmit} />
{:else}
<SourceCard
{source}
onFile={handleFile}
onError={onSourceError}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
/>
{/if}
</div>
{/if}
<span class="edge-legend result-legend" aria-hidden="true">
{isInfo ? t("toolPage.legendSummary") : t("toolPage.legendResult")}
</span>
<div class="cell">
<ResultCard
{tool}
sourceLoaded={isSourceless ? true : !!source}
{status}
{result}
{displayImage}
{info}
{isInfo}
params={sanitized}
{textResult}
onChainToggle={canChainBase ? toggleChain : undefined}
{hasChain}
onDownloadError={showError}
/>
</div>
</div>
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
<div class="params-sep">
<span class="edge-legend" aria-hidden="true"
>{t("toolPage.legendParams")}</span
>
</div>
<ParamsCard
{tool}
params={tool.params}
bind:values
{pipetteTargetId}
onPipetteToggle={handlePipetteToggle}
{hasMask}
bind:showMask
/>
{/if}
</div>
<style>
.tool-stage {
position: relative;
}
.source-legend {
left: 25%;
transform: translateX(-50%);
}
.result-legend {
left: 75%;
transform: translateX(-50%);
}
@media (max-width: 48rem) {
.source-legend,
.result-legend {
display: none;
}
}
</style>
@@ -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<string, any>;
canChainBase: boolean;
hasChain: boolean;
hasMask: boolean;
showMask: boolean;
values: Record<string, any>;
pipetteTargetId: string | null;
handleFile: (file: File) => Promise<void> | void;
onTextSubmit: (text: string) => Promise<void> | 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<string, any>;
onRemove: () => void;
onError: (e: unknown) => void;
onAddStep: () => void;
onRemoveChain: () => void;
}
@@ -1,67 +0,0 @@
<script lang="ts">
import { t } from "$lib/v1/i18n/t";
import type { PixelImage } from "$lib/core/types";
import DropZone from "../DropZone.svelte";
import Preview from "../Preview.svelte";
import Button from "../ui/Button.svelte";
interface Props {
overlay: PixelImage | null;
onFile: (file: File) => void;
onError: (e: unknown) => void;
onClear: () => void;
}
let { overlay, onFile, onError, onClear }: Props = $props();
</script>
<div class="overlay-card">
<span class="edge-legend overlay-legend" aria-hidden="true"
>{t("ui.overlayTitle")}</span
>
{#if !overlay}
<DropZone {onFile} {onError} label={t("ui.overlayDrop")} />
{:else}
<div class="preview-wrap">
<Preview image={overlay} />
<Button variant="secondary" onclick={onClear}
>{t("ui.overlayRemove")}</Button
>
</div>
{/if}
</div>
<style>
.overlay-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px dashed var(--border);
}
.overlay-legend {
top: -0.65em;
left: 50%;
transform: translateX(-50%);
background: var(--surface);
}
.overlay-card :global(.dropzone) {
min-height: 7rem;
font-size: var(--text-s);
}
.preview-wrap {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: stretch;
}
.preview-wrap :global(.media) {
max-height: 12rem;
}
</style>
@@ -1,51 +0,0 @@
<script lang="ts">
import { t } from "$lib/v1/i18n/t";
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
import ParamForm from "../ParamForm.svelte";
interface Props {
tool: ToolEntry;
params: ParamDef[];
values: Record<string, any>;
pipetteTargetId?: string | null;
onPipetteToggle?: (id: string) => void;
hasMask?: boolean;
showMask?: boolean;
}
let {
tool,
params,
values = $bindable(),
pipetteTargetId = null,
onPipetteToggle,
hasMask = false,
showMask = $bindable(false),
}: Props = $props();
</script>
<div class="root">
{#if params.length > 0 || hasMask}
<ParamForm
{tool}
{params}
bind:values
{pipetteTargetId}
{onPipetteToggle}
{hasMask}
bind:showMask
/>
{:else}
<p class="hint text-caption text-muted">{t("paramsCard.noParams")}</p>
{/if}
</div>
<style>
.root {
padding: 0 var(--space-4) var(--space-3);
}
.hint {
margin: 0;
}
</style>
@@ -1,129 +0,0 @@
<script lang="ts">
import type { PixelImage } from "$lib/core/types";
interface Props {
image: PixelImage;
px: number;
py: number;
hex: string;
clientX: number;
clientY: number;
}
let { image, px, py, hex, clientX, clientY }: Props = $props();
const SIZE = 110;
const ZOOM = 8;
const BLOCK = 15;
const HALF = Math.floor(BLOCK / 2);
const CENTER = SIZE / 2;
let loupeCanvas = $state<HTMLCanvasElement | undefined>();
let srcCanvas: HTMLCanvasElement | null = null;
let srcKey: PixelImage | null = null;
function ensureSource(): HTMLCanvasElement | null {
if (!srcCanvas || srcKey !== image) {
srcCanvas = document.createElement("canvas");
srcCanvas.width = image.width;
srcCanvas.height = image.height;
srcCanvas
.getContext("2d")
?.putImageData(
new ImageData(image.data, image.width, image.height),
0,
0,
);
srcKey = image;
}
return srcCanvas;
}
function clamp(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v));
}
const blockX = $derived(
clamp(px - HALF, 0, Math.max(0, image.width - BLOCK)),
);
const blockY = $derived(
clamp(py - HALF, 0, Math.max(0, image.height - BLOCK)),
);
const flipBelow = $derived(clientY < SIZE + 24);
$effect(() => {
if (!loupeCanvas) return;
const dpr = window.devicePixelRatio || 1;
loupeCanvas.width = SIZE * dpr;
loupeCanvas.height = SIZE * dpr;
const ctx = loupeCanvas.getContext("2d");
const src = ensureSource();
if (!ctx || !src) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = "#111318";
ctx.fillRect(0, 0, SIZE, SIZE);
ctx.drawImage(
src,
blockX,
blockY,
BLOCK,
BLOCK,
CENTER + (blockX - px) * ZOOM,
CENTER + (blockY - py) * ZOOM,
BLOCK * ZOOM,
BLOCK * ZOOM,
);
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 1;
ctx.strokeRect(
CENTER - ZOOM / 2 + 0.5,
CENTER - ZOOM / 2 + 0.5,
ZOOM,
ZOOM,
);
});
</script>
<div
class="loupe"
class:flip={flipBelow}
style="left:{clientX}px;top:{clientY}px"
aria-hidden="true"
>
<canvas bind:this={loupeCanvas} style="width:{SIZE}px;height:{SIZE}px"
></canvas>
<p class="hex">{hex}</p>
</div>
<style>
.loupe {
position: fixed;
z-index: 50;
transform: translate(-50%, calc(-100% - 16px));
padding: 4px 4px 2px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-m);
box-shadow: var(--shadow-card);
pointer-events: none;
}
.loupe.flip {
transform: translate(-50%, 16px);
}
canvas {
display: block;
border-radius: var(--radius-s);
}
.hex {
margin: 2px 0 0;
text-align: center;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
}
</style>
@@ -1,126 +0,0 @@
<script lang="ts">
import type { ImageInfo } from "$lib/core/analyze";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/v1/i18n/t";
import { outputOf, type ToolEntry } from "$lib/v1/registry";
import DownloadButton from "../DownloadButton.svelte";
import InfoPanel from "../InfoPanel.svelte";
import Preview from "../Preview.svelte";
import Button from "../ui/Button.svelte";
import EmptyState from "../ui/EmptyState.svelte";
import TextResult from "./TextResult.svelte";
type Status = "idle" | "loaded" | "processing" | "error";
interface Props {
tool: ToolEntry;
sourceLoaded: boolean;
status: Status;
result: PixelImage | null;
displayImage: PixelImage | null;
info: ImageInfo | null;
isInfo: boolean;
params: Record<string, unknown>;
textResult: string | null;
onChainToggle?: () => void;
hasChain?: boolean;
onDownloadError: (e: unknown) => void;
}
let {
tool,
sourceLoaded,
status,
result,
displayImage,
info,
isInfo,
params,
textResult,
onChainToggle,
hasChain = false,
onDownloadError,
}: Props = $props();
</script>
<div class="container">
{#if !sourceLoaded}
<div class="media">
<EmptyState
title={t("resultCard.emptyTitle")}
hint={t("resultCard.emptyHint")}
/>
</div>
{:else if !isInfo && status === "processing" && !result}
<div class="media">
<EmptyState
title={t("resultCard.processingTitle")}
hint={t("resultCard.processingHint")}
/>
</div>
{:else if isInfo}
<div class="media">
{#if info}
<InfoPanel {info} />
{/if}
</div>
{:else if tool.resultType === "text"}
<div class="media">
{#if textResult !== null}
<TextResult text={textResult} filename={tool.id} toolId={tool.id} />
{/if}
</div>
{:else}
<div class="media">
<Preview image={displayImage} />
{#if status === "processing"}
<span class="recalc" aria-live="polite">{t("resultCard.recalc")}</span>
{/if}
</div>
<div class="actions-row">
<DownloadButton
image={result}
format={outputOf(tool)}
baseName={tool.id}
{params}
onError={onDownloadError}
/>
{#if onChainToggle}
<Button variant="secondary" fullWidth onclick={onChainToggle}>
{hasChain ? t("resultCard.breakChain") : t("resultCard.nextTool")}
</Button>
{/if}
</div>
{/if}
</div>
<style>
.container {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
height: 100%;
}
.media {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 16rem;
position: relative;
}
.recalc {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
color: var(--link);
font-size: var(--text-xs);
}
</style>
@@ -1,60 +0,0 @@
<script lang="ts">
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/v1/i18n/t";
import DropOverlay from "../DropOverlay.svelte";
import DropZone from "../DropZone.svelte";
import Preview from "../Preview.svelte";
import Button from "../ui/Button.svelte";
interface Props {
source: PixelImage | null;
onFile: (file: File) => void;
onError: (e: unknown) => void;
onReset: () => void;
pipetteActive?: boolean;
onPickColor?: (hex: string) => void;
}
let {
source,
onFile,
onError,
onReset,
pipetteActive = false,
onPickColor,
}: Props = $props();
</script>
<div class="container">
{#if !source}
<DropZone {onFile} {onError} />
{:else}
<DropOverlay {onFile} {onError}>
<div class="media">
<Preview image={source} {pipetteActive} {onPickColor} />
</div>
<Button variant="secondary" onclick={onReset}
>{t("sourceCard.replaceImage")}</Button
>
</DropOverlay>
{/if}
</div>
<style>
.container {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
height: 100%;
}
.media {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 16rem;
}
</style>
@@ -1,61 +0,0 @@
<script lang="ts">
import Button from "../ui/Button.svelte";
import { t } from "$lib/v1/i18n/t";
interface Props {
onSubmit: (text: string) => void;
}
let { onSubmit }: Props = $props();
let text = $state("");
function submit() {
if (text.trim().length === 0) return;
onSubmit(text);
}
</script>
<div class="container">
<h2 class="heading-section">{t("textInput.heading")}</h2>
<textarea
class="input"
rows="8"
bind:value={text}
placeholder={t("textInput.placeholder")}
aria-label={t("textInput.aria")}></textarea>
<Button
variant="secondary"
onclick={submit}
disabled={text.trim().length === 0}
>
{t("textInput.decode")}
</Button>
</div>
<style>
.container {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
height: 100%;
}
h2 {
margin-bottom: var(--space-1);
}
.input {
flex: 1;
width: 100%;
resize: vertical;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
font-family: var(--font-mono);
font-size: var(--text-s);
min-height: 12rem;
}
</style>
@@ -1,102 +0,0 @@
<script lang="ts">
import { downloadBlob } from "$lib/core/io";
import { t } from "$lib/v1/i18n/t";
import { getMergedDict } from "$lib/v1/i18n/locale.svelte";
interface Props {
text: string;
filename: string;
toolId?: string;
}
let { text, filename, toolId }: Props = $props();
const shown = $derived(
toolId ? (getMergedDict().tools[toolId]?.results?.[text] ?? text) : text,
);
let copied = $state(false);
async function copy() {
await navigator.clipboard.writeText(shown);
copied = true;
setTimeout(() => (copied = false), 1500);
}
function download() {
downloadBlob(new Blob([shown], { type: "text/plain" }), `${filename}.txt`);
}
</script>
<div class="panel text-result">
<textarea
class="output"
rows="10"
readonly
value={shown}
aria-label={t("textResult.outputAria")}></textarea>
<div class="actions">
<button type="button" class="secondary" onclick={copy}>
{copied ? t("textResult.copied") : t("textResult.copy")}
</button>
<button type="button" class="primary" onclick={download}
>{t("textResult.downloadTxt")}</button
>
</div>
</div>
<style>
.text-result {
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.output {
width: 100%;
resize: vertical;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--bg);
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;
}
.actions {
display: flex;
gap: var(--space-2);
}
.actions button {
flex: 1;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-s);
border: 1px solid transparent;
font-weight: 600;
font-size: var(--text-m);
cursor: pointer;
}
.actions .primary {
background: var(--accent);
color: var(--accent-contrast);
}
.actions .primary:hover {
background: var(--accent-hover);
}
.actions .secondary {
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
.actions .secondary:hover {
border-color: var(--link);
color: var(--link);
}
</style>
@@ -1,83 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
variant?: "primary" | "secondary";
type?: "button" | "submit";
disabled?: boolean;
busy?: boolean;
busyText?: string;
fullWidth?: boolean;
onclick?: () => void;
children: Snippet;
}
let {
variant = "primary",
type = "button",
disabled = false,
busy = false,
busyText = "",
fullWidth = false,
onclick,
children,
}: Props = $props();
</script>
<button
{type}
class={fullWidth ? `${variant} fullwidth` : variant}
aria-busy={busy}
disabled={disabled || busy}
{onclick}
>
{#if busy && busyText}
{busyText}
{:else}
{@render children()}
{/if}
</button>
<style>
button {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-s);
border: 1px solid transparent;
font-weight: 600;
font-size: var(--text-m);
cursor: pointer;
transition:
background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
button.primary {
background: var(--accent);
color: var(--accent-contrast);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
button.secondary {
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
button.secondary:hover:not(:disabled) {
border-color: var(--link);
color: var(--link);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.fullwidth {
width: 100%;
}
</style>
@@ -1,56 +0,0 @@
<script lang="ts">
interface Props {
id: string;
label: string;
checked?: boolean;
hint?: string;
}
let { id, label, checked = $bindable(false), hint }: Props = $props();
</script>
<div class="checkbox-row">
<label class="checkbox" for={id}>
<input {id} type="checkbox" bind:checked />
{label}
</label>
{#if hint}
<p class="hint text-caption text-muted">{hint}</p>
{/if}
</div>
<style>
.checkbox-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-1) + 2px) 0;
border-bottom: 1px dashed var(--border);
}
.checkbox {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--text-caption, var(--text-s));
color: var(--text-muted);
}
input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: var(--accent);
}
label:hover {
color: var(--text);
}
.hint {
margin: 0;
text-align: right;
}
</style>
@@ -1,87 +0,0 @@
<script lang="ts">
import Field from "./Field.svelte";
import { t } from "$lib/v1/i18n/t";
interface Props {
id: string;
label: string;
value?: string;
hint?: string;
pipetteActive?: boolean;
onPipetteToggle?: () => void;
}
let {
id,
label,
value = $bindable("#000000"),
hint,
pipetteActive = false,
onPipetteToggle,
}: Props = $props();
</script>
<Field {id} {label} {hint}>
<div class="row">
<input {id} class="control swatch" type="color" bind:value />
{#if onPipetteToggle}
<button
type="button"
class="pipette"
aria-label={t("ui.pipette")}
aria-pressed={pipetteActive}
title={t("ui.pipette")}
onclick={onPipetteToggle}
>
◎
</button>
{/if}
</div>
</Field>
<style>
.row {
display: flex;
align-items: center;
gap: var(--space-1);
}
.swatch {
height: var(--control-height);
width: 3.5rem;
padding: 2px;
cursor: pointer;
}
.pipette {
flex: none;
width: var(--control-height);
height: var(--control-height);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast),
background var(--transition-fast);
}
.pipette:hover {
border-color: var(--link);
color: var(--link);
}
.pipette[aria-pressed="true"] {
border-color: var(--link);
color: var(--link);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
</style>
@@ -1,38 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
title: string;
hint?: string;
children?: Snippet;
}
let { title, hint, children }: Props = $props();
</script>
<div class="empty panel">
<p class="title">{title}</p>
{#if hint}
<p class="text-caption text-muted">{hint}</p>
{/if}
{#if children}
{@render children()}
{/if}
</div>
<style>
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
min-height: 8rem;
padding: var(--space-4);
text-align: center;
}
.title {
font-weight: 600;
}
</style>
-54
View File
@@ -1,54 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
id: string;
label: string;
hint?: string;
children: Snippet;
}
let { id, label, hint, children }: Props = $props();
</script>
<div class="field">
<label class="text-caption text-muted" for={id}>{label}</label>
<div class="control-slot">
{@render children()}
{#if hint}
<p class="hint text-caption text-muted">{hint}</p>
{/if}
</div>
</div>
<style>
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-1) + 2px) 0;
border-bottom: 1px dashed var(--border);
}
label {
flex: none;
max-width: 45%;
text-align: left;
}
.control-slot {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-1);
}
.hint {
margin: 0;
align-self: flex-end;
text-align: right;
}
</style>
@@ -1,21 +0,0 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Props {
id: string;
label: string;
value?: string;
options: { value: string; label: string }[];
hint?: string;
}
let { id, label, value = $bindable(""), options, hint }: Props = $props();
</script>
<Field {id} {label} {hint}>
<select {id} class="control" bind:value>
{#each options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</Field>
@@ -1,127 +0,0 @@
<script lang="ts">
import Field from "./Field.svelte";
import { t } from "$lib/v1/i18n/t";
interface Props {
id: string;
label: string;
value?: number;
min?: number;
max?: number;
step?: number;
default?: number;
hint?: string;
}
let {
id,
label,
value = $bindable(0),
min,
max,
step,
default: defaultValue,
hint,
}: Props = $props();
function decrement() {
if (min !== undefined && value <= min) return;
value = Math.max(min ?? -Infinity, value - (step ?? 1));
}
function increment() {
if (max !== undefined && value >= max) return;
value = Math.min(max ?? Infinity, value + (step ?? 1));
}
function reset() {
if (defaultValue !== undefined) value = defaultValue;
}
const resetDisabled = $derived(
defaultValue === undefined || value === defaultValue,
);
</script>
<Field {id} {label} {hint}>
<div class="row">
<button
type="button"
class="step"
aria-label={t("ui.decrease")}
onclick={decrement}>−</button
>
<input {id} type="range" {min} {max} {step} bind:value />
<button
type="button"
class="step"
aria-label={t("ui.increase")}
onclick={increment}>+</button
>
<button
type="button"
class="step"
aria-label={t("ui.reset")}
disabled={resetDisabled}
onclick={reset}
>
↺
</button>
<output>{value}</output>
</div>
</Field>
<style>
.row {
display: flex;
align-items: center;
gap: var(--space-1);
width: 100%;
max-width: 22rem;
}
input[type="range"] {
flex: 1;
min-width: 3rem;
accent-color: var(--link);
margin: 0;
}
.step {
flex: none;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
.step:hover:not(:disabled) {
border-color: var(--link);
color: var(--link);
}
.step:disabled {
opacity: 0.45;
cursor: not-allowed;
}
output {
flex: none;
min-width: 3ch;
text-align: right;
font-family: var(--font-mono);
font-size: var(--text-s);
}
</style>
@@ -1,47 +0,0 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Props {
id: string;
label: string;
value?: string | number;
type?: "number" | "text";
min?: number;
max?: number;
step?: number;
hint?: string;
placeholder?: string;
}
let {
id,
label,
value = $bindable(""),
type = "text",
min,
max,
step,
hint,
placeholder,
}: Props = $props();
</script>
<Field {id} {label} {hint}>
<input
class="control"
{id}
{type}
{min}
{max}
{step}
{placeholder}
bind:value
/>
</Field>
<style>
input.control::placeholder {
color: var(--text-muted);
opacity: 0.7;
}
</style>
-56
View File
@@ -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<Locale, string> = {
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<string, string>;
/** Подписи опций select: paramId -> value -> label. */
options?: Record<string, Record<string, string>>;
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
results?: Record<string, string>;
};
export type HeaderStrings = {
workspace: string;
catalog: string;
sectionsAria: string;
footerNote: string;
};
export type Dict = {
header: HeaderStrings;
categories: Record<CategoryId, string>;
home: Record<string, string>;
catalog: Record<string, string>;
toolPage: Record<string, string>;
chain: Record<string, string>;
sourceCard: Record<string, string>;
resultCard: Record<string, string>;
paramsCard: Record<string, string>;
textInput: Record<string, string>;
textResult: Record<string, string>;
download: Record<string, string>;
infoPanel: Record<string, string>;
dropZone: Record<string, string>;
search: Record<string, string>;
ui: Record<string, string>;
errors: Record<string, string>;
tools: Record<string, ToolStrings>;
};
-188
View File
@@ -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.",
},
},
},
};
-82
View File
@@ -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<string, string>;
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<string, string>).onlyEnKey;
vi.unstubAllGlobals();
});
describe("t", () => {
it("возвращает строку по точечному пути активной локали", () => {
expect(t("header.workspace")).toBe("Рабочая область");
setLocale("en");
expect(t("header.catalog")).toBe("Catalog");
});
it("фолбэк на английскую базу, если в активной локали нет ключа", () => {
(en.home as Record<string, string>).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");
});
});
-58
View File
@@ -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<Locale, Dict> = { ru, en };
let locale = $state<Locale>(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<string, unknown>)[section];
const b = (base as unknown as Record<string, unknown>)[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;
}
}
-101
View File
@@ -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");
}
});
});
-69
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
@@ -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");
});
});
-96
View File
@@ -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");
});
});
-34
View File
@@ -1,34 +0,0 @@
import { getMergedDict } from "./locale.svelte";
export function interpolate(
template: string,
vars?: Record<string, string | number>,
): 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, string | number>,
): string {
let node: unknown = getMergedDict();
for (const part of path.split(".")) {
if (
node &&
typeof node === "object" &&
part in (node as Record<string, unknown>)
) {
node = (node as Record<string, unknown>)[part];
} else {
return path;
}
}
return typeof node === "string" ? interpolate(node, vars) : path;
}
-63
View File
@@ -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,
]),
};
}
-90
View File
@@ -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();
}
}
});
});
-25
View File
@@ -1,25 +0,0 @@
import { ToolError } from "../core/errors";
export function num(params: Record<string, unknown>, 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<string, unknown>, id: string): string {
const v = params[id];
if (typeof v !== "string") {
throw new ToolError("errors.paramString", { id });
}
return v;
}
export function bool(params: Record<string, unknown>, id: string): boolean {
const v = params[id];
if (typeof v !== "boolean") {
throw new ToolError("errors.paramBool", { id });
}
return v;
}
-218
View File
@@ -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<ParamDef, { type: "number" | "slider" }> =>
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<string, unknown>) =>
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;
}
-186
View File
@@ -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<P = Record<string, unknown>> = {
id: string;
title: string;
description: string;
category: CategoryId;
sourceMode?: SourceMode;
params: ParamDef[];
run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
generate?: (params: P) => Promise<PixelImage> | PixelImage;
toText?: (img: PixelImage, params: P) => Promise<string> | string;
runFromText?: (text: string, params: P) => Promise<PixelImage> | PixelImage;
textToText?: (text: string) => Promise<string> | string;
preview?: (img: PixelImage, params: P) => Promise<PixelImage> | 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<string, unknown> {
return Object.fromEntries(tool.params.map((p) => [p.id, p.default]));
}
export function sanitizeParams(
tool: ToolEntry,
values: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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;
}
-674
View File
@@ -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"),
});
},
},
];
}
-293
View File
@@ -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<string, unknown>,
) => (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";
}
},
},
];
}
-501
View File
@@ -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",
),
},
];
}
-354
View File
@@ -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<string, unknown>, "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<string, unknown>, "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<number | null> => {
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),
},
];
}
-209
View File
@@ -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")),
},
];
}
-950
View File
@@ -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",
),
},
];
}
-645
View File
@@ -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"),
),
},
];
}
-315
View File
@@ -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"),
}),
},
];
}
-26
View File
@@ -1,26 +0,0 @@
export function createAutoRunner(delayMs = 300) {
let token = 0;
let timer: ReturnType<typeof setTimeout> | 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;
}
}
}
-43
View File
@@ -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");
});
});
-136
View File
@@ -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<string, unknown>,
) => Promise<PixelImage> | PixelImage;
};
export async function executeStep(
tool: MaybeRunnable,
img: PixelImage,
params: Record<string, unknown>,
): Promise<PixelImage> {
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<string, unknown>,
): Promise<PixelImage> {
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<string, string | number>;
};
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<string, unknown>,
): Promise<PixelImage> {
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,
});
});
}
-47
View File
@@ -1,47 +0,0 @@
/// <reference lib="webworker" />
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<string, unknown>;
};
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
void handle(event.data);
};
async function handle(request: WorkerRequest): Promise<void> {
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,
});
}
}
@@ -1,20 +0,0 @@
import type { PixelImage } from "$lib/core/types";
/**
* Картинка-знак для watermark-image-png. Живёт только в состоянии страницы:
* сознательно не сохраняется в pipeline/localStorage — после восстановления
* цепочки знак нужно выбрать заново.
*/
let overlay = $state<PixelImage | null>(null);
export function getOverlay(): PixelImage | null {
return overlay;
}
export function setOverlay(img: PixelImage): void {
overlay = img;
}
export function clearOverlay(): void {
overlay = null;
}
-56
View File
@@ -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"]);
});
});
-102
View File
@@ -1,102 +0,0 @@
import { getTool, sanitizeParams } from "../registry";
import { ToolError } from "../../core/errors";
export type PipelineStep = {
id: string;
toolId: string;
values: Record<string, unknown>;
};
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<string, unknown>;
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 [];
}
}
-184
View File
@@ -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<string, typeof AppWindow> = {
"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,
};
-53
View File
@@ -1,53 +0,0 @@
export const TOOL_POPULARITY: Record<string, number> = {
"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,
};
-176
View File
@@ -1,176 +0,0 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
import { LOCALES, type Locale } from "$lib/v1/i18n/dict";
import { getLocale, initLocale, setLocale } from "$lib/v1/i18n/locale.svelte";
import { t } from "$lib/v1/i18n/t";
import { onMount } from "svelte";
import "../../app_v1.css";
let { children } = $props();
onMount(() => {
initLocale();
initTheme();
});
const LANG_LABELS: Record<Locale, string> = { ru: "RU", en: "EN" };
</script>
<svelte:head>
<link rel="icon" href={`${resolve("/")}favicon.svg`} />
</svelte:head>
<div class="app">
<header>
<a href={resolve("/v1")} class="brand">easy-png-tools</a>
<nav aria-label={t("header.sectionsAria")}>
<a class="nav-link workspace-link" href={resolve("/v1")}>
{t("header.workspace")}
</a>
<a class="nav-link" href={resolve("/v1/list-tools")}>
{t("header.catalog")}
</a>
<div class="lang-switch" role="group" aria-label="Language / Язык">
{#each LOCALES as l (l)}
<button
type="button"
class="lang-btn"
class:active={getLocale() === l}
aria-pressed={getLocale() === l}
onclick={() => setLocale(l)}
>
{LANG_LABELS[l]}
</button>
{/each}
</div>
<div class="theme-switch" role="group" aria-label="Theme / Тема">
<button
type="button"
class="lang-btn"
class:active={getTheme() === "light"}
aria-pressed={getTheme() === "light"}
title={t("ui.themeLight")}
aria-label={t("ui.themeLight")}
onclick={() => setTheme("light")}
>
☀
</button>
<button
type="button"
class="lang-btn"
class:active={getTheme() === "dark"}
aria-pressed={getTheme() === "dark"}
title={t("ui.themeDark")}
aria-label={t("ui.themeDark")}
onclick={() => setTheme("dark")}
>
☾
</button>
</div>
</nav>
</header>
<main>
{@render children()}
</main>
<footer>
<p class="text-caption text-muted">{t("header.footerNote")}</p>
</footer>
</div>
<style>
.app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--text);
}
.brand:hover {
text-decoration: none;
color: var(--link);
}
nav {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.nav-link {
color: var(--text-muted);
font-size: var(--text-s);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-s);
}
.nav-link:hover {
color: var(--link);
background: var(--bg);
text-decoration: none;
}
.lang-switch,
.theme-switch {
display: flex;
gap: 2px;
margin-left: var(--space-2);
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--bg);
}
.lang-btn {
border: none;
background: transparent;
color: var(--text-muted);
font-size: var(--text-s);
font-weight: 600;
padding: 2px 8px;
border-radius: calc(var(--radius-s) - 1px);
cursor: pointer;
}
.lang-btn:hover {
color: var(--link);
}
.lang-btn.active {
background: var(--accent);
color: var(--bg);
}
.workspace-link {
font-weight: 600;
color: var(--link);
}
main {
flex: 1;
width: 100%;
padding: var(--space-4) var(--space-5);
}
footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
text-align: center;
}
</style>
-119
View File
@@ -1,119 +0,0 @@
<script lang="ts">
import { t } from "$lib/v1/i18n/t";
import { toolTitle } from "$lib/v1/i18n/tool-strings";
import ToolPage from "$lib/v1/components/ToolPage.svelte";
import ToolSearch from "$lib/v1/components/search/ToolSearch.svelte";
import Button from "$lib/v1/components/ui/Button.svelte";
import { getTool } from "$lib/v1/registry";
const LAST_TOOL_KEY = "last-tool-id";
function loadLastToolId(): string | null {
if (typeof localStorage === "undefined") return null;
try {
const id = localStorage.getItem(LAST_TOOL_KEY);
return id !== null && getTool(id) ? id : null;
} catch {
return null;
}
}
let selectedId = $state<string | null>(null);
let restoreOnOpen = $state(false);
let lastToolId = $state<string | null>(loadLastToolId());
function openTool(id: string, restore = false) {
if (!getTool(id)) return;
selectedId = id;
restoreOnOpen = restore;
lastToolId = id;
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(LAST_TOOL_KEY, id);
} catch {
// приватный режим — просто не запоминаем
}
}
function closeTool() {
selectedId = null;
}
let selected = $derived(
selectedId !== null ? (getTool(selectedId) ?? null) : null,
);
</script>
<svelte:head>
<title>
{selected ? `${selected.title} — easy-png-tools` : t("home.defaultTitle")}
</title>
</svelte:head>
{#if !selected}
<section class="hero">
<h1>{t("home.heroTitle")}</h1>
<p class="lead text-muted">{t("home.heroLead")}</p>
<ToolSearch onSelect={(id) => openTool(id)} />
{#if lastToolId !== null && getTool(lastToolId)}
{@const restoreId = lastToolId}
<div class="restore-row">
<Button
variant="secondary"
fullWidth
onclick={() => openTool(restoreId, true)}
>
{t("home.restoreLast", {
title: getTool(restoreId) ? toolTitle(getTool(restoreId)!) : "",
})}
</Button>
</div>
{/if}
</section>
{:else}
<section class="workbench">
<button
type="button"
class="back text-caption text-muted"
onclick={closeTool}
>
{t("home.changeTool")}
</button>
{#key selectedId}
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
{/key}
</section>
{/if}
<style>
.hero {
text-align: center;
padding-top: var(--space-5);
}
h1 {
margin-bottom: var(--space-2);
}
.lead {
max-width: 40rem;
margin: 0 auto var(--space-4);
}
.restore-row {
max-width: 36rem;
margin: var(--space-2) auto 0;
}
.back {
display: inline-block;
margin-bottom: var(--space-3);
border: 0;
background: none;
cursor: pointer;
}
.back:hover {
color: var(--link);
}
</style>
-40
View File
@@ -1,40 +0,0 @@
<script lang="ts">
import ToolPage from "$lib/v1/components/ToolPage.svelte";
import { getTool } from "$lib/v1/registry";
const base = getTool("linear-gradient-png")!;
</script>
<svelte:head>
<title>Mockup: цепочка инструментов — easy-png-tools</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<section class="demo">
<ToolPage
tool={base}
stageVariant="inline"
presetBaseValues={{
width: 520,
height: 340,
fromColor: "#e5484d",
toColor: "#fbbf24",
direction: "horizontal",
}}
presetChain={[
{
toolId: "remove-background-png",
values: { color: "#fbbf24", tolerance: 10, outerOnly: true, smooth: 2 },
},
{ toolId: "add-stroke-png", values: { color: "#2563eb", thickness: 8 } },
{ toolId: "round-corners-png", values: { radius: 28 } },
]}
/>
</section>
<style>
.demo {
max-width: 100rem;
margin: 0 auto;
}
</style>
-83
View File
@@ -1,83 +0,0 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { t } from "$lib/v1/i18n/t";
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
import { CATEGORIES } from "$lib/v1/categories";
import ToolCard from "$lib/v1/components/search/ToolCard.svelte";
import { TOOLS } from "$lib/v1/registry";
</script>
<svelte:head>
<title>{t("catalog.pageTitle")}</title>
<meta name="description" content={t("catalog.metaDescription")} />
</svelte:head>
<h1>{t("catalog.heading")}</h1>
<p class="lead text-muted">{t("catalog.lead", { count: TOOLS.length })}</p>
{#each CATEGORIES as category (category)}
{@const categoryTools = TOOLS.filter((tool) => tool.category === category)}
{#if categoryTools.length > 0}
<section
id={category}
class="category"
aria-labelledby="{category}-heading"
>
<h2 id="{category}-heading" class="heading-section">
{t(`categories.${category}`)}
</h2>
<div class="grid">
{#each categoryTools as tool (tool.id)}
<div class="panel">
<ToolCard
toolId={tool.id}
title={toolTitle(tool)}
description={toolDescription(tool)}
href={resolve(`/tools/${tool.id}`)}
/>
</div>
{/each}
</div>
</section>
{/if}
{/each}
<style>
h1 {
margin-bottom: var(--space-1);
}
.lead {
max-width: 48rem;
margin-bottom: var(--space-5);
}
.category {
margin-bottom: var(--space-5);
scroll-margin-top: 5rem;
}
h2 {
margin-bottom: var(--space-3);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: var(--space-3);
}
.grid > .panel {
padding: var(--space-2) var(--space-2) var(--space-3);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.grid > .panel:hover {
border-color: var(--accent);
box-shadow: var(--shadow-card);
transform: translateY(-1px);
}
</style>
-23
View File
@@ -1,23 +0,0 @@
<script lang="ts">
import { t } from "$lib/v1/i18n/t";
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
import ToolPage from "$lib/v1/components/ToolPage.svelte";
import { getTool } from "$lib/v1/registry";
let { data } = $props();
const tool = $derived(getTool(data.id));
</script>
<svelte:head>
<title
>{tool ? toolTitle(tool) : t("toolPage.fallbackTitle")} — easy-png-tools</title
>
{#if tool}
<meta name="description" content={toolDescription(tool)} />
{/if}
</svelte:head>
{#if tool}
<ToolPage {tool} />
{/if}
-15
View File
@@ -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 };
};
+1 -4
View File
@@ -1,12 +1,11 @@
// Stylelint config — design-token enforcement for easy-png-tools. // Stylelint config — design-token enforcement for easy-png-tools.
// FWHM: keeps the design system a single source of truth. Colors and sizes must // 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 // 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 { export default {
extends: ["stylelint-config-standard"], extends: ["stylelint-config-standard"],
ignoreFiles: [ ignoreFiles: [
"src/app_v1.css",
"**/node_modules/**", "**/node_modules/**",
"**/build/**", "**/build/**",
"**/.svelte-kit/**", "**/.svelte-kit/**",
@@ -52,8 +51,6 @@ export default {
}, },
overrides: [ 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"], files: ["src/**/*.css"],
rules: { rules: {
// Rule: only --brand-main and --brand-alt may set a color // Rule: only --brand-main and --brand-alt may set a color