Compare commits

...
16 Commits
152 changed files with 3957 additions and 12754 deletions
+32 -1
View File
@@ -16,7 +16,38 @@ concurrency:
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install root dependencies
run: pnpm install --frozen-lockfile
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run quality gate
run: pnpm verify
build:
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -61,4 +92,4 @@ jobs:
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v4
+77
View File
@@ -0,0 +1,77 @@
name: Quality
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: Fast quality gate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install root dependencies
run: pnpm install --frozen-lockfile
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run quality gate
run: pnpm verify
e2e:
name: E2E (non-blocking baseline)
needs: verify
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
working-directory: web
run: pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run E2E
working-directory: web
run: pnpm test:e2e
+44 -17
View File
@@ -8,37 +8,56 @@
- Менеджер пакетов: **pnpm**. Запускать команды из `web/`, либо через обёртки в
корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать
npm.
- Сборка: `pnpm --dir web build`, проверка типов:
`pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, тесты:
`pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web check`, тесты:
`pnpm --dir web test` (Vitest), тесты lint-правил:
`pnpm --dir web test:rules`, e2e: `pnpm --dir web test:e2e` (Playwright,
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт
4173).
- Форматирование: `pnpm format` в корне (оба прогона) или
`pnpm --dir web format` (только код web/). Проверка: `pnpm check:docs`
(docs) + `pnpm --dir web exec prettier --check .` (код).
- Полный быстрый gate: `pnpm verify`; подробная матрица команд и CI —
`docs/quality-gates.md`.
- После пуша результат CI проверяется явно: смотреть и blocking `verify`, и
non-blocking `e2e` — если тот job вообще запускался. `Quality` workflow живёт
на `pull_request` и `workflow_dispatch`, поэтому при прямом пуше в `main`
выполняется только `Deploy to GitHub Pages` со своим `verify`, а browser
matrix проверяется на PR или ручным запуском. Красный `e2e` не блокирует PR,
но остаётся падением — сообщать его фактический статус.
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
выполняется, даже если предыдущий упал. Текущие «ожидаемые долги» (hardcoded
размеры в kit до C19, не-Prefix токены в `app_v1.css`) — техдолг: чинить
только по заведённым tasks, не игнорировать правилом.
выполняется, даже если предыдущий упал. Новые изменения не должны добавлять
предупреждения без записи в baseline.
## Правила кода
Синтаксические ограничения описывать не нужно — они проверяются линтером
(весь список правил и токены-префиксы в `web/eslint-plugins/README.md`,
прогон: `pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то,
что линтер не умеет.
Синтаксические ограничения описывать не нужно — они проверяются линтером (весь
список правил и токены-префиксы в `web/eslint-plugins/README.md`, прогон:
`pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то, что
линтер не умеет.
### Svelte 5
Типизация пропсов через локальный `interface Props` закреплена правилом
`conventions/interface-props`; руками его нигде дублировать не надо.
### E2E
- Сначала используй `role`, `aria-*`, label и стабильный `href`; `data-testid`
разрешён только для source/result/verdict/empty-state.
- `test.fixme` допускается только для зарегистрированной задачи в backlog или
checklist; после исправления тест и ссылку обновляют вместе.
- Полные переводы проверяются в unit-тестах; e2e проверяет поведение.
- Playwright-прогоны запускаются только по явной команде пользователя, по одному
прогону, без циклов повторов и без фонового запуска: локально matrix идёт с
`workers: 1`. Отзывчивость компьютера пользователя важнее скорости проверки —
подробности в `docs/agent-workflow.md`.
### Сначала искать
Новая константа или тип не заводится, пока не произведён поиск существующего
определения (по ключу в соседних модулях и по всему `web/src/`). Это правило
в первую очередь для ИИ-агентов: дублирующее определение ухудшает правки в
определения (по ключу в соседних модулях и по всему `web/src/`). Это правило в
первую очередь для ИИ-агентов: дублирующее определение ухудшает правки в
нескольких местах и запутывает. (Синтаксис самих определений — под линтером:
строка `conventions/no-string-union-alias` и т.д.)
@@ -47,17 +66,25 @@
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
которые линтер не проверяет:
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
`web/src/app_v1.css` (маршруты `/v1/*`).
- Новый дизайн живёт в `web/src/app.css` и применяется ко всем
production-маршрутам.
- Все повторяющиеся визуальные элементы — отдельные компоненты в
`web/src/lib/components/`, даже «просто div с двумя стилями».
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
### Изоляция веток old ↔ new
## Документация
Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
каждый импорт до файла и запрещает смешивание.
- Здесь держать только обязательные инварианты, команды и ссылки на подробные
документы; не превращать `AGENTS.md` в полный справочник.
- Индекс активных документов: `docs/README.md`; подробный workflow:
`docs/agent-workflow.md`; quality gates: `docs/quality-gates.md`; текущий
план: `docs/plan-codebase-quality.md`.
- Новые подробные правила, процедуры и таблицы хранить в `docs/`, а детали
конкретных lint-правил — в `web/eslint-plugins/README.md`.
- Завершённые или устаревшие документы переносить в `docs/archive/`, указывать
дату архивирования и срок удаления; архив — только справка, не источник
требований.
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
Изменения делать небольшими (< ~500 строк), атомарными.
+35 -19
View File
@@ -1,22 +1,32 @@
# easy-png-tools
Browser-based PNG utility toolkit — no server, no data upload, everything runs locally.
Browser-based PNG utility toolkit — no server, no data upload, everything runs
locally.
Inspired by online services like onlinepngtools.com, but fully offline-capable and open source.
Inspired by online services like onlinepngtools.com, but fully offline-capable
and open source.
## Features
- **Pixel-level image processing** — format conversion, transparency/alpha channel, color transformations, geometry, filters/convolutions, morphology, quantization & palettes, image generation, text/watermarks, analysis & checks
- **Pipeline system (workspace)** — load image → chain multiple tool steps → sequential application → download result; pipelines saved to localStorage, exportable as JSON
- **Web Worker execution** — heavy operations run off the main thread with automatic fallback to direct calls
- **Pixel-level image processing** — format conversion, transparency/alpha
channel, color transformations, geometry, filters/convolutions, morphology,
quantization & palettes, image generation, text/watermarks, analysis & checks
- **Pipeline system (workspace)** — load image → chain multiple tool steps →
sequential application → download result; pipelines saved to localStorage,
exportable as JSON
- **Web Worker execution** — heavy operations run off the main thread with
automatic fallback to direct calls
- **i18n** — full Russian and English localization with search matching
- **Dark/Light themes** — persisted to localStorage
- **Pure TypeScript core** — `lib/core/` operates on `ImageData`/`Uint8ClampedArray` with no DOM dependency
- **~30+ tools** across categories: transparency, color, geometry, filters, morphology, palettes, generation, text, analysis
- **Pure TypeScript core** — `lib/core/` operates on
`ImageData`/`Uint8ClampedArray` with no DOM dependency
- **~30+ tools** across categories: transparency, color, geometry, filters,
morphology, palettes, generation, text, analysis
## Tech Stack
- **SvelteKit** (Svelte 5, runes mode) with static adapter — pure static export, no server
- **SvelteKit** (Svelte 5, runes mode) with static adapter — pure static export,
no server
- **Vite** + **TypeScript** (strict)
- **Vitest** for unit tests
- **ESLint** + **Prettier**
@@ -46,14 +56,15 @@ Static output is written to `web/build/`.
### Other Commands
| Command | Description |
| ------------------------ | ------------------------- |
| `pnpm --dir web test` | Run unit tests (Vitest) |
| `pnpm --dir web check` | Type-check (svelte-check) |
| `pnpm --dir web lint` | Lint (ESLint) |
| `pnpm --dir web format` | Format code (Prettier) |
| `pnpm format:docs` | Format docs (Prettier) |
| `pnpm --dir web preview` | Preview production build |
| Command | Description |
| ------------------------ | ------------------------------ |
| `pnpm verify` | Run the full fast quality gate |
| `pnpm --dir web test` | Run unit tests (Vitest) |
| `pnpm --dir web check` | Type-check (svelte-check) |
| `pnpm --dir web lint` | Lint (ESLint) |
| `pnpm --dir web format` | Format code (Prettier) |
| `pnpm format:docs` | Format docs (Prettier) |
| `pnpm --dir web preview` | Preview production build |
## Project Structure
@@ -75,13 +86,18 @@ easy-png-tools/
## Architecture
The project is built around a **tool registry** (`registry.ts`) — each tool is a self-contained entry with `{id, title, description, category, params, run}`. Pages, forms, and pipelines are generated from this registry.
The project is built around a **tool registry** (`registry.ts`) — each tool is a
self-contained entry with `{id, title, description, category, params, run}`.
Pages, forms, and pipelines are generated from this registry.
Core image processing lives in `lib/core/` and operates directly on `ImageData` objects with no browser API dependencies, making it portable to other environments (WASM, CLI) in the future.
Core image processing lives in `lib/core/` and operates directly on `ImageData`
objects with no browser API dependencies, making it portable to other
environments (WASM, CLI) in the future.
## Roadmap
See [docs/roadmap.md](docs/roadmap.md) for the full development plan. Current phases:
See [docs/roadmap.md](docs/roadmap.md) for the full development plan. Current
phases:
1. **Phase 1** — TypeScript core + web UI (in progress)
2. **Phase 2** — Rust/WASM core for performance-critical operations
+79
View File
@@ -0,0 +1,79 @@
# Документация проекта
> Статус: **active**. Это индекс документов и карта источников истины для
> разработчиков и агентов. Подробные правила находятся в самих документах, а
> обязательный краткий контракт — в `AGENTS.md`.
## Порядок чтения
1. `AGENTS.md` — команды и обязательные инварианты.
2. Этот файл — выбрать актуальный документ по теме.
3. `docs/plan-codebase-quality.md` — текущий план приведения качества в порядок
и последовательность следующих шагов.
4. Соответствующий план или checklist темы.
5. Исходники, конфигурация и тесты рядом с изменяемым кодом.
## Активные документы
| Документ | Роль | Статус |
| ---------------------------------- | ---------------------------------------------------- | ------ |
| `AGENTS.md` | Краткий обязательный контракт для агента | active |
| `docs/plan-codebase-quality.md` | План quality gates, тестов и lint-правил | draft |
| `docs/agent-workflow.md` | Подробный порядок исследования, изменения и проверки | active |
| `docs/testing-strategy.md` | Locator, fixture, i18n и fixme-policy для e2e | active |
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
| `docs/roadmap.md` | Общие направления развития | active |
| `docs/plan-redesign.md` | Новый дизайн production UI | active |
| `docs/plan-seo.md` | SEO/GEO-план | active |
| `docs/plan-platform.md` | PWA/API/WASM-план | active |
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
| `docs/tools-map.md` | Живая карта инструментов | active |
| `web/eslint-plugins/README.md` | Детали custom lint rules и token policy | active |
| `docs/archive/README.md` | Правила архивирования и удаления старых документов | active |
`docs/archive/` содержит временную историческую справку и не является текущим
источником критериев приёмки. Для старых документов действует срок хранения 30
дней; после него их удаляют, а при необходимости достают из git history.
## Где хранить новую информацию
- Поведение, которое агент обязан соблюдать сразу, — кратко в `AGENTS.md`.
- Точные команды, параметры скриптов и критерии качества — в
`docs/quality-gates.md` или в README соответствующей подсистемы.
- Архитектура, потоки данных и границы модулей — в отдельном architecture-плане
или документе, не дублируя существующие планы.
- Реализация lint-правил, fixtures и технические детали ESLint — в
`web/eslint-plugins/README.md` и исходниках правил.
- Продуктовые решения и ручная приёмка — в backlog, плане и checklist.
Подробные материалы хранятся в `docs/`, а не в корне. Новый файл создаётся
только если темы нет в существующем документе; иначе обновляется источник
истины.
## Текущий baseline Q6f.3
На момент фиксации Q6f.3:
- production registry содержит 122 инструмента;
- `pnpm --dir web check` проходит без ошибок и предупреждений;
- `pnpm --dir web test` — 43 test files / 465 tests;
- `pnpm --dir web test:rules` — 4 test files / 74 tests;
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
design tokens от token audit;
- `pnpm verify` проходит;
- `pnpm --dir web test:e2e` — 276 тестов в четырёх проектах: 276 passed, 0
skipped, 0 failed. Отказ декодера битого PNG зафиксирован per-engine в
`web/e2e/png-fixtures.spec.ts`.
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
## Правила актуальности
- У активного документа есть статус: `draft`, `active`, `completed` или
`archived`.
- Ссылки на конкретный файл или команду проверяются при изменении источника
истины.
- Архивный документ не возвращается в live-критерии без явной сверки.
- После каждого шага плана агент останавливается, показывает изменённые файлы,
проверки и нерешённые решения, и ждёт ревью.
+130
View File
@@ -0,0 +1,130 @@
# Workflow для агентов
> Статус: **active**. Подробная процедура работы с репозиторием; краткие
> обязательные правила остаются в `AGENTS.md`. Этот документ не заменяет source
> of truth конкретной подсистемы.
## 1. Подготовка к задаче
1. Прочитать `AGENTS.md` и `docs/README.md`.
2. Найти релевантный активный план, backlog или checklist.
3. Проверить `git status`: не перезаписывать чужие незакоммиченные изменения.
4. Найти существующие типы, константы, helpers и тестовые паттерны до создания
нового определения.
5. Определить границу изменения:
- production UI — `web/src/routes/**` и `web/src/lib/components/**`;
- shared — `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts`.
Изменение shared-кода требует проверки production-потребителей. Не добавлять
исключения линтера ради отдельных модулей без записи в baseline.
## 2. Порядок изменения
- Сначала исправляются тесты или контракт, который фиксирует неверное поведение,
затем production-код — если это не отдельная продуктовая задача.
- Один PR решает одну проблему и не объединяет lint, i18n, UI и архитектурный
рефакторинг без необходимости.
- Изменения держатся небольшими и атомарными, примерно до 500 строк или меньше.
- Не добавлять комментарии, которые пересказывают код. Комментарий объясняет
причину решения или фиксирует неочевидный инвариант.
- Не коммитить изменения автоматически: коммит делает разработчик после ревью.
## 3. Проверки
Команды и их область применения перечислены в `AGENTS.md`; точная матрица и
текущая политика warnings/errors поддерживаются в `docs/quality-gates.md`.
| Изменение | Минимальная проверка |
| ------------------------------------------------------- | ------------------------------------------------------ |
| Только Markdown | `pnpm check:docs` |
| TypeScript/Svelte | `pnpm --dir web check`, `pnpm --dir web test` |
| Lint-конфиг или custom rule | `pnpm --dir web test:rules`, `pnpm --dir web lint:all` |
| Маршрут, загрузка, результат, download, тема или локаль | `pnpm --dir web test:e2e` |
| Перед релизом | полный quality gate из плана Q1 |
`lint:all` может завершиться успешно при warnings. Это не означает чистый
baseline: нужно отдельно показать число errors, warnings и token warnings.
E2E запускается отдельно от быстрых проверок, потому что поднимает build и
статический сервер. Для UI-изменений сначала выбирается минимальный
пользовательский поток, затем при необходимости расширяется smoke.
### Нагрузка на машину
Агент работает на компьютере пользователя, поэтому тяжёлые прогоны не
запускаются «по умолчанию»:
- Playwright-прогоны запускаются только по явной команде пользователя: один
прогон за раз, без циклов повторов и без фонового запуска.
- Локально `workers: 1` (`web/playwright.config.ts`). Browser matrix из четырёх
проектов проходит медленно, зато не забирает память и не мешает работе
пользователя. Скорость проверки уступает отзывчивости компьютера.
- Нельзя запускать тяжёлый прогон параллельно с другим прогоном или в момент,
когда пользователь работает с приложением.
- Диагностика флаков идёт от меньшего к большему: один spec, один проект,
`--workers=1`, и только затем полный прогон.
- Таймауты не подбираются под конкретную машину. Если проверка падает из-за
нехватки ресурсов, это фиксируется как ограничение окружения, а не лечится
увеличением `expect.timeout`.
- При необходимости повторного прогона сначала спрашивается пользователь.
## 4. Работа с тестами
- Поведенческий тест проверяет пользовательский контракт, а не текущую
реализацию.
- Сначала предпочитать `role`, `aria-*`, label, stable id или href.
- `data-testid` ограничен source/result/verdict/empty-state; общие
locator-паттерны собраны в `web/e2e/helpers/page.ts`, policy — в
`docs/testing-strategy.md`.
- `data-testid` использовать только там, где семантического locator нет.
- Не фиксировать абсолютное число инструментов, если проверка не связана с этим
инвариантом.
- Точные переводы проверять в словарях и i18n helpers; в e2e проверять поведение
и отдельный locale smoke.
- `test.fixme` допускается только для зарегистрированной проблемы или явного
тестового долга, с ссылкой на backlog или checklist. После исправления тест и
его issue обновляются вместе.
- Новые fixtures должны быть воспроизводимыми и не требовать ручного создания
больших бинарных файлов.
## 5. Документация
Источник истины выбирается по теме:
- обязательное поведение агента — `AGENTS.md`;
- индекс и карта документов — `docs/README.md`;
- качество и точные команды — `docs/quality-gates.md`;
- тестовая архитектура — `docs/testing-strategy.md`;
- lint implementation — `web/eslint-plugins/README.md`;
- продуктовые решения — backlog и активные планы.
Не дублировать длинные объяснения в нескольких файлах. Если правило требует
пояснения, в `AGENTS.md` оставить короткую понятную формулировку и ссылку на
подробный документ.
### Архивирование завершённых документов
- Если план или исследование больше не является рабочим источником, перенести
файл в `docs/archive/`.
- В начало файла добавить `Статус`, `Архивирован`, `Удалить после` и причину.
- Архив хранить 30 дней для быстрой справки; после срока удалить после проверки
ссылок. Если решение понадобится снова, использовать git history.
- Не переносить активный план в архив только потому, что он пока не начат.
## 6. Протокол ревью между шагами
После каждого шага плана агент:
1. останавливается и не начинает следующий Q-этап;
2. перечисляет изменённые файлы и принятые решения;
3. указывает выполненные проверки и их фактический результат;
4. отдельно перечисляет warnings, fixme, долги и вопросы;
5. ждёт подтверждения на продолжение.
Коммит и пуш делает разработчик. После пуша агент проверяет результат CI и
сообщает его вместе с локальными проверками, включая случай, когда нужный job не
запускался: `Quality` workflow с browser matrix живёт на `pull_request` и
`workflow_dispatch`, поэтому прямой пуш в `main` проверяет только быстрый gate.
Процедура описана в `docs/quality-gates.md`.
Следующий шаг начинается только после ревью текущего изменения.
+52
View File
@@ -0,0 +1,52 @@
# Архив документов
> Статус: **active**. Здесь хранятся завершённые, отменённые и явно устаревшие
> документы как временная историческая справка.
## Назначение
Архив помогает быстро посмотреть, какое решение принималось раньше и почему. Он
не является источником текущих требований, критериев приёмки или команды. Для
актуальных правил используются `AGENTS.md`, `docs/README.md` и активные планы.
Если решение снова понадобится, его можно восстановить из git history или
создать новый активный документ.
## Обязательные метаданные
При переносе документа в архив добавить в начало:
```text
> Статус: archived
> Архивирован: YYYY-MM-DD
> Удалить после: YYYY-MM-DD
> Причина: краткое объяснение, почему документ больше не активен
> Использование: только историческая справка
```
Дата `Удалить после` — дата архивирования плюс 30 дней. Старые файлы, созданные
до введения политики, могут не иметь этих дат; они также считаются архивными и
не являются активными требованиями. При следующем касании их можно удалить или
привести к шаблону.
## Порядок архивирования
1. Убедиться, что документ действительно завершён, отменён или заменён более
актуальным источником.
2. Добавить обязательные метаданные.
3. Переместить файл в `docs/archive/`.
4. Проверить ссылки из backlog, планов, checklist и `docs/README.md`.
5. Не менять архивный документ под новые требования.
## Удаление
Через 30 дней после архивирования:
- проверить, что документ не нужен для текущей работы;
- найти и удалить оставшиеся ссылки на него из активных документов;
- удалить файл из `docs/archive/`;
- при необходимости отметить удаление в истории изменений.
До истечения срока архивный файл не превращается обратно в live-документ: если
он снова актуален, создаётся новый документ или возвращается версия из git
истории с новым статусом и датами.
@@ -1,5 +1,9 @@
# Анализ: набор PNG-утилит для браузера
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> ранний исследовательский разбор, не являющийся текущим планом Использование:
> только историческая справка
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере.
Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких
загрузок на сервер, всё считается локально. Стек: **SvelteKit +
@@ -1,15 +1,20 @@
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в
> backlog. Использование: только историческая справка
# План: i18n в preview (новый UI)
> Статус: готов к реализации (вопросы §5 решены). Соответствует беклог-задаче
> «добавить i18n в preview» (`docs/backlog.md`). Реализация не начата.
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2
> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и
> ограничения — в `docs/plan-codebase-quality.md`.
## 1. Что получается
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
`v1`) переходит на существующий модуль `lib/i18n`: переключатель RU/EN в шапке
работает по-настоящему, все строки локализованы, ошибки уже локализуются.
Инфраструктура i18n уже есть и проверена (v1-ветка) — задача в том, чтобы
подключить к ней preview и закрыть пробелы словарей.
`v1`) использует модуль `lib/i18n`: переключатель RU/EN в шапке работает по
локали, строки и ошибки локализованы. Остаточные задачи — довести паритет
словарей до zero-warning и спроектировать plural-формы.
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
копия `lib/i18n` → `lib/v1/i18n`, у нового UI — своя независимая ветка
@@ -278,11 +283,11 @@
`empty value "x.y" in en`.
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
всех локалях — поймать потерянную при переводе переменную.
- **8.3** Цель — zero-warn. Сейчас `en.ts` неполный (в `tools` только
результаты-помощники, остальной английский — из реестра), поэтому после
включения будет много warn'ов: доводим `en.ts` до полноты `ru.ts` (секции +
`tools` полностью, source — registry-English, без дрейфа). До достижения цели
допускается allowlist-механизм в ядре правила для осознанных отклонений.
- **8.3** Политика источников: `tools.*.title` и `tools.*.description` в
`BASE_LOCALE` намеренно берутся из registry English fallback; остальные локали
обязаны иметь физические переводы. Старый `tools.*.params` не используется
новым UI и пока исключён policy-pattern, а не списком id. Target — zero-warn
без молчаливых подавлений.
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
@@ -1,5 +1,9 @@
# Хранить данные для сортировки по популярности инструментов
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> ранний исследовательский вариант, не являющийся текущим планом Использование:
> только историческая справка
Идея выложить на Vercel и использовать Vercel KV (на базе Upstash Redis) — это
отличный, рабочий и очень быстрый в реализации вариант. Для 150 инструментов
объем данных будет крошечным, но здесь есть пара критически важных нюансов по
+68 -58
View File
@@ -4,8 +4,9 @@
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
правило `i18n/dict-consistency`, детали в §Фаза 8
`docs/plan-preview-i18n.md`
правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`.
Правило реализовано; Q2 перевёл i18n baseline к zero-warning через
policy-aware fallback.
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
что за `SonarLint` или написать кастомный линтер)
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
@@ -13,6 +14,8 @@
- [ ] консистентность комментариев - если есть описание к одному инструменту -
оно должно быть у всех или объяснено, почему этот инструмент такой
особенный и заслуживает комментарий
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого Q2 держит pattern-based policy-исключение
## Мелочи всякие
@@ -50,8 +53,7 @@
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность
(range?)
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] проверить настройки range slider на crop-png - слишком большие значения
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
значения
@@ -72,8 +74,7 @@
- [x] добавить i18n в preview
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
`tPlural()` и формат `PluralForms`, детали в §Фаза 9
`docs/plan-preview-i18n.md`
`tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog
## SEO/GEO
@@ -100,38 +101,44 @@
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
(фазы 3–8) — здесь только новые треки.
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1;
C19 отложен — не блокер)
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
(после wasm-фаз 5–7)
## Баги preview
## Качество production UI
Зарегистрированы при написании e2e (`web/e2e/`, `pnpm --dir web test:e2e`) и
ручного чек-листа — см. `docs/checklist-manual-testing.md` (раздел G). Каждый
зафиксирован как `test.fixme`. После фикса бага: убрать соответствующий
`test.fixme` и отметить пункт G в чек-листе как пройденный.
Q5–Q6f.3 закрывают известные preview-проблемы, special fixtures, smoke,
layout/view-model инварианты и browser matrix; активных `test.fixme` в e2e
больше нет.
1. **Генераторы без кнопки «Generate»** — все 21 инструмент категории GENERATE
открываются, но в UI нет кнопки Generate и полей схемы (только RU/EN/Reset);
результат через UI недостижим. В `SchemaToolView.svelte` `isGenerator`
определяется как `tool.generate && !tool.run`, кнопка рисуется только если
`ongenerate` прокинут, а для генераторов с дефолтным `input: "file"` — нет.
Ожидание: поля схемы + кнопка «Generate», результат-картинка по дефолтам.
Fixme: `web/e2e/generators.spec.ts`. Кнопки и не должно быть, все процессы
должны запускаться автоматически
2. **resize-png с дефолтами падает** — схема по умолчанию `size: 0×0` +
`keepAspect: true` → при загрузке ЛЮБОГО PNG сразу `errors.resizeSize`
(alert), результата нет. `registry-new/geometry.ts`. Ожидание: осмысленный
дефолт-размер (например, исходный) либо кнопка, а не авто-ошибка.
3. **crop-png с дефолтами падает** — аналогично: `width: 0, height: 0` → сразу
`errors.cropSize`. `registry-new/geometry.ts`. Ожидание: базовый кроп по
умолчанию либо внятное предзаполнение.
4. **Сырые ключи ошибок в UI** — alert на tool-странице показывает сырой ключ
i18n (`errors.resizeSize`), а не сообщение из `en.ts` / `ru.ts` (там
`resizeSize: "Width and/or height must be positive"`). Проверяется на
resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке.
Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»).
- [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`.
- [x] `resize-png` и `crop-png` используют размеры текущего source через общий
source-aware schema resolver — `web/e2e/pipeline.spec.ts`.
- [x] Reset сохраняет source/text, восстанавливает defaults и запускает deferred
auto-run — `web/e2e/pipeline.spec.ts`.
- [x] Ошибки хранят i18n key/vars и переводятся при выводе; изменение языка
обновляет alert — `web/e2e/i18n.spec.ts`.
- [x] Malformed/special PNG fixtures: CRC, truncated, palette и 16-bit проходят
через upload без краша — `web/e2e/png-fixtures.spec.ts`.
- [x] Registry-wide smoke покрывает все 122 инструмента по input/result-типу —
`web/e2e/tools-smoke.spec.ts`, `generators.spec.ts`,
`text-and-verdicts.spec.ts`.
- [x] Layout-группировка вынесена в `resolveLayoutGroups` и покрыта
unit-тестами; `SchemaFields.svelte` использует общий seam.
- [x] `SchemaPreview` view-model вынесена в `buildSchemaPreviewModel` и покрыта
unit-тестами для всех input/result-типов.
- [x] Browser matrix: полный Chromium, Firefox/WebKit для browser-critical
спецификаций и mobile Chromium 390×844 — `web/playwright.config.ts`.
- [x] Расхождение декодеров зафиксировано как ожидаемое поведение: Firefox и
WebKit декодируют `truncated-no-iend` и `idat-cut`, а WebKit ещё и
`crc-bad-idat`. Таблица движков живёт в `web/e2e/png-fixtures.spec.ts`,
продукт не ужесточается, и любая выгрузка битого файла обязана давать
alert, а не падение или «пустой» результат.
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ. Это
продуктовая задача, не Q6d.
## Идеи
@@ -168,9 +175,11 @@
**Download**: юзер скачивает результат в нужном формате (mime/ext/quality), а
pipeline при этом не усложняется лишним звеном. **Сделано (2026-09):**
механическая часть реализована в preview — `ToolEntry.output`
(`OutputFormat`: mime/ext/qualityParamId) в `registry-new/types.ts`;
(`OutputFormat`: mime/ext/qualityParamId) в `web/src/lib/registry/types.ts`;
`SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены;
`png-to-bmp` переведён сюда же). **Осталось (UX-райз «Download»):**
`png-to-bmp` переведён сюда же). Q6c покрывает этот фиксированный контракт
unit/E2E-проверками; переключатель форматов и quality остаётся отдельным
UX-райзом. **Осталось (UX-райз «Download»):**
- выбор формата/качества прямо в кнопке/диалоге Download для любого
результата (не отдельными конвертерами);
- экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png`
@@ -207,39 +216,40 @@
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
`archive/plan-gap-waves.md`.
13. **Контраст текста на акцентных кнопках — пересмотреть после готовности
сайта**. Текст `--color-background` на фоне `--color-main`/`--color-accent`
(тон 48) даёт контраст ~4.1–4.3 (light) — ниже AA 4.5 для обычного текста;
`palette.test.ts` держит для кнопок порог 4.0 (AA large-text) осознанно, как
компромисс текущего дизайна. После полного завершения редизайна пересмотреть
тона (например поднять tone кнопок до ~55) и вернуть порог 4.5. Также
13. **Контраст текста на акцентных кнопках — частично закрыт**. Для dark-темы
`--color-main`/`--color-accent` подняты до tone 56, чтобы пройти текущий
порог `palette.test.ts` 4.0; targeted-тест проходит. Light-тема сохраняет
контраст ~4.1–4.3, что ниже AA 4.5 для обычного текста, поэтому после
завершения редизайна нужно повторно поднять тона и вернуть порог 4.5. Также
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
light-danger тон 48 = 4.25 на границе.
light-danger tone 48 = 4.25 на границе.
14. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
реализовать процесс показа маски результата. **Контекст:** в старом
контракте поле `preview` у `remove-color`/`remove-background` показывало
быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что
будет вырезано. При миграции на единый `run(ctx)` поле убрано (шаг 8,
`registry-new/alpha.ts`) — новый executor и schema-превью зовут `run()`
напрямую, маска пока не нужна. **Идея 1:** дать каждому `run` возможность
вернуть (или инструмент — объявить) `runMask` — ч/б изображение маски
изменений либо иной подход (отдельное поле `mask` + функция маски, второй
результат, overlay). Оценка — на усмотрение при проработке (S/M). Пока живём
без неё. **Идея 2:** каждый `run` возвращает два изображения - для
отображения (показывает маску или сам результат), и для передачи в следующий
инструмент (всегда результат. Оценка — на усмотрение при проработке (S/M).
Пока живём без неё.
`web/src/lib/registry/alpha.ts`) — новый executor и schema-превью зовут
`run()` напрямую, маска пока не нужна. **Идея 1:** дать каждому `run`
возможность вернуть (или инструмент — объявить) `runMask` — ч/б изображение
маски изменений либо иной подход (отдельное поле `mask` + функция маски,
второй результат, overlay). Оценка — на усмотрение при проработке (S/M).
Пока живём без неё. **Идея 2:** каждый `run` возвращает два изображения -
для отображения (показывает маску или сам результат), и для передачи в
следующий инструмент (всегда результат. Оценка — на усмотрение при
проработке (S/M). Пока живём без неё.
15. **Процесс работы с инструментами 1 → many и many → 1** — абстракция
результата «набор файлов» оформлена и работает: тип `FileResult`,
`result: "files"` в реестре, сериализация через executor, скачивание
zip-архивом, грид-превью частей. Реализовано на первом потребителе
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
**1 → many:** подключение остальных потребителей (gif-to-frames,
separate-colors) при необходимости; формат/качество файлов внутри zip —
отложено до UX-райза «Download» (п.7). **many → 1** (вход = архив/несколько
картинок → единый результат): не реализовано. Зависит от batch-обработки
архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна отдельная
проработка входа (распаковка zip в браузере, порядок файлов, поведение в
пайплайне — такие инструменты терминальны, как и 1 → many).
Q6b закрывает контракт ZIP/download: unit-тесты `web/src/lib/zip.test.ts` и
browser-сценарий `web/e2e/pipeline.spec.ts` проверяют имена частей и
содержимое архива. **1 → many:** подключение остальных потребителей
(gif-to-frames, separate-colors) при необходимости; формат/качество файлов
внутри zip — отложено до UX-райза «Download» (п.7). **many → 1** (вход =
архив/несколько картинок → единый результат): не реализовано. Зависит от
batch-обработки архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна
отдельная проработка входа (распаковка zip в браузере, порядок файлов,
поведение в пайплайне — такие инструменты терминальны, как и 1 → many).
+42 -10
View File
@@ -1,13 +1,13 @@
# Чек-лист ручного тестирования preview
# Чек-лист ручного тестирования production UI
> Вспомогательный документ к `docs/archive/plan-testing-preview.md` и e2e-тестам
> (`web/e2e/`, `pnpm --dir web test:e2e`). Автоматизированная часть закрывает
> Вспомогательный документ к e2e-тестам (`web/e2e/`, `pnpm --dir web test:e2e`)
> и стратегии `docs/testing-strategy.md`. Автоматизированная часть закрывает
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
>
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
> → `node scripts/serve-static.mjs`).
>
> Критерий приёмки большинства пунктов: **нет зависит, нет «залипания»,
> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания»,
> сообщения об ошибках человекочитаемые**.
## A. Краевые PNG-файлы
@@ -19,12 +19,13 @@
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
remove-background, feather-edges, clean-edges дают корректные края.
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется.
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется
(авто-покрытие: `web/e2e/png-fixtures.spec.ts`).
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
pipeline.spec.ts).
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
размер, прозрачность).
`pipeline.spec.ts` и `png-fixtures.spec.ts`).
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`.
### Файлы (`tests/fixtures/manual-edge-cases/`)
@@ -59,8 +60,7 @@
красного alert-текста в виде `errors.*`.
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
именные цвета) — silent repair или понятная ошибка.
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет
(кейс из плана п.14).
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет.
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
## C. Буфер обмена и drag-n-drop
@@ -96,6 +96,38 @@
корректный (не «кракозябры»).
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
## G. Закрытые Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1/Q6f.2/Q6f.3-проблемы
> Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1/Q6f.2/Q6f.3-проблемы закрыты автоматическими тестами;
> для ручной проверки остаются субъективные сценарии разделов A–F.
- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без
кнопки Generate. `web/e2e/generators.spec.ts`.
- [x] **resize-png/crop-png:** размеры текущего source используются по
умолчанию, alert не появляется. `web/e2e/pipeline.spec.ts`.
- [x] **Reset:** defaults восстанавливаются, source сохраняется, deferred
auto-run завершается результатом. `web/e2e/pipeline.spec.ts`.
- [x] **Локализация ошибок:** alert хранит key/vars, переводится при выводе и
обновляется при смене языка. `web/e2e/i18n.spec.ts`.
- [x] **FileResult → ZIP:** `split-into-parts-png` скачивает
`split-into-parts-png.zip` с именованными PNG-частями; содержимое и
сигнатуры проверяются `web/src/lib/zip.test.ts` и
`web/e2e/pipeline.spec.ts`.
- [x] **Output MIME/quality:** JPG/WebP/BMP имеют ожидаемые расширения и
сигнатуры, quality 10/90 меняет размер файла; `web/e2e/pipeline.spec.ts`.
- [x] **Malformed/special PNG:** CRC, truncated, palette и 16-bit fixtures
проходят через upload без краша; `web/e2e/png-fixtures.spec.ts`.
- [x] **Registry smoke:** все 122 production tools имеют специализированный
browser smoke по input/result-типу; `web/e2e/tools-smoke.spec.ts`,
`generators.spec.ts`, `text-and-verdicts.spec.ts`.
- [x] **Layout resolver:** `resolveLayoutGroups` покрыт unit-тестами; UI
группировка полей использует тот же seam.
- [x] **Preview model:** `buildSchemaPreviewModel` покрывает source/result
values, формат и `hasResult` для image/text/verdict/files.
- [x] **Browser matrix:** pipeline, verdicts и PNG-fixtures проходят в Firefox и
WebKit, mobile viewport 390×844 — в Chromium; отказ декодера битого PNG
зафиксирован per-engine. См. раздел 7 `docs/testing-strategy.md`.
## Как долго
- Полный прогон: ~40 минут (все разделы, включая мегапиксели и 4К) в двух
+500
View File
@@ -0,0 +1,500 @@
# План: линтеры, тесты и качество кодовой базы
> Статус: **draft — Q0–Q5 и Q6a–Q6f.3 готовы к ревью; Q6f.4 — следующий этап**.
>
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
> инфраструктурных скриптов. Q0–Q5 и Q6a–Q6f.3 выполнены; Q6f.4–Q8 выполняются
> отдельными атомарными шагами.
>
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
> проверок, стабилизировать тестовые контракты и только после этого расширять
> автоматические правила.
## 1. Границы и принципы
1. Сначала stabilизировать существующие проверки и тестовые контракты, а не
добавлять новые сложные lint-правила.
2. Старый UI удалён: production работает на корневых маршрутах, а `/v1/**` и
v1-specific код больше не являются частью приложения.
3. Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты.
4. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и
smoke-сценариями production-потребителей.
5. Изменения делаются небольшими атомарными частями, в соответствии с
`AGENTS.md`.
6. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме,
получает fixtures и тесты, и только после очистки baseline становится
blocking.
7. Детали реализации правил остаются в `web/eslint-plugins/README.md`;
`AGENTS.md` содержит краткие обязательные политики, понятные агенту без
дополнительных поисков, и ссылки на подробные документы.
### Политика удаления legacy v1
- Q3 — отдельная атомарная cleanup-задача; поддержка v1 не возвращается.
- Старые маршруты и UI-файлы удаляются без redirects и без совместимого
maintenance-режима.
- В рабочей кодовой базе не оставлять UI-v1-пути, исключения или документацию,
которые выглядят как действующий источник совместимости.
- Исторические упоминания допустимы только в `docs/archive/`.
- Будущий API-контракт `POST /v1/process` из `docs/plan-platform.md` не связан с
удалённым UI-v1 и остаётся в планах.
## 2. Текущее состояние quality-контура
- `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование,
docs и production build; deploy выполняет его перед сборкой.
- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt —
4 unused design tokens от token audit.
- E2E-контракт использует semantic locators и общий source-aware schema
resolver; активных known-issue `test.fixme` нет.
- `ToolError` хранится как key/vars и переводится только при выводе; смена
локали обновляет уже видимый alert.
- E2E гоняется в четырёх Playwright-проектах: полный Chromium, Firefox и WebKit
для browser-critical спецификаций и mobile Chromium 390×844. Job остаётся
non-blocking, число воркеров ограничено из-за памяти.
## 3. Этапы выполнения
### Q0. Зафиксировать baseline и синхронизировать документацию
**Статус:** готов к ревью. **Оценка:** S. **Зависимости:** нет.
- Проверить фактические команды и зафиксировать их в quality-gate документации.
- Вынести расширенный workflow агента в `docs/agent-workflow.md`, оставив в
`AGENTS.md` только обязательные инварианты и ссылки.
- Обновить `docs/backlog.md`:
- отметить dictionary linter как реализованный, но не доведённый до
zero-warning;
- убрать противоречие по кнопке Generate;
- синхронизировать числа инструментов и тестовых сценариев.
- Восстановить или исправить ссылку на раздел G в
`docs/checklist-manual-testing.md`.
- Убрать устаревшее утверждение про hardcoded debt в `kit` из `AGENTS.md`, если
оно больше не соответствует коду.
- Перенести явно устаревшие `docs/analysis.md` и `docs/sorting-backend.md` в
`docs/archive/`, добавить даты архивирования и удаления через 30 дней.
- Создать `docs/README.md` как индекс активных документов и карту источников
истины; подробные правила не дублировать в корневых файлах.
**Готово, когда:** один документ описывает текущие команды, актуальный baseline
и все известные `test.fixme` имеют ссылки на зарегистрированный баг.
### Q1. Единый quality gate и CI
**Статус:** готов к ревью. **Проверено:** `pnpm verify` проходит; E2E оставлен
отдельным non-blocking job. **Оценка:** S–M. **Зависимости:** Q0.
Добавить read-only `verify`-команду, которая запускает:
```text
pnpm --dir web check
pnpm --dir web test
pnpm --dir web test:rules
pnpm --dir web lint:all
pnpm --dir web exec prettier --check .
pnpm check:docs
pnpm --dir web build
```
E2E оставить отдельным тяжёлым гейтом:
```text
pnpm --dir web test:e2e
```
- Не дублировать вручную аргументы ESLint/Stylelint в нескольких скриптах;
оркестратор должен вызывать package scripts или единый общий модуль.
- Описать фактические команды и semantics ошибок/warnings в
`docs/quality-gates.md`; здесь нужна точная процедура, а не длинный дубль в
`AGENTS.md`.
- На первом этапе явно разделить `errors` и `warnings`.
- После очистки i18n baseline добавить строгий режим `--max-warnings=0`.
- Добавить отдельный workflow для pull request: typecheck, unit, rule-тесты,
lint, format и build.
- E2E сначала запускать отдельным job; сделать его blocking после закрытия
известных ошибок и стабилизации baseline.
**Готово, когда:** одна команда воспроизводит все быстрые проверки, а deploy не
может пройти при type/unit/lint/build-ошибке.
### Q2. Политика i18n и zero-warning baseline
**Статус:** готов к ревью. **Проверено:** i18n lint даёт 0 warnings, rule-тесты
и полный `pnpm verify` проходят. **Оценка:** M. **Зависимости:** Q0, Q1.
- `BASE_LOCALE` — единственный источник registry-English fallback для
`tools.*.title` и `tools.*.description`; остальные локали требуют физические
ключи.
- Временно неиспользуемые `tools.*.params` исключены pattern-based policy до
отдельной очистки мёртвого словаря; пустые значения и placeholders не
подавляются.
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые
option-группы из `ru.ts`.
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается
`BASE_LOCALE`.
- `lint-all` запускает ESLint с `--max-warnings=0`; token audit пока оставляет 4
unused-token warnings отдельным debt.
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим
этапом.
### Q3. Удалить UI-v1 (cleanup завершён)
**Статус:** выполнен. **Проверено:** `pnpm verify` и production E2E smoke
проходят; после проверки обновлены конфиги и активные документы. **Оценка:** M.
**Зависимости:** Q0, Q1, Q2.
- Удалены `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific
i18n/tests и fixtures.
- Удалены isolation plugin, его тест и все v1-исключения из lint-конфигураций.
- Убраны v1-ссылки из production-кода и активных документов; исторические
упоминания остаются в `docs/archive/`.
- Проверены production build, verify и отдельный E2E smoke production-маршрутов.
**Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют;
следующий этап — Q5.
### Q4. Тестовый контракт e2e
**Статус:** выполнен. **Проверено:** `pnpm verify` и полный Playwright smoke
проходят; 105 тестов passed, 4 known-issue теста skipped. **Оценка:** M.
**Зависимости:** Q0–Q3.
- Добавлен `docs/testing-strategy.md` с locator, fixture, i18n и `test.fixme`
policy.
- В production markup добавлены только разрешённые hooks для source/result/
verdict/empty-state; текстовые элементы получили accessible labels, фильтры —
`aria-pressed`, а каталог — семантические heading/status landmarks.
- E2E переведён на `role`, label и stable `href`; удалены `TOTAL`/`GROUPS`,
exact fuzzy counts, internal CSS selectors, `alt`-locators и полные verdict
strings. Точные verdict-переводы перенесены в unit-тесты.
- Helpers централизованы в `web/e2e/helpers/page.ts`; исправлены stale
theme/source/locale checks. Theme fixme снят.
- Q5-фиксы resize/crop, generator, Reset и error-сценария не смешивались с этим
этапом.
**Результат:** изменение формулировки, класса или порядка элементов не ломает
поведенческий тест без изменения пользовательского контракта; следующий этап —
Q6.
### Q5. Закрыть известные preview-баги
**Статус:** выполнен. **Проверено:** unit, Q5 E2E smoke и полный `pnpm verify`
проходят; активных known-issue `test.fixme` нет. **Оценка:** M–L.
**Зависимости:** Q4.
- Добавлен общий opt-in `defaultFromSource` resolver в `registry-schema`: он
принимает source dimensions, обновляет только source-aware поля при upload и
используется в UI, executor и worker. `resize-png` и `crop-png` получают
размеры текущего source; static/generator/text schemas не затронуты.
- Reset сохраняет source/text, восстанавливает source-aware defaults и запускает
deferred auto-run через debounce.
- Генераторы проверены в auto-run без кнопки Generate; кнопка не добавляется.
- `ToolError` хранится как key/vars, переводится derived-значением при выводе и
обновляется при смене локали. E2E использует независимый invalid pixel-count
сценарий.
- Resize/crop, generator, Reset и localization fixme сняты; Q6a расширяет
покрытие, не смешиваясь с исправлением этих контрактов.
**Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и
`docs/checklist-manual-testing.md`; Q6a выполнен, следующий этап — Q6b.
### Q6. Расширение unit и browser-покрытия
**Оценка:** M–L. **Зависимости:** Q4, Q5.
#### Q6a. Executor protocol, fallback и debounce
**Статус:** выполнен. Unit-тесты покрывают debounce, worker image/string/
verdict/files protocol, `ToolError` transport, constructor/postMessage/onerror/
malformed fallback, source-aware sanitization и DOM metadata. Browser smoke
проверяет rapid auto-run и no-worker fallback; deferred transport fallback
покрыт unit-тестами.
- `execute(tool, ctx)` сохраняет публичный API; `createExecutor` и protocol
handler являются внутренними test seams.
- `ToolError` не запускает direct retry повторно; transport/protocol failures
отключают worker и переходят в direct fallback.
- DOM-зависимые registry entries помечены `domOnly`, поэтому зелёный smoke не
скрывает нормальный worker fallback.
- `debounce` покрыт fake timers; E2E подтверждает последние значения при быстрых
последовательных изменениях генератора.
#### Q6b. FileResult → ZIP → download
**Статус:** выполнен. Q6a закрыл worker-транспорт; Q6b добавил проверяемый ZIP
seam, unit round-trip для `FileResult`, защиту пустого результата и error/busy
контракт download. Отдельный browser-сценарий скачивает архив и проверяет имена
частей и PNG-сигнатуры.
- `buildZipEntries` не зависит от DOM; `downloadZip` остаётся UI-обёрткой.
- `SchemaToolView` переиспользует `downloadBlob`, ловит ошибки кодирования и
блокирует повторный download на время сборки.
- E2E использует `downloadResultFile` и `unzipSync`, не добавляя новые
`data-testid`; files-инструменты не проходят через image-smoke.
#### Q6c. Output MIME/quality
**Статус:** выполнен. Q6c покрывает текущий фиксированный output-контракт
JPG/WebP/BMP, не превращаясь в UX-райз кнопки Download. Registry проверяет
согласованность MIME/extension/quality-поля, unit — границы quality, browser —
реальные сигнатуры файлов и разницу размера при изменении quality.
- `ToolEntry.output` остаётся декларативным описанием формата; новый селектор
форматов не добавляется.
- `validateOutputQuality` отделяет проверку 0..1 от DOM-рендеринга; JPEG/WebP
используют quality `1..100` из schema, BMP не имеет quality-параметра.
- E2E проверяет JPEG `FF D8 FF`, WebP `RIFF/WEBP`, BMP `BM` и PNG-фолбэк.
- Ошибки кодирования и unsupported MIME используют существующие i18n-ключи.
#### Q6d. Malformed и special PNG fixtures
**Статус:** выполнен. Runtime-fixtures покрывают CRC, truncated, palette и
16-bit без бинарных файлов в git. E2E фиксирует браузерный контракт: critical
CRC/truncated отклоняются без падения, ancillary CRC и хвост после `IEND`
допускаются, palette/16-bit декодируются. Unit фиксирует контракт анализаторов
для RGBA-данных, которые браузер отдаёт после нормализации.
- `web/e2e/helpers/fixtures.ts` собирает indexed, 16-bit, CRC-mutation,
truncation и tail-варианты из уже существующих `crc32`/`chunk`.
- Отклонение битых файлов — свойство декодера движка, а не единый продуктовый
контракт: Q6f.3 зафиксировал, что Firefox и WebKit декодируют `idat-cut` и
`truncated-no-iend`, а WebKit ещё и `crc-bad-idat`. Это ожидаемое поведение,
которое закреплено per-engine в `web/e2e/png-fixtures.spec.ts`.
- Обязательное требование при любом исходе: файл не роняет страницу, ошибка
показывается alert-ом, а не «пустым» результатом.
- Поведение `createImageBitmap` является частью browser-контракта, а не
самостоятельного PNG-парсера product-кода.
- Сырое сообщение браузера при decode-ошибке не локализуется в Q6d; это
отдельная задача в backlog.
#### Q6e. Registry-wide smoke
**Статус:** выполнен. Smoke расширен до всех 122 production tools из registry:
image→image, image→text, text→image, generator input→image и verdict. FileResult
и malformed/special PNG не дублируются — они уже покрыты Q6b/Q6d.
- `tools-smoke.spec.ts` расширен image/text-output кейсами, включая все
незакрытые color/geometry инструменты.
- `generators.spec.ts` использует единый data-harness для всех генераторов;
`single-color-png` сохраняет отдельную проверку auto-run/размеров.
- Text-input сценарии используют общий `renderText` helper, включая
`data-uri-to-png`.
- Component-тесты не добавлены: Vitest работает в node без DOM/Testing Library,
а текущий e2e-контракт уже покрывает поведение preview. Выбор test seam или
DOM-окружения перенесён в Q6f.
#### Q6f.1. Layout resolver seam
**Статус:** выполнен. Чистая логика группировки полей вынесена из
`SchemaFields.svelte` в `resolveLayoutGroups` и покрыта unit-тестами на
дедупликацию, неизвестные поля, пустые группы, `cols` и default-хвост.
- Компонент использует общий resolver, поэтому unit-тесты блокируют изменения
layout-логики без DOM/Testing Library.
- `SchemaPreview` view-model и DOM-компоненты не менялись; их seam остаётся
отдельным решением Q6f.2.
- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
#### Q6f.2. SchemaPreview model seam
**Статус:** выполнен. Вычисления `sourceValue`, `resultValue`, `formatValue` и
`hasResult` вынесены в `buildSchemaPreviewModel`; Svelte-компонент использует
модель, а unit-тесты проверяют все input/result-ветки без DOM.
- Translator передаётся в модель явно, поэтому unit-тесты не зависят от locale
state и проверяют только view-model.
- image/text/verdict/files и пустые результаты покрыты в `pnpm verify`.
- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
#### Q6f.3. Browser matrix и mobile viewport
**Статус:** выполнен. Playwright запускает четыре проекта: полный Chromium,
Firefox и WebKit для browser-critical спецификаций и mobile Chromium 390×844 с
`hasTouch` для навигации, каталога, генераторов и pipeline.
- `web/playwright.config.ts`: `browserContractTests` — `pipeline.spec.ts`,
`png-fixtures.spec.ts`, `text-and-verdicts.spec.ts`; `mobileTests` —
`navigation.spec.ts`, `catalog.spec.ts`, `generators.spec.ts`,
`pipeline.spec.ts`.
- `workers` — 1 локально и 2 в CI. Browser matrix ограничена памятью: при 12
воркерах на машине с 16 GB RAM браузеры не успевают поднять worker, и тесты
падают без ошибок (проверено и в Chromium). Локально matrix поэтому медленная,
но почти не нагружает компьютер. Per-project лимита воркеров в Playwright нет.
- Per-project `expect.timeout` не задаётся: Firefox и WebKit отдают результат
так же быстро, а расхождения объясняются памятью, а не скоростью движка.
- Декодеры движков расходятся, и это зафиксировано как ожидаемое поведение:
`png-fixtures.spec.ts` хранит список движков, отклоняющих каждый битый файл, и
не пропускает проверки. В любом браузере файл не должен ронять страницу, а
отказ декодера показывается alert-ом.
- CI устанавливает `chromium firefox webkit`; job остаётся non-blocking.
- Локальный прогон: 276 passed, 0 skipped, 0 failed. Visual snapshots и coverage
thresholds не добавлялись — это Q6f.4.
#### Q6f.4. Оставшиеся направления
- visual snapshots для kit/критичных экранов;
- coverage thresholds для `web/src/lib/`;
- при необходимости отдельное решение по DOM-тестам `SchemaToolView`/controls.
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
file-result-инструментам.
### Q7. Укрепление lint-инфраструктуры
**Оценка:** M. **Зависимости:** Q1, Q2, Q3.
- Очистить или использовать unused design tokens; после очистки решить, должен
ли unused audit быть fail-режимом.
- Добавить тесты для token audit и PostCSS HCT-плагина.
- Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов.
- Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и
hardcoded colors наравне со Svelte styles.
- Новые правила подключать только после fixtures, `test:rules` и baseline.
### Q8. Новые автоматические правила
**Оценка:** L. **Зависимости:** Q4, Q7.
#### Дублирование HTML/Svelte-разметки
Сначала провести report-only аудит новых `components/**` и `routes/**`.
Определить:
- минимальный размер клона;
- нормализацию Svelte blocks и динамических классов;
- список допустимых fixture/allowlist-случаев.
Только после оценки false positives выбирать между готовым detector и
собственным AST-правилом. Не включать detector в blocking gate сразу.
#### Пользовательские тексты вне i18n
Сделать warn-only пилот с исключениями для:
- бренда и версии;
- форматов и имён файлов;
- `X`/`Y` и технических значений;
- registry source strings;
- `/kit`;
- тестовых данных и внутренних сообщений.
Blanket-запрет всех string literals не использовать.
#### Согласованность комментариев
Автоматический lint не делать. Это review/process policy: комментарий должен
объяснять причину исключения, а `TODO`/`FIXME` — ссылаться на backlog или issue
и иметь критерий закрытия.
## 4. Что описать в `AGENTS.md`
В `AGENTS.md` добавить только следующие неочевидные правила:
1. Какой набор команд считается полным quality gate и что означает успешный
результат при warnings.
2. Production-scope и политику удаления v1: новые изменения относятся только к
production UI; исторические UI-v1-упоминания остаются в архиве.
3. Политику e2e: semantic locators, допустимый `data-testid`, запрет
бессмысленных counts/classes/alt/exact prose.
4. Политику i18n: новые пользовательские тексты через i18n; registry source,
brand, formats и `/kit` — явные исключения.
5. Жизненный цикл `test.fixme`: только для зарегистрированного бага, с ссылкой;
исправление и удаление теста происходят вместе.
6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на
работу.
7. Shared-модули `core`/`theme` требуют проверки production-потребителей.
Синтаксические списки ESLint-правил и подробные токены остаются в
`web/eslint-plugins/README.md`.
### Политика документации для агентов
`AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем
только обязательный рабочий контракт: команды, production-scope, ключевые
инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять
причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка;
длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в
`docs/` или в README рядом с соответствующей подсистемой.
Принцип выбора места документа:
1. **Поведение агента, которое нельзя пропустить** — кратко в `AGENTS.md`.
2. **Точная команда, скрипт, параметр или критерий качества** — в
`docs/quality-gates.md` или в README соответствующей подсистемы.
3. **Архитектурное решение, поток данных и границы модулей** — в
`docs/architecture-overview.md` или существующем плане.
4. **Реализация конкретного lint-правила и его fixtures** — только в
`web/eslint-plugins/README.md` и исходниках правила.
5. **Продуктовая задача, backlog и ручная приёмка** — в `docs/backlog.md`,
активном плане или `docs/checklist-manual-testing.md`.
Подробные документы не дублируются в нескольких местах: у документа должен быть
один источник истины, а остальные файлы ссылаются на него. Все подробные
материалы хранятся в `docs/`, чтобы не засорять корень репозитория.
Активный документ должен иметь статус (`draft`, `active`, `completed` или
`archived`) и понятную область применения. Архивные планы не используются как
текущие критерии приёмки без отдельной сверки с кодом. Новый документ создаётся
только когда темы нет в существующем файле; иначе обновляется существующий.
### Архивирование и срок хранения
Завершённые, отменённые или явно устаревшие документы переносятся в
`docs/archive/`, а не остаются в корне `docs/` как рабочие планы. При переносе
добавляются дата архивирования, дата удаления и причина. Архивный документ —
временная справка для быстрого просмотра прошлых решений, но не источник текущих
требований.
Срок хранения после архивирования — 30 дней. После этой даты документ можно
удалить после проверки ссылок; если он снова нужен, его восстанавливают из git
history или пересоздают как новый активный документ. Подробная процедура и
шаблон метаданных находятся в `docs/archive/README.md`.
### Какие документы подготовить
| Документ | Назначение | Когда создавать/обновлять |
| ------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа |
| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` |
| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI |
| `docs/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры |
| `docs/architecture-overview.md` | Карта `core`, registry, executor, production UI и shared-модулей | Q5 или перед крупным рефакторингом; не дублировать существующие планы |
| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами |
| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики |
Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию
backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и
checklist. `README.md` в корне остаётся кратким обзором продукта, а не вторым
руководством агента.
## 5. Порядок PR
1. **Q0** — синхронизация backlog/checklist/AGENTS.
2. **Q1** — `verify` и PR quality gate.
3. **Q2** — i18n parity и strict warning gate.
4. **Q3** — удалить v1 отдельной атомарной задачей.
5. **Q4** — тестовый контракт и stale-тесты для production UI.
6. **Q5** — preview-баги и снятие `fixme`.
7. **Q6** — расширенное покрытие.
8. **Q7** — тесты lint-инфраструктуры и unused tokens.
9. **Q8** — report-only duplicate/text audits; blocking — только после baseline.
Каждый PR должен быть атомарным и небольшим; не объединять `SchemaPreview`
refactor, новые i18n-правила и исправление продуктовых багов в одну работу.
## 6. Отдельные направления и что не начинать автоматически
- полноценный browser/visual/coverage matrix;
- полноценная PWA/API/WASM-платформа;
- `SchemaPreview` state/view-model refactor;
- plural-формы до модели словаря;
- универсальный линтер одинаковых комментариев;
- batch/архивы, region-инструменты и остальные продуктовые волны из backlog.
## 7. Решения, которые нужно принять на ревью
- Какие executor/worker/fallback сценарии войдут в первый Q6 coverage PR?
- Нужны ли browser/mobile matrix и visual snapshots до coverage thresholds?
- Какие unused design tokens можно удалить, а какие должны остаться?
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
отдельным ночным прогоном?
+14 -17
View File
@@ -181,11 +181,12 @@ buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
## 3. Волна C — обязательные фиксы (баги + дефолты)
### C1. resize-png: дефолт 0×0 → осмысленный
### C1. Source-aware defaults для dimension-полей
`geometry.ts`: `width: 0, height: 0` → `width: 512, height: 512` (или
`width: imgWidth, height: imgHeight` — но дефолт в схеме не может знать размер
изображения). **Решение:** дефолт `512×512`.
Q5 добавил общий `defaultFromSource` resolver в `registry-schema`. Для
`resize-png` и `crop-png` дефолт после загрузки равен размерам текущего source;
до загрузки используется положительный fallback `1×1`. Один-sided `0` у resize
сохраняет режим auto.
### C2. symmetric-copy-png: "keep side" не работает
@@ -198,19 +199,17 @@ buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
{top, bottom} → заменить на `left`.
### C3. Механизм проброса размеров картинки в схему
### C3. Source-aware bounds (отдельный следующий шаг)
**Проблема.** Поля, значения которых зависят от размеров исходника (crop x/y,
resize, offsets), объявлены в статической схеме (`-100000…100000`), а реальная
картинка обычно 800–4000px. Диапазон надо считать от фактического размера
изображения, а не от потолков.
Q5 реализовал только defaults dimension-полей через `SchemaContext` и
`defaultFromSource`. Динамические min/max для crop x/y, shift и resize остаются
отдельной задачей: bounds не следует смешивать с default resolver.
**Что нужно спроектировать и завести:**
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
→ `PixelImage` (width/height). Сейчас он никуда не пробрасывается — надо
прокинуть в `SchemaFields` и дальше в контролы, например через context или
проп `sourceSize`.
→ `PixelImage` (width/height); для bounds понадобится передать этот context в
`SchemaFields` и контролы.
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
источника вместо жёстких чисел. Кандидат:
@@ -228,15 +227,13 @@ resize, offsets), объявлены в статической схеме (`-100
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
размера источника; при смене картинки диапазон пересчитывается, текущее
значение пере-клампится. Дефолт и `sanitize` остаются на статической основе
(или тоже учат bound) — решить при проектировании.
значение пере-клампится.
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
(offsetX/offsetY), resize (max → maxSide).
Это отдельный шаг плана: **завести механизм**, спроектировать на ревью интерфейс
спека (`bound` vs `range: (size) => [min, max]`), затем применить к crop и
остальным полям размерности. Пока механизма нет — поля crop остаются как есть.
Это отдельный refactor после Q6/Q7; source-aware defaults из Q5 не заменяют
bounds.
### C4. verify-is-png: неверное название
+2 -3
View File
@@ -43,7 +43,7 @@
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
CLI и API (harness фазы 7 расширяется третьим рантаймом).
## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер)
## 2. Фаза 10 — PWA (после S1)
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
maskable, theme-color из дизайн-токенов, `display: standalone`.
@@ -64,8 +64,7 @@
## 3. Порядок и связи
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19
отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7.
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
GEO-текстов (`llms.txt`, описания категорий).
+50 -115
View File
@@ -1,43 +1,26 @@
# План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**.
> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21
> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не
> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт,
> аудит против рефа прекращён. Tech debt закрыт
> (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд старых файлов в
> `old/`) — **выполнено**. Покупка домена НЕ была блокером C17: домен нужен для
> S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**, Q3
> (удаление старого UI) **выполнен**. Остался C20 (шаг 6, проверка нового
> дизайна) — **следующий этап**. C21 (удалить refs) остаётся отдельной уборкой.
> Общий tech-debt baseline и следующие quality-задачи зафиксированы в
> `docs/plan-codebase-quality.md`. Покупка домена НЕ была блокером C17: домен
> нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
>
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
>
> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка
> общих компонентов на месте с алиасами токенов) — **параллельная сборка**:
> новый дизайн строится отдельно, изолируется от старого, проверяется целиком,
> затем становится основным и старый удаляется. Старое приложение остаётся
> рабочим и шippable на всём протяжении перехода.
> **Историческая стратегия перехода:** сначала новый UI собирался отдельно,
> затем стал production, после чего legacy-ветка была удалена Q3. Текущие
> критерии относятся только к production UI; старые решения и детали перехода
> остаются в архивных планах.
## 0. Почему меняем стратегию
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые токены
как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие компоненты
редактируются вживую → регресс старого UI во время работы, новый дизайн нельзя
принять изолированно.
- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые страницы
рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем его основным,
(5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги.
- **Главный технический риск и его решение.** Сейчас `web/src/app.css`
импортируется в **корневом** `web/src/routes/+layout.svelte:2` и потому
грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать новый
CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без
наследования**: (а) убрать `import '../app.css'` из корневого layout; (б)
старые маршруты обернуть группой `(old)/` с собственным layout, импортирующим
`app.css`; (в) новые маршруты (`preview/*`) — собственный layout,
импортирующий `design2.css`. SvelteKit делает code-splitting CSS по маршрутам,
а стили компонентов скоупятся сами — поэтому глобальные стили каждого дизайна
живут только в своей ветке, и префикс-обёртка `.ds2` не нужна. Старые `ui/` и
страницы не трогаются до фазы 5.
- Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить
независимо; теперь production использует только новые маршруты и `app.css`.
- Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout
для удалённого UI-v1 больше не нужны.
## 1. Источники и их оценка
@@ -188,68 +171,29 @@
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
> пригодится для сброса родительского layout.
## 6. Маршрутизация параллельных страниц
## 6. Маршрутизация: историческая схема перехода
Новые страницы живут в **отдельной ветке `preview/*`**, не трогая старые:
Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были
перенесены на production-маршруты. Эта схема нужна только для восстановления
контекста C17; текущая структура описана в разделе 7, отдельных preview- или
legacy-layouts в production больше нет.
```txt
src/routes/+layout.svelte # нейтральный, БЕЗ app.css
src/routes/(old)/+layout.svelte # импорт app.css + старая шапка/подвал
src/routes/(old)/+page.svelte # / (старое)
src/routes/(old)/demo/+page.svelte # /demo
src/routes/(old)/list-tools/...
src/routes/(old)/tools/[id]/...
src/routes/preview/+layout.svelte # импорт design2.css, <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
```
## 7. Фазы выполнения
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы не
изменяются (только перемещены в `(old)/`).
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты (move-файлы),
группа `(old)/` удаляется, `design2.css` подключается в корневом
`+layout.svelte` (как новый основной слой), временный флаг/кнопка убираются.
- **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и
интерактивные состояния приняты; детали перехода остаются в архивном
`plan-design-fix.md`.
- **C17/C18 — production-переход.** Новые страницы перенесены на реальные
корневые маршруты; `app.css` стал единственным production-стилем.
- **Q3/C19 — cleanup.** Удалены UI-v1, legacy CSS, v1-тесты, fixtures и
isolation-конфигурация; production build и verify проверяются отдельно.
- **C20 — следующий этап.** Финальный проход по брейкпоинтам и визуальной
приёмке production UI.
- **C21 — отдельная уборка.** Удалить `refs/` и `refs-html/`, если они больше не
нужны.
## 7. Фазы выполнения (1–5 по запросу)
Каждая фаза заканчивается зелёными `test`/`check`/`build` и ревью в браузере
(обе темы) против `refs-html/`.
- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все примитивы
§3 и контролы §4 как отдельные компоненты. Критерий: демо-страница kit'а
(`/preview` или временная) показывает все состояния (light/dark,
hover/disabled, все варианты Button/Segmented/Badge); unit-тесты форм не
падают; **старые страницы визуально не изменились** (изоляция работает).
- **Фаза 2 — Новые страницы.** Под `preview/*` (§6): воркспейс, demo,
list-tools, tool-page (двухпанельная раскладка из рефа: settings-panel +
preview-panel, step-cards для chain). Критерий: разметка соответствует
`refs-html/index.html`, `gradient.html`, `background-remover.html`,
`list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает в
новом стиле; старые маршруты продолжают работать как раньше.
- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран, 800px),
скриншоты против `refs-html/`, прогон старого тест-сьюта для доказательства
отсутствия регресса, RU/EN-локализация новых лейблов. Критерий: визуальное
совпадение с рефом, зелёные тесты, записанные скриншоты приняты.
- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*` → реальные
маршруты; `design2.css` подключается в корневом `+layout.svelte`; удаление
preview-группы и временного флага/кнопки. Старые страницы теперь не
используются (мёртвый код), но физически ещё в репо. Критерий: реальные
маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений.
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`,
старого `app.css` (и его токенов), неиспользуемых классов; наведение порядка в
`components/` (имя каталога и подпапок — решается на фазе 1; к C17 `kit/` уже
разложен в `components/`). Финальный проход по брейкпоинтам 1200/1100/800/480.
Критерий: `grep` по старым токенам/классам пуст, build чистый, старый дизайн
нигде не упоминается.
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня),
Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня).
Критерии каждого этапа — зелёные `test`/`check`/`build`, отсутствие остаточных
legacy-путей в production-коде и ревью в браузере для UI-изменений.
## 8. Смоук (после фазы 3, контроль на фазе 4)
@@ -267,14 +211,10 @@ src/routes/preview/tools/[id]/+page.svelte
## 9. Открытые вопросы
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
разложил его в `components/` (подпапки fields/layout/ui). На C19 — только
разложил его в `components/` (подпапки fields/layout/ui). На C21 — только
навести порядок внутри `components/`, переименование не требуется.
- **Изоляция через layout:** `app.css` вынесен из корневого layout в
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
(DevTools → вкладка Styles, нет правил из `app.css`).
- **Переключатель для проверки:** рекомендую `localStorage`-флаг + кнопка
«Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
- **Единый production CSS:** `app.css` подключается в корневом layout; отдельных
legacy-layouts и переключателей версий больше нет.
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
ключи, но допустить часть английских брендовых («приборная панель»).
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
@@ -282,8 +222,8 @@ src/routes/preview/tools/[id]/+page.svelte
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
не нужен и не создаётся. `refs/` и `refs-html/` можно удалить из репо на фазе
5 (по желанию), они не являются частью рантайма.
не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка;
их можно удалить в C21, они не являются частью рантайма.
## 10. План исполнения (мелкие коммиты)
@@ -361,10 +301,10 @@ src/routes/preview/tools/[id]/+page.svelte
### Шаг 5. Сделать новый дизайн основным
> C17 — **выполнен** (`docs/archive/plan-c17.md`): старый `(old)/` → `/v1/`,
> preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки
> импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались:
> C19 (удаление старого, раскрытие `recommended` на весь код), C20, C21.
> C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты
> перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов
> после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и
> код. Остались C20 и C21.
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
@@ -386,26 +326,21 @@ src/routes/preview/tools/[id]/+page.svelte
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
отсутствия регресса: старый `v1/` работает и не затронут.
отсутствия регресса: production-маршруты и основные пользовательские
потоки работают.
### Шаг 7. Опциональная чистка (отложен, нет срока)
### Шаг 7. Cleanup
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
из конфигурации и активной документации.
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
правды.
- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
`app_v1.css` (и его токены), неиспользуемые классы. **И раскрыть ESLint
`recommended` на весь код** — убрать scoped-блок `newCode` в
`web/eslint.config.js`, прогнать `lint` и починить всплывшие ошибки.
_Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) —
это не «старый дизайн», у них свой линт-долг; расширение `recommended` на
них может выдать много ошибок. Решить на C19: либо чиним сразу, либо
расширяем scoped только на `src/routes/**` + `src/lib/components/**`._
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
каталога).
> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской
> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит
> только когда нет горящих задач и хочется навести порядок.
> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
> После удаления проверяются только production-маршруты и общие модули.
>
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
+34 -35
View File
@@ -50,11 +50,10 @@
клиенте (localStorage) — для SEO не учитываем.
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`) ломает
абсолютные URL, canonical и «корневость» страниц. Свой домен + корень.
- **URL-схема инструментов:** короткие плоские адреса. После C17 вынести
страницы инструментов в корень: `/crop-png` (как у конкурента), каталог —
`/tools`. Переезд `/preview/tools/[id]` → `/[id]` сделать **одновременно с
C17** (в одном атомарном коммите): после индексации переименование URL = 301 и
потеря позиций.
- **URL-схема инструментов:** короткие плоские адреса. После C17 страницы
инструментов находятся в корне на `/tools/[id]`, каталог — на `/list-tools`;
текущую схему не менять без отдельного SEO-плана: после индексации
переименование URL означает 301 и потерю позиций.
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя
подрывать собственными скриптами.
@@ -68,10 +67,10 @@
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
JS. Это главный технический козырь.
- 121 инструмент в `registry-new` с «SEO-шными» id (`crop-png`,
- 122 инструмента в `web/src/lib/registry` с «SEO-шными» id (`crop-png`,
`add-border-png`, …) — URL уже повторяют паттерн конкурента.
- Уникальные `<title>` + `<meta description>` на страницах инструментов старой
ветки; механика переносится на новую при C17.
- На корневых страницах инструментов есть базовые `<title>` и `robots`; полный
SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2.
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
инструментов против конкурента: `docs/tools-map.md` (у них 311, из них 107 —
нишевые клоны-серии Logo*/Icon*/Stamp* поверх обычных операций).
@@ -79,17 +78,17 @@
Чего нет (пробелы закрываются этапами ниже):
| Пробел | Где сейчас | Этап |
| ----------------------------------------------------- | ----------------------------------- | ------ |
| `sitemap.xml` | нет | S1 |
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
| `rel=canonical` | нет | S1 |
| OG/Twitter-теги, OG-картинки | нет | S1 |
| `noindex, nofollow` на страницах инструментов preview | `preview/tools/[id]/+page.svelte` | S1/C17 |
| Контент страницы (H1-текст, how-to, FAQ, related) | только title/description | S2 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + приватная аналитика | нет | S4 |
| Пробел | Где сейчас | Этап |
| ----------------------------------------------------- | ---------------------------------------- | ---- |
| `sitemap.xml` | нет | S1 |
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
| `rel=canonical` | нет | S1 |
| OG/Twitter-теги, OG-картинки | нет | S1 |
| `noindex, nofollow` на страницах инструментов preview | `web/src/routes/tools/[id]/+page.svelte` | S1 |
| Контент страницы (H1-текст, how-to, FAQ, related) | только title/description | S2 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + приватная аналитика | нет | S4 |
## 2. Конкурент (onlinepngtools.com)
@@ -111,7 +110,7 @@
## 3. Стратегия
1. **Не лобовая, а ядро интентов.** 80% объёма поиска приносят ~30–50 запросов.
Покрытие инструментов у нас почти полное (121); приоритет — контент и
Покрытие инструментов у нас почти полное (122); приоритет — контент и
качество страниц ядра, затем хвосты.
2. **Страница = лендинг, а не «пустая коробка».** Шаблон контента: H1 → 1–2
абзаца «что делает» → как использовать (шаги) → параметры → FAQ (3–5 вопросов
@@ -139,7 +138,7 @@
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
**domain-required** (после покупки и DNS-настройки).
#### S1-free: без домена (параллельно с C18–C19)
#### S1-free: без домена (параллельно с quality-планом)
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
@@ -150,7 +149,7 @@
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть
(backlog №7, райз «выбор формата/качества»). Продуктовая основа «image
tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
параллельно с C18–C19.
параллельно с текущими quality-задачами.
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в
png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`,
@@ -163,12 +162,12 @@
(`tools/[id]/+page.svelte`) — делается одновременно с S1d (canonical)
чтобы Google сразу проиндексировал с правильным доменом.
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
`build/` из `TOOLS` реестра (`registry-new`) + статических страниц. Вызов
из `pnpm build` после `vite build` (adapter-static пишет в `build/`,
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
опционального `BASE_PATH`.
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до
C19; после — вернуть минимальный allow-all).
`build/` из `TOOLS` реестра (`web/src/lib/registry`) + статических
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`),
учёт опционального `BASE_PATH`.
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
cleanup — вернуть минимальный allow-all.
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
актуальному маршруту), OG-теги (title/description/type/url/image),
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
@@ -178,12 +177,12 @@
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
> canonical/OG присутствуют на всех страницах; e2e зелёные.
### S2. Контентный слой (параллельно C18–C19, волнами по категориям)
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
вручную пачками по категориям (самый трудоёмкий этап: ~121 × 2 локали; RU
вручную пачками по категориям (самый трудоёмкий этап: ~122 × 2 локали; RU
— отложить до решения о `/ru/`).
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
@@ -239,7 +238,7 @@
## 5. Приоритизация инструментов (первая волна контента)
Источник — `docs/tools-map.md` (121 реализовано; у конкурента 311, из них 107 —
Источник — `docs/tools-map.md` (122 реализовано; у конкурента 311, из них 107 —
клоны-серии). Первая волна — топ-интенты, где конкуренты ранжируются, а у нас
уже есть инструмент и он не хуже:
@@ -329,10 +328,10 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2
(домен) — блокер только для S1-domain (canonical + sitemap + снятие
`noindex`). После индексации переименование = переезд с 301, потеря позиций и
ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17.
- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
блокируют). R2 (домен) — блокер только для S1-domain (canonical + sitemap +
снятие `noindex`). После индексации переименование = переезд с 301, потеря
позиций и ссылок — не делать.
## 7. Что осознанно НЕ делаем
+134
View File
@@ -0,0 +1,134 @@
# Quality gates
> Статус: **active**. Каноническое описание быстрых проверок репозитория.
> Краткие обязательные команды остаются в `AGENTS.md`.
## Каноническая команда
Запускать из корня репозитория:
```text
pnpm verify
```
`verify` выполняет последовательно:
1. `pnpm --dir web check` — SvelteKit sync и `svelte-check`.
2. `pnpm --dir web test` — полный Vitest-набор.
3. `pnpm --dir web test:rules` — отдельный быстрый прогон тестов ESLint-правил.
4. `pnpm --dir web lint:all` — ESLint, Stylelint и token audit.
5. `pnpm --dir web exec prettier --check .` — форматирование кода `web/`.
6. `pnpm check:docs` — форматирование Markdown.
7. `pnpm --dir web build` — production build.
Полный `test` уже включает тесты lint-правил; отдельный `test:rules` в `verify`
оставлен как явный быстрый диагностический этап.
## E2E
E2E не входит в `verify`, потому что Playwright поднимает build и статический
сервер. Locator, fixture, i18n и `test.fixme`-политика описаны в
`docs/testing-strategy.md`:
```text
pnpm --dir web test:e2e
```
Запускать его обязательно для изменений маршрутов, загрузки файла, результата,
download, темы или локализации. Изменения runtime fixtures/загрузки файла
проверяются отдельным `web/e2e/png-fixtures.spec.ts`; ручные бинарники из
`tests/fixtures/` не входят в `verify`. В текущем CI это отдельный non-blocking
baseline; после закрытия известных ошибок его можно сделать blocking.
Локально прогон идёт с `workers: 1` и заметно медленнее: это осознанный размен,
экономит память и оставляет компьютер отзывчивым. Playwright не умеет
per-project лимит воркеров, а browser matrix упирается в память, а не CPU.
Тяжёлые прогоны запускаются по команде пользователя — по одному, без фонового
запуска и циклов повторов; детали в `docs/agent-workflow.md`.
## Быстрые подмножества
| Изменение | Команда |
| ---------------------------- | ------------------------------------------------------- |
| Только Markdown | `pnpm check:docs` |
| TypeScript/Svelte | `pnpm --dir web check` и `pnpm --dir web test` |
| Lint-правило или lint-конфиг | `pnpm --dir web test:rules` и `pnpm --dir web lint:all` |
| Пользовательский поток | `pnpm --dir web test:e2e` |
| Fixtures/загрузка файла | `pnpm --dir web test:e2e` |
| Полный локальный gate | `pnpm verify` |
## Errors и warnings
- `errors` завершают соответствующий инструмент ненулевым кодом.
- После Q2 ESLint запускается с `--max-warnings=0`: новые ESLint warnings
блокируют gate.
- Token audit пока оставляет unused-token warnings отдельным неблокирующим
сообщением.
- `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным
предупреждением token audit; это следующий debt Q7.
- `dict-consistency` использует base-locale fallback для registry metadata и
pattern-based policy для временно неиспользуемых `tools.*.params`; это не
молчаливый `allowPaths`-список.
## CI
`.github/workflows/quality.yml`:
- `verify` блокирует PR при typecheck, unit, rule-test, lint, format, docs или
build ошибке;
- `e2e` запускается отдельным job после verify и пока не блокирует PR.
`.github/workflows/deploy.yml` перед production build запускает тот же
`pnpm verify`, поэтому deploy не должен проходить при ошибке быстрых проверок.
### Что и когда запускается
| Workflow | Триггер | Jobs |
| ------------------------ | ------------------------ | --------------------------- |
| `Deploy to GitHub Pages` | push в `main`, dispatch | `verify`, `build`, `deploy` |
| `Quality` | `pull_request`, dispatch | `verify`, `e2e` |
Следствие: при прямом пуше в `main` выполняется только быстрый gate — `verify`
внутри deploy-workflow. Browser matrix из четырёх проектов на этом прогоне не
запускается вообще, поэтому зелёный deploy-run нельзя читать как «e2e прошёл».
### Проверка результатов CI после пуша
Первый реальный прогон browser matrix происходит на runner, а не на локальной
машине: локально Firefox и WebKit проходят на 1 воркере, но состав, скорость и
память Linux-раннера отличаются. Поэтому после пуша разработчика:
1. дождаться завершения workflow и посмотреть, какие job вообще запустились;
2. отдельно открыть non-blocking `e2e`, если он был в прогоне: его красный
статус не блокирует PR, но остаётся падением, и его нельзя выдавать за
«зелёный по умолчанию»;
3. если `e2e` не запускался (прямой пуш в `main`), сказать это прямо: matrix на
раннере ещё не проверена, и её можно прогнать через PR или
`gh workflow run quality.yml`;
4. сообщить фактический результат (passed, failed, skipped) в отчёте агента.
Красный `e2e` только из-за ресурсов раннера фиксируется как ограничение
окружения: сначала проверяется один проект и один spec, а не увеличиваются
таймауты под конкретную машину.
### Измеренная стоимость прогона
Замер на ручном запуске `quality.yml` (276 e2e-тестов, 4 проекта,
ubuntu-latest):
| Этап | Время |
| ----------------- | -------- |
| `verify` job | ~1 мин |
| `e2e` job целиком | ~3.1 мин |
| из них сами тесты | ~2.2 мин |
| весь run | ~4.2 мин |
Browser matrix стоит около трёх минут, поэтому её дешёво держать на каждом PR.
Полный прогон локально на 1 воркере — заметно дольше (~7 мин), и именно поэтому
локальные правила нагрузки описаны в `docs/agent-workflow.md`.
## Локальная диагностика
При медленном полном прогоне сначала запускай минимальную команду из таблицы, а
перед ревью или merge — `pnpm verify`. После каждого шага плана результат команд
и текущий baseline фиксируются в отчёте агента.
+9 -8
View File
@@ -1,12 +1,13 @@
# План разработки: easy-png-tools
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
> каталог переведён в типизированный `registry` (121/125), новый дизайн по
> `plan-redesign.md` стал основным на корневых маршрутах (C17/C18 выполнены),
> старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19 в бэклоге).
> Tech debt закрыт. Следующий этап: C20 (проверка нового дизайна,
> `plan-redesign.md`, §10) + SEO S1-free (`plan-seo.md`) + баги. Домен нужен для
> SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
> каталог переведён в типизированный `registry` (122 текущих инструмента), новый
> дизайн по `plan-redesign.md` стал единственным production UI на корневых
> маршрутах; UI-v1 удалён в Q3. Tech debt не закрыт полностью; текущий baseline
> и следующие quality-задачи зафиксированы в `docs/plan-codebase-quality.md`.
> Следующий этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO
> S1-free (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`,
> S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
> `docs/plan-platform.md`.
## 0. Решения
@@ -161,8 +162,8 @@ wasm.
### Фаза 10 — PWA — см. `docs/plan-platform.md`
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
устройство»), установка как приложение. Wasm-фаз не требует, идёт после C17/C19
и S1 из `plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из
`plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
## 3. Правила сравнения (для фаз 4–7)
+141
View File
@@ -0,0 +1,141 @@
# Стратегия e2e-тестирования
> Статус: **active**. Канонические правила для `web/e2e/`; продуктовые решения и
> известные баги остаются в `docs/backlog.md` и
> `docs/checklist-manual-testing.md`.
## 1. Что проверяем
E2E проверяет пользовательский контракт production UI: открытие маршрута,
доступность элемента, загрузку или ввод данных, появление результата, ошибку,
скачивание и переключение локали. Внутренняя реализация, порядок карточек,
CSS-классы и конкретный формат временного URL не являются контрактом.
Точные переводы проверяются в unit-тестах i18n. В e2e проверяется поведение и
отдельный RU/EN smoke, а не полный локализованный текст каждого verdict.
## 2. Приоритет locator-ов
1. `getByRole()` с доступным именем и `getByLabel()`.
2. Стабильные `href` или `id`, если семантического API нет.
3. Разрешённые `data-testid` только для визуальных границ, которые нельзя
надёжно назвать через accessibility API:
- `source-image`;
- `result-image`;
- `result-verdict`;
- `empty-state`.
4. `input[type="file"]` и download-button допускаются только внутри
`web/e2e/helpers/page.ts`.
Нельзя добавлять `data-testid` для счётчиков, групп, metadata, verdict текста,
кнопок темы или переключателя языка без отдельного обоснования.
## 3. Запрещённые зависимости
В e2e не фиксируются:
- абсолютные `TOTAL`, `GROUPS` и другие snapshot-числа каталога;
- точное число fuzzy-search результатов или порядок карточек;
- внутренние CSS-классы и HTML-структура, если есть role/label/href;
- `alt` как способ найти результат или источник;
- полные локализованные предложения и длинные тире в ожиданиях;
- локализованные имена контролов в общих сценариях без явной установки `en`.
Поведенческий тест может проверять, что известный инструмент видим или скрыт,
ошибка имеет текст и не является сырым i18n-ключом, результат доступен для
скачивания. Это не требует повторять словарь в e2e.
## 4. Локаль и fixtures
Обычные сценарии запускаются с `en` через `useEnglish()` или `openTool()`. RU/EN
проверяется отдельным `i18n.spec.ts`; после смены локали locator не должен
зависеть от старого accessible name.
Изображения создаются воспроизводимыми helpers в `web/e2e/helpers/fixtures.ts`.
Тесты не добавляют большие бинарные файлы и не зависят от локальных
пользовательских файлов. Runtime-fixtures для malformed/special PNG проверяют
browser-контракт: critical CRC и truncated отклоняются движком, ancillary CRC и
хвост после `IEND` допускаются, palette/16-bit декодируются. Отказ декодера —
свойство движка, поэтому ожидание задаётся per-engine в
`web/e2e/png-fixtures.spec.ts`, а не через skip. Общее требование для всех
движков: битый файл не роняет страницу, отказ показывается alert-ом, а успешный
декод даёт результат без ошибки. `toPass()` допускается только для ограниченного
ожидания hydration или асинхронного результата; он не должен превращать проверку
продукта в бесконечный retry.
Чистая логика схемы, layout и preview-модели (defaults, sanitization,
`resolveLayoutGroups`, `buildSchemaPreviewModel`) проверяется в
`src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в Playwright.
Component-тесты не дублируют e2e-контракт.
## 5. Helpers и `test.fixme`
`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`,
`renderText`, `resultImage`, `sourceImage`, `verdictStatus`, `textResult`,
`errorAlert`, `emptyState`, `rangeInput`, `downloadResult`, `downloadResultFile`
и `downloadResultBytes`. Специфика должна вызывать эти helpers, а не повторять
raw-селекторы. Harness выбирается по контракту: image — upload + `resultImage`,
text — `renderText` + результат, generator — auto-run без upload, verdict —
`verdictStatus`, files — отдельный ZIP-сценарий.
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
checklist. В комментарии указывается ссылка на неё. После исправления тест и
ссылка обновляются вместе; в текущем Q6e baseline активных fixme нет.
Если тест сравнивает два состояния одного элемента, он ждёт изменения значения
(`not.toHaveText`, `not.toHaveAttribute`), а не его видимости. Пересчёт идёт
через debounce, поэтому `toBeVisible()` после второго ввода проходит на старом
значении. Такой тест флакает по таймингу и обычно ловится на CI, а не локально.
## 6. Проверки
Для изменений e2e выполняются:
```text
pnpm --dir web check
pnpm --dir web test:rules
pnpm --dir web lint:all
pnpm --dir web exec prettier --check .
pnpm check:docs
pnpm --dir web test:e2e
pnpm verify
```
`verify` не включает Playwright: E2E остаётся отдельным тяжёлым gate. В CI
non-blocking E2E не считается заменой локальному smoke; перед ревью фиксируются
число passed, skipped и известные fixme.
## 7. Browser matrix
`web/playwright.config.ts` запускает четыре проекта:
| Проект | Движок | Спецификации |
| ----------------- | -------- | ---------------------------------------------------------------------- |
| `chromium` | Chromium | все e2e |
| `firefox` | Firefox | `pipeline`, `png-fixtures`, `text-and-verdicts` |
| `webkit` | WebKit | `pipeline`, `png-fixtures`, `text-and-verdicts` |
| `mobile-chromium` | Chromium | `navigation`, `catalog`, `generators`, `pipeline`, 390×844, `hasTouch` |
- Новые engine-специфичные фичи сначала добавляются в `chromium`, потом
решается, попадает ли spec в `browserContractTests`.
- Расхождения декодеров не ужесточаются и не скрываются skip-ом: они описаны в
тесте как ожидаемое поведение движка.
| Fixture | Отклоняют |
| ------------------------------------ | ------------------------- |
| `crc-bad-idat.png` | Chromium, Firefox |
| `truncated-header.png` | Chromium, Firefox, WebKit |
| `truncated-no-iend.png` | Chromium |
| `idat-cut.png` | Chromium |
| ancillary CRC, tail, palette, 16-bit | ни один движок |
- Firefox и WebKit работают с теми же таймаутами, что и Chromium: движок не
причина расхождений, поэтому per-project `expect.timeout` не задаётся.
- `workers` — 1 локально и 2 в CI. Playwright не умеет per-project лимит
воркеров, а browser matrix ограничена памятью, а не CPU: при высокой
параллельности браузеры на машине с 16 GB RAM не успевают поднять worker, и
тесты падают без ошибок. Локальный matrix поэтому медленный, но не нагружает
компьютер.
- Локальный baseline: 276 тестов, 276 passed, 0 skipped, 0 failed.
- Visual snapshots и coverage thresholds не часть matrix; они вынесены в
`docs/plan-codebase-quality.md` (Q6f.4).
+3 -2
View File
@@ -87,8 +87,9 @@
### Геометрия
- resize-png — width (0=авто), height (0=авто), keepAspect
- crop-png — x, y, width, height
- resize-png — width/height (размеры source по умолчанию; 0=авто для одной
стороны), keepAspect
- crop-png — x, y, width/height (полная область source по умолчанию)
- rotate-png — angle (90/180/270)
- flip-png — axis (h/v)
- skew-png — degX, degY
+1
View File
@@ -5,6 +5,7 @@
"main": "index.js",
"scripts": {
"test": "pnpm --dir web test",
"verify": "pnpm --dir web check && pnpm --dir web test && pnpm --dir web test:rules && pnpm --dir web lint:all && pnpm --dir web exec prettier --check . && pnpm check:docs && pnpm --dir web build",
"dev": "pnpm --dir web dev",
"build": "pnpm --dir web build",
"format": "pnpm format:docs && pnpm --dir web format",
+30 -35
View File
@@ -1,55 +1,50 @@
import { expect, test } from "playwright/test";
import { expectNoErrors, trackErrors } from "./helpers/page";
import {
expectNoErrors,
toolLink,
trackErrors,
useEnglish,
} from "./helpers/page";
const TOTAL = 122;
const GROUPS = 8;
test.beforeEach(async ({ page }) => {
await useEnglish(page);
});
test("catalog shows total and all groups", async ({ page }) => {
test("catalog exposes a total and category headings", async ({ page }) => {
const sink = trackErrors(page);
await page.goto("/list-tools");
await expect(page.locator(".catalog-total b")).toHaveText(String(TOTAL));
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
const total = page.getByRole("status", { name: /tools available/i });
await expect(total).toBeVisible();
await expect(total).toHaveAttribute("aria-label", /^\d+ /);
await expect(page.getByRole("heading", { level: 2 }).first()).toBeVisible();
expectNoErrors(sink);
});
test("catalog search narrows results", async ({ page }) => {
await page.goto("/list-tools");
await expect(async () => {
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
await expect(page.locator(".tool-card")).toHaveCount(1);
}).toPass();
await expect(page.locator(".tool-card").first()).toContainText("Resize PNG");
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
await expect(toolLink(page, "resize-png")).toBeVisible();
await expect(toolLink(page, "flip-png")).toBeHidden();
});
test("category filter shows only matching group and resets on ALL", async ({
page,
}) => {
test("category filter narrows the catalog and resets", async ({ page }) => {
await page.goto("/list-tools");
const allButtons = page.locator(".tool-card");
const allCount = await allButtons.count();
expect(allCount).toBe(TOTAL);
const convert = page.getByRole("button", { name: /convert/i, exact: true });
const all = page.getByRole("button", { name: /^all$/i });
await expect(async () => {
await page.getByRole("button", { name: "CONVERT" }).click();
await expect(page.locator(".catalog-group")).toHaveCount(1);
}).toPass();
await expect(page.locator(".catalog-group")).toContainText("Convert");
const convertCards = await page.locator(".tool-card").count();
expect(convertCards).toBeGreaterThan(0);
expect(convertCards).toBeLessThan(allCount);
await convert.click();
await expect(convert).toHaveAttribute("aria-pressed", "true");
await expect(toolLink(page, "convert-png-to-jpg")).toBeVisible();
await expect(toolLink(page, "flip-png")).toBeHidden();
await expect(async () => {
await page.getByRole("button", { name: "ALL" }).click();
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
}).toPass();
await expect(page.locator(".tool-card")).toHaveCount(allCount);
await all.click();
await expect(all).toHaveAttribute("aria-pressed", "true");
await expect(toolLink(page, "flip-png")).toBeVisible();
});
test("workspace search matches a tool by title", async ({ page }) => {
await page.goto("/");
await expect(async () => {
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
await expect(page.locator(".tool-card")).toHaveCount(1);
}).toPass();
await expect(page.locator(".tool-card").first()).toContainText("Flip PNG");
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
await expect(toolLink(page, "flip-png")).toBeVisible();
await expect(toolLink(page, "resize-png")).toBeHidden();
});
+54 -16
View File
@@ -1,24 +1,62 @@
import { expect, test } from "playwright/test";
import { expectNoErrors, openTool, trackErrors } from "./helpers/page";
import {
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
} from "./helpers/page";
test.describe("generators — known UI gap (#checklist, п.1)", () => {
// FIXME: Генераторы (21/121) открываются, но в UI нет ни полей схемы, ни кнопки
// «Generate» — только RU/EN/Reset. Пока баг открыт — fixme с ожидаемым
// сценарием; после фикса убрать fixme.
test.fixme("single-color-png renders Generate controls and produces a result", async ({
const GENERATOR_IDS = [
"create-empty-png",
"random-noise-png",
"linear-gradient-png",
"color-spectrum-png",
"random-colors-png",
"draw-grid-png",
"placeholder-png",
"blend-two-png",
"step-colors-png",
"emoji-to-png",
"color-wheel-png",
"complementary-png",
"triadic-png",
"tetradic-png",
"analogous-png",
"monochromatic-png",
"shades-png",
"mix-colors-png",
"sort-colors-png",
"text-to-png",
] as const;
test.describe("generators", () => {
test("single-color-png auto-runs and reacts to parameters", async ({
page,
}) => {
await openTool(page, "single-color-png");
const generate = page.getByRole("button", { name: "Generate" });
await expect(generate).toBeVisible();
await generate.click();
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
});
test("create-empty-png page opens without errors", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "create-empty-png");
await expect(page.locator("h1")).toContainText("Create");
await openTool(page, "single-color-png");
await expect(resultImage(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0);
await page.getByRole("spinbutton", { name: "Width" }).fill("32");
await page.getByRole("spinbutton", { name: "Height" }).fill("24");
await expect(resultImage(page)).toHaveJSProperty("naturalWidth", 32);
await expect(resultImage(page)).toHaveJSProperty("naturalHeight", 24);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
for (const id of GENERATOR_IDS) {
test(`tool ${id} produces a result image`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, id);
await expect(resultImage(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(
0,
);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
}
});
+176 -8
View File
@@ -23,6 +23,22 @@ function crc32(data: Uint8Array): number {
return (c ^ 0xffffffff) >>> 0;
}
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
function makeIhdr(
width: number,
height: number,
bitDepth: number,
colorType: number,
): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = bitDepth;
ihdr[9] = colorType;
return ihdr;
}
function chunk(type: string, data: Buffer): Buffer {
const out = Buffer.alloc(8 + data.length + 4);
out.writeUInt32BE(data.length, 0);
@@ -32,18 +48,23 @@ function chunk(type: string, data: Buffer): Buffer {
return out;
}
function findChunk(png: Buffer, type: string): number {
let offset = PNG_SIGNATURE.length;
while (offset + 12 <= png.length) {
const length = png.readUInt32BE(offset);
const chunkType = png.subarray(offset + 4, offset + 8).toString("ascii");
if (chunkType === type) return offset;
offset += 12 + length;
}
throw new Error(`PNG chunk ${type} not found`);
}
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
export function makePng(
width: number,
height: number,
pixelAt: (x: number, y: number) => [number, number, number, number],
): Buffer {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const stride = width * 4 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
@@ -57,13 +78,100 @@ export function makePng(
}
}
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 8, 6)),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function makeIndexedPng(
width: number,
height: number,
palette: [number, number, number][],
pixelAt: (x: number, y: number) => number,
): Buffer {
const plte = Buffer.alloc(palette.length * 3);
for (const [index, color] of palette.entries()) {
plte[index * 3] = color[0];
plte[index * 3 + 1] = color[1];
plte[index * 3 + 2] = color[2];
}
const stride = width + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
raw[y * stride + 1 + x] = pixelAt(x, y);
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 8, 3)),
chunk("PLTE", plte),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function make16BitPng(
width: number,
height: number,
pixelAt: (x: number, y: number) => [number, number, number, number],
): Buffer {
const stride = width * 8 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const [r, g, b, a] = pixelAt(x, y);
const p = y * stride + 1 + x * 8;
raw.writeUInt16BE(r, p);
raw.writeUInt16BE(g, p + 2);
raw.writeUInt16BE(b, p + 4);
raw.writeUInt16BE(a, p + 6);
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 16, 6)),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function withPngChunk(png: Buffer, type: string, data: Buffer): Buffer {
const iendOffset = png.length - 12;
return Buffer.concat([
png.subarray(0, iendOffset),
chunk(type, data),
png.subarray(iendOffset),
]);
}
export function withBadChunkCrc(png: Buffer, type: string): Buffer {
const copy = Buffer.from(png);
const offset = findChunk(copy, type);
const length = copy.readUInt32BE(offset);
copy[offset + 8 + length + 3] ^= 0xff;
return copy;
}
export function truncatePng(png: Buffer, length: number): Buffer {
return Buffer.from(png.subarray(0, length));
}
export function truncateChunkData(
png: Buffer,
type: string,
length: number,
): Buffer {
const offset = findChunk(png, type);
return truncatePng(png, offset + 8 + length);
}
export function withTrailingBytes(png: Buffer, tail: Buffer): Buffer {
return Buffer.concat([png, Buffer.from(tail)]);
}
const checker = (x: number, y: number): [number, number, number, number] =>
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
@@ -102,6 +210,66 @@ export const corruptPng: SourceFile = {
buffer: Buffer.from("this is definitely not a png file", "utf8"),
};
const specialPng = makePng(8, 8, checker);
const specialWithText = withPngChunk(
specialPng,
"tEXt",
Buffer.from("Comment\0q6d", "ascii"),
);
const palette4 = [
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[255, 255, 255],
] as [number, number, number][];
const palette256 = Array.from({ length: 256 }, (_, index) => [
index,
(index * 3) % 256,
(index * 7) % 256,
]) as [number, number, number][];
export const crcBadIdatPng = asSourceFile(
withBadChunkCrc(specialPng, "IDAT"),
"crc-bad-idat.png",
);
export const crcBadAncillaryPng = asSourceFile(
withBadChunkCrc(specialWithText, "tEXt"),
"crc-bad-ancillary.png",
);
export const truncatedHeaderPng = asSourceFile(
truncatePng(specialPng, PNG_SIGNATURE.length + 12 + 13),
"truncated-header.png",
);
export const truncatedNoIendPng = asSourceFile(
truncatePng(specialPng, specialPng.length - 12),
"truncated-no-iend.png",
);
export const idatCutPng = asSourceFile(
truncateChunkData(specialPng, "IDAT", 4),
"idat-cut.png",
);
export const garbageTailPng = asSourceFile(
withTrailingBytes(specialPng, Buffer.from("trailing-garbage", "ascii")),
"garbage-tail.png",
);
export const palette4Png = asSourceFile(
makeIndexedPng(
8,
8,
palette4,
(x, y) => (Math.floor(x / 2) + Math.floor(y / 2)) % palette4.length,
),
"palette-4.png",
);
export const palette256Png = asSourceFile(
makeIndexedPng(16, 16, palette256, (x, y) => (x + y * 16) % 256),
"palette-256.png",
);
export const sixteenBitPng = asSourceFile(
make16BitPng(8, 8, (x, y) => [x * 8000, y * 8000, 32768, 65535]),
"16bit.png",
);
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
export const svgMarkup =
+85 -17
View File
@@ -1,6 +1,19 @@
import { expect, type Page } from "playwright/test";
import { readFile } from "node:fs/promises";
import {
expect,
type Download,
type Locator,
type Page,
} from "playwright/test";
import type { SourceFile } from "./fixtures";
export const TEST_IDS = {
source: "source-image",
result: "result-image",
verdict: "result-verdict",
empty: "empty-state",
} as const;
export type ErrorSink = { errors: string[] };
export function trackErrors(page: Page): ErrorSink {
@@ -12,39 +25,94 @@ export function trackErrors(page: Page): ErrorSink {
return { errors };
}
export async function useEnglish(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
}
export async function openTool(page: Page, id: string): Promise<void> {
await useEnglish(page);
await page.goto(`/tools/${id}`);
await expect(page.locator(".schema-tool h1")).toBeVisible();
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
}
export function toolLink(page: Page, id: string): Locator {
return page.locator(`a[href="/tools/${id}"]`);
}
export function resultImage(page: Page): Locator {
return page.getByTestId(TEST_IDS.result);
}
export function sourceImage(page: Page): Locator {
return page.getByTestId(TEST_IDS.source);
}
export function verdictStatus(page: Page): Locator {
return page.getByTestId(TEST_IDS.verdict);
}
export function textResult(page: Page): Locator {
return page.getByLabel("Text result");
}
export function errorAlert(page: Page): Locator {
return page.getByRole("alert");
}
export function emptyState(page: Page): Locator {
return page.getByTestId(TEST_IDS.empty);
}
export function rangeInput(page: Page, name: RegExp | string): Locator {
return page.getByRole("slider", { name });
}
export async function renderText(page: Page, value: string): Promise<void> {
const input = page.getByRole("textbox", { name: "Text data" });
await input.fill(value);
await page.getByRole("button", { name: "Render text" }).click();
}
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
await page.locator('.actions input[type="file"]').setInputFiles({
const input = page.locator('input[type="file"]');
await expect(input).toHaveCount(1);
await input.setInputFiles({
name: file.name,
mimeType: file.mimeType,
buffer: file.buffer,
});
}
export async function metaValue(page: Page, caption: string): Promise<string> {
const row = page.locator(".meta-row", { hasText: caption });
await expect(row).toBeVisible();
const value = await row.locator(".meta-value").textContent();
return value?.trim() ?? "";
}
export async function suggestedDownloadName(
page: Page,
selector: string,
): Promise<string> {
export async function downloadResultFile(page: Page): Promise<Download> {
const button = page.getByRole("button", { name: "Download result" });
await expect(button).toBeVisible();
const [download] = await Promise.all([
page.waitForEvent("download"),
page.locator(selector).click(),
button.click(),
]);
return download.suggestedFilename();
return download;
}
export async function downloadResultBytes(
page: Page,
): Promise<{ name: string; bytes: Uint8Array }> {
const download = await downloadResultFile(page);
const path = await download.path();
if (!path) throw new Error("download path is unavailable");
return {
name: download.suggestedFilename(),
bytes: new Uint8Array(await readFile(path)),
};
}
export async function downloadResult(page: Page): Promise<string> {
return (await downloadResultFile(page)).suggestedFilename();
}
export async function expectNoErrorAlert(page: Page): Promise<void> {
await expect(page.locator('[role="alert"]')).toHaveCount(0);
await expect(errorAlert(page)).toHaveCount(0);
}
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
+25 -22
View File
@@ -1,13 +1,14 @@
import { expect, test } from "playwright/test";
import { opaquePng } from "./helpers/fixtures";
import { openTool, uploadImage } from "./helpers/page";
import { pixelRow } from "./helpers/fixtures";
import { openTool, toolLink, useEnglish } from "./helpers/page";
test("поиск каталога находит по названию из другой локали", async ({
page,
}) => {
await useEnglish(page);
await page.goto("/list-tools");
const input = page.locator(".catalog-search input");
const flipCard = page.locator('a.tool-card[href*="flip-png"]');
const input = page.getByRole("textbox");
const flipCard = toolLink(page, "flip-png");
await input.fill("отразить");
await expect(flipCard).toContainText("Flip PNG");
await page.getByRole("button", { name: "RU", exact: true }).click();
@@ -22,27 +23,29 @@ test("RU/EN: переключение переводит заголовок ин
await openTool(page, "resize-png");
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
const enBtn = page.getByRole("button", { name: "EN", exact: true });
await expect(async () => {
await ruBtn.click();
await expect(page.locator(".schema-tool h1")).toHaveText(
"Изменить размер PNG",
);
}).toPass();
const heading = page.getByRole("heading", { level: 1 });
await ruBtn.click();
await expect(heading).toHaveText("Изменить размер PNG");
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
await enBtn.click();
await expect(page.locator(".schema-tool h1")).toHaveText("Resize PNG");
await expect(heading).toHaveText("Resize PNG");
});
// Валится на известном баге resize-png (дефолтные 0-параметры дают ошибку при
// аплоаде, см. known-issues.spec.ts). Здесь он используется как детерминированная
// ошибка: важно, что её текст локализован, а не сырой ключ.
// TODO: найти другую ошибку, эта будет исправлена при фиксе resize-png
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
const alert = page.locator("[role='alert']");
test("ошибка run'а локализуется и переводится при смене языка", async ({
page,
}) => {
await openTool(page, "bytes-to-png");
await page
.getByRole("textbox", { name: "Text data" })
.fill(pixelRow(33, [255, 0, 0, 255]));
await page.getByRole("button", { name: "Render text" }).click();
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
const text = (await alert.textContent()) ?? "";
expect(text).not.toMatch(/^errors\./);
expect(text.length).toBeGreaterThan(3);
await expect(alert).toHaveText(/\S/);
await expect(alert).not.toHaveText(/^errors\./);
const english = await alert.textContent();
await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(alert).not.toHaveText(english ?? "");
await expect(alert).not.toHaveText(/^errors\./);
});
-29
View File
@@ -1,29 +0,0 @@
import { expect, test } from "playwright/test";
import { opaquePng } from "./helpers/fixtures";
import { openTool, uploadImage } from "./helpers/page";
// FIXME: В файле зарегистрированы баги preview (см. docs/checklist-manual-testing.md
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
// тест станет зелёным «сам по себе». (Тест про локализацию ошибок переехал в
// i18n.spec.ts — он больше не issue.)
test.describe("known bugs — documented as fixme", () => {
test.fixme("resize-png: upload produces a resized result (no alert)", async ({
page,
}) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
await expect(page.locator("[role='alert']")).toHaveCount(0);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
});
test.fixme("crop-png: upload produces a cropped result (no alert)", async ({
page,
}) => {
await openTool(page, "crop-png");
await uploadImage(page, opaquePng);
await expect(page.locator("[role='alert']")).toHaveCount(0);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
});
});
+26 -29
View File
@@ -1,14 +1,18 @@
import { expect, test } from "playwright/test";
import { expectNoErrors, trackErrors } from "./helpers/page";
import { expectNoErrors, trackErrors, useEnglish } from "./helpers/page";
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
test.beforeEach(async ({ page }) => {
await useEnglish(page);
});
for (const route of ROUTES) {
test(`page ${route} loads without errors`, async ({ page }) => {
const sink = trackErrors(page);
const resp = await page.goto(route);
expect(resp?.status()).toBe(200);
await page.waitForLoadState("networkidle");
await expect(page.getByRole("main")).toBeVisible();
expectNoErrors(sink);
});
}
@@ -18,40 +22,33 @@ test("unknown tool route responds 404 on static build", async ({ page }) => {
expect(resp?.status()).toBe(404);
});
// FIXME:
// Error: expect(received).toBe(expected) // Object.is equality
// Expected: "dark"
// Received: null
// 36 | expect(["light", "dark"]).toContain(before);
// 37 | expect(["light", "dark"]).toContain(after);
// > 38 | expect(stored).toBe(after);
test.fixme("theme toggle flips preview theme and persists to localStorage", async ({
page,
}) => {
test("theme toggle persists the selected theme", async ({ page }) => {
const sink = trackErrors(page);
await page.goto("/");
const root = page.locator("main.preview-root");
const before = await root.getAttribute("data-theme");
await expect(async () => {
await page.getByRole("button", { name: "Toggle theme" }).click();
const after = await root.getAttribute("data-theme");
expect(after).not.toBe(before);
}).toPass();
const after = await root.getAttribute("data-theme");
const stored = await page.evaluate(() =>
localStorage.getItem("easy-png-tools:theme"),
);
const main = page.getByRole("main");
const before = await main.getAttribute("data-theme");
expect(["light", "dark"]).toContain(before);
await page.getByRole("button", { name: "Toggle theme" }).click();
await expect(main).not.toHaveAttribute("data-theme", before ?? "");
const after = await main.getAttribute("data-theme");
const stored = await page.evaluate(() => localStorage.getItem("theme"));
expect(["light", "dark"]).toContain(after);
expect(stored).toBe(after);
await page.reload();
await expect(main).toHaveAttribute("data-theme", after ?? "");
expectNoErrors(sink);
});
test("language toggle marks the active button", async ({ page }) => {
await page.goto("/");
const group = page.getByRole("group", { name: "Language" });
await expect(async () => {
await group.getByRole("button", { name: "EN" }).click();
await expect(page.locator(".lang-btn.active")).toHaveText("EN");
}).toPass();
const en = group.getByRole("button", { name: "EN" });
await en.click();
await expect(en).toHaveAttribute("aria-pressed", "true");
await expect(group.getByRole("button", { name: "RU" })).toHaveAttribute(
"aria-pressed",
"false",
);
});
+159 -58
View File
@@ -1,3 +1,4 @@
import { unzipSync } from "fflate";
import { expect, test } from "playwright/test";
import {
corruptPng,
@@ -7,101 +8,201 @@ import {
opaquePng,
} from "./helpers/fixtures";
import {
downloadResultBytes,
errorAlert,
expectNoErrorAlert,
expectNoErrors,
metaValue,
openTool,
suggestedDownloadName,
rangeInput,
resultImage,
sourceImage,
trackErrors,
uploadImage,
useEnglish,
} from "./helpers/page";
test("flip-png: full flow - upload, result, meta, download", async ({
page,
}) => {
test("resize-png uses the source dimensions by default", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"64",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"48",
);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("crop-png starts with the full source area", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "crop-png");
await uploadImage(page, opaquePng);
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"64",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"48",
);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("flip-png: full flow - upload, result, download", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, landscapePng);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await expect(page.locator('img[alt="Result image"]')).toHaveAttribute(
"src",
/^(blob:|data:image\/png;base64,)/,
);
const resultMeta = await metaValue(page, "RESULT");
expect(resultMeta).toBe("64 × 16 px");
const downloadName = await suggestedDownloadName(
page,
'button[aria-label="Download result"]',
);
expect(downloadName).toBe("flip-png.png");
await expect(resultImage(page)).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("flip-png.png");
expect(Array.from(bytes.subarray(0, 8))).toEqual([
137, 80, 78, 71, 13, 10, 26, 10,
]);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
test("convert-png-to-jpg: produces JPEG and quality affects size", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "convert-png-to-jpg");
await uploadImage(page, opaquePng);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
const downloadName = await suggestedDownloadName(
page,
'button[aria-label="Download result"]',
);
expect(downloadName).toBe("convert-png-to-jpg.jpg");
await uploadImage(page, largePng);
await expect(resultImage(page)).toBeVisible();
const quality = rangeInput(page, "Quality");
await quality.fill("10");
const low = await downloadResultBytes(page);
expect(low.name).toBe("convert-png-to-jpg.jpg");
expect(Array.from(low.bytes.subarray(0, 3))).toEqual([255, 216, 255]);
await quality.fill("90");
const high = await downloadResultBytes(page);
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test.fixme("reset clears result but keeps source", async ({ page }) => {
await openTool(page, "flip-png");
test("convert-png-to-webp: produces WebP and quality affects size", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "convert-png-to-webp");
await uploadImage(page, largePng);
await expect(resultImage(page)).toBeVisible();
const quality = rangeInput(page, "Quality");
await quality.fill("10");
const low = await downloadResultBytes(page);
expect(low.name).toBe("convert-png-to-webp.webp");
expect(Array.from(low.bytes.subarray(0, 4))).toEqual([82, 73, 70, 70]);
expect(Array.from(low.bytes.subarray(8, 12))).toEqual([87, 69, 66, 80]);
await quality.fill("90");
const high = await downloadResultBytes(page);
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("png-to-bmp: produces a BMP download", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "png-to-bmp");
await uploadImage(page, opaquePng);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await expect(resultImage(page)).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("png-to-bmp.bmp");
expect(Array.from(bytes.subarray(0, 2))).toEqual([66, 77]);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("reset restores defaults and re-runs with the source", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "blur-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
const radius = rangeInput(page, /radius/i);
await radius.fill("20");
await expect(radius).toHaveValue("20");
await page.getByRole("button", { name: "Reset" }).click();
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
await expect(page.locator(".empty")).toContainText("no result yet");
await expect(page.locator('img[alt="source"]')).toBeVisible();
await expect(radius).toHaveValue("4");
await expect(sourceImage(page)).toBeVisible();
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("split-into-parts-png downloads a zip with named PNG parts", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "split-into-parts-png");
await uploadImage(page, landscapePng);
await rangeInput(page, "Columns").fill("3");
await rangeInput(page, "Rows").fill("2");
await expect(page.getByText("part-1-3.png")).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("split-into-parts-png.zip");
const entries = unzipSync(bytes);
expect(Object.keys(entries)).toEqual([
"part-1-1.png",
"part-1-2.png",
"part-1-3.png",
"part-2-1.png",
"part-2-2.png",
"part-2-3.png",
]);
for (const bytes of Object.values(entries)) {
expect(Array.from(bytes.subarray(0, 8))).toEqual([
137, 80, 78, 71, 13, 10, 26, 10,
]);
}
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("blur: changing slider updates result image", async ({ page }) => {
await openTool(page, "blur-png");
await uploadImage(page, opaquePng);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
const src1 = await page
.locator('img[alt="Result image"]')
.getAttribute("src");
await expect(async () => {
await page.locator('input[type="range"]').fill("20");
await expect(page.locator('img[alt="Result image"]')).not.toHaveAttribute(
"src",
src1 ?? "",
);
}).toPass();
await expect(resultImage(page)).toBeVisible();
const firstSource = await resultImage(page).getAttribute("src");
await rangeInput(page, /radius/i).fill("20");
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
});
test("runs without Web Worker (fallback)", async ({ browser }) => {
const context = await browser.newContext();
await context.addInitScript(() => {
Object.defineProperty(window, "Worker", {
value: undefined,
configurable: true,
try {
await context.addInitScript(() => {
Object.defineProperty(window, "Worker", {
value: undefined,
configurable: true,
});
});
});
const page = await context.newPage();
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, landscapePng);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
await context.close();
const page = await context.newPage();
await useEnglish(page);
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, landscapePng);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
} finally {
await context.close();
}
});
test("invalid file upload shows error, no crash", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, corruptPng);
await expect(page.locator('[role="alert"]')).toBeVisible();
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
await expect(errorAlert(page)).toBeVisible();
await expect(resultImage(page)).toHaveCount(0);
expectNoErrors(sink);
});
@@ -110,7 +211,7 @@ for (const fixture of [onePixelPng, largePng]) {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, fixture);
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await expect(resultImage(page)).toBeVisible();
expectNoErrors(sink);
});
}
+72
View File
@@ -0,0 +1,72 @@
import { expect, test, type Page } from "playwright/test";
import {
crcBadAncillaryPng,
crcBadIdatPng,
garbageTailPng,
idatCutPng,
palette4Png,
palette256Png,
sixteenBitPng,
truncatedHeaderPng,
truncatedNoIendPng,
type SourceFile,
} from "./helpers/fixtures";
import {
errorAlert,
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
uploadImage,
} from "./helpers/page";
async function expectRejected(page: Page, file: SourceFile): Promise<void> {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, file);
await expect(errorAlert(page)).toBeVisible();
await expect(resultImage(page)).toHaveCount(0);
expectNoErrors(sink);
}
async function expectAccepted(page: Page, file: SourceFile): Promise<void> {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, file);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
}
type DecodeContract = {
file: SourceFile;
rejectedIn: readonly string[];
};
const decodeContracts: readonly DecodeContract[] = [
{ file: crcBadIdatPng, rejectedIn: ["chromium", "firefox"] },
{ file: truncatedHeaderPng, rejectedIn: ["chromium", "firefox", "webkit"] },
{ file: truncatedNoIendPng, rejectedIn: ["chromium"] },
{ file: idatCutPng, rejectedIn: ["chromium"] },
{ file: crcBadAncillaryPng, rejectedIn: [] },
{ file: garbageTailPng, rejectedIn: [] },
{ file: palette4Png, rejectedIn: [] },
{ file: palette256Png, rejectedIn: [] },
{ file: sixteenBitPng, rejectedIn: [] },
];
test.describe("PNG special fixtures", () => {
for (const { file, rejectedIn } of decodeContracts) {
test(`${file.name}: matches the engine decode contract`, async ({
browserName,
page,
}) => {
if (rejectedIn.includes(browserName)) {
await expectRejected(page, file);
} else {
await expectAccepted(page, file);
}
});
}
});
+44 -42
View File
@@ -10,10 +10,19 @@ import {
expectNoErrorAlert,
expectNoErrors,
openTool,
renderText,
resultImage,
trackErrors,
uploadImage,
verdictStatus,
} from "./helpers/page";
async function expectVerdict(page: Parameters<typeof verdictStatus>[0]) {
const verdict = verdictStatus(page);
await expect(verdict).toBeVisible();
await expect(verdict).toHaveText(/\S/);
}
for (const [id, input] of [
["base64-to-png", tinyBase64],
["hex-to-png", "ff0000ff"],
@@ -22,15 +31,15 @@ for (const [id, input] of [
"rgb-values-to-png",
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
],
["data-uri-to-png", `data:image/png;base64,${tinyBase64}`],
["svg-to-png", svgMarkup],
] as const) {
test(`text input → ${id} produces result image`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, id);
await expect(async () => {
await openTool(page, id);
await page.locator(".text-source textarea").fill(input);
await page.getByRole("button", { name: "Render text" }).click();
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await renderText(page, input);
await expect(resultImage(page)).toBeVisible();
}).toPass({ timeout: 25_000 });
await expectNoErrorAlert(page);
expectNoErrors(sink);
@@ -41,62 +50,55 @@ test("png-to-base64 shows decoded text result", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "png-to-base64");
await uploadImage(page, opaquePng);
const code = page.locator(".result-pre code");
await expect(code).toBeVisible();
const text = (await code.textContent()) ?? "";
expect(text.length).toBeGreaterThan(20);
const output = page.getByLabel("Text result");
await expect(output).toBeVisible();
await expect(output).toHaveText(/\S/);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
for (const [input, expected] of [
[tinyBase64, "Yes — this is a valid PNG signature."],
["aGVsbG8=", "No — the signature does not match a PNG file."],
] as const) {
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
await expect(async () => {
await openTool(page, "verify-is-png");
await page.locator(".text-source textarea").fill(input);
await page.getByRole("button", { name: "Render text" }).click();
await expect(page.locator(".verdict-text")).toContainText(expected);
}).toPass({ timeout: 25_000 });
});
}
test("verify-is-png renders different verdicts for valid and invalid input", async ({
page,
}) => {
await openTool(page, "verify-is-png");
const verdict = verdictStatus(page);
test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
await openTool(page, "png-is-transparent");
await uploadImage(page, opaquePng);
await expect(page.locator(".verdict-text")).toContainText(
"No — all pixels are fully opaque.",
);
await renderText(page, tinyBase64);
await expect(verdict).toBeVisible();
const validText = await verdict.textContent();
await renderText(page, "aGVsbG8=");
await expect(verdict).not.toHaveText(validText ?? "");
});
test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => {
test("png-is-transparent: opaque image renders a verdict", async ({ page }) => {
await openTool(page, "png-is-transparent");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-is-transparent: transparent image renders a verdict", async ({
page,
}) => {
await openTool(page, "png-is-transparent");
await uploadImage(page, transparentPng);
await expect(page.locator(".verdict-text")).toContainText(
"Yes — there are transparent or semi-transparent pixels.",
);
await expectVerdict(page);
});
test("png-is-grayscale: colored image → 'No'", async ({ page }) => {
test("png-is-grayscale: colored image renders a verdict", async ({ page }) => {
await openTool(page, "png-is-grayscale");
await uploadImage(page, opaquePng);
await expect(page.locator(".verdict-text")).toContainText(
"No — colored pixels were found.",
);
await expectVerdict(page);
});
test("png-orientation: landscape image → Landscape", async ({ page }) => {
test("png-orientation: landscape image renders a verdict", async ({ page }) => {
await openTool(page, "png-orientation");
await uploadImage(page, opaquePng); // 64×48 → landscape
await expect(page.locator(".verdict-text")).toContainText("Landscape");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-file-size: returns a size in KB", async ({ page }) => {
test("png-file-size: renders a verdict", async ({ page }) => {
await openTool(page, "png-file-size");
await uploadImage(page, opaquePng);
const verdict = page.locator(".verdict-text");
await expect(verdict).toBeVisible();
await expect(verdict).toContainText("KB");
await expectVerdict(page);
});
+27 -4
View File
@@ -5,8 +5,11 @@ import {
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
textResult,
uploadImage,
verdictStatus,
} from "./helpers/page";
type Kind = "image" | "text-out" | "verdict";
@@ -17,6 +20,10 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "convert-png-to-webp", kind: "image" },
{ id: "png-to-bmp", kind: "image" },
{ id: "png-to-base64", kind: "text-out" },
{ id: "png-to-data-uri", kind: "text-out" },
{ id: "png-to-hex", kind: "text-out" },
{ id: "png-to-bytes", kind: "text-out" },
{ id: "png-to-rgb-values", kind: "text-out" },
// ── alpha ──────────────────────────────────────────────────
{ id: "remove-background-png", kind: "image", file: transparentPng },
{ id: "remove-color-from-png", kind: "image", file: transparentPng },
@@ -50,9 +57,25 @@ const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
{ id: "auto-contrast-png", kind: "image" },
{ id: "png-to-hsl", kind: "image" },
{ id: "png-to-cmyk", kind: "image" },
{ id: "gamma-png", kind: "image" },
{ id: "temperature-png", kind: "image" },
{ id: "custom-palette-png", kind: "image" },
{ id: "adjust-brightness-contrast-png", kind: "image" },
{ id: "change-png-opacity", kind: "image" },
{ id: "change-png-hue", kind: "image" },
{ id: "swap-channels-png", kind: "image" },
{ id: "black-and-white-png", kind: "image" },
{ id: "posterize-png", kind: "image" },
{ id: "png-to-hsv", kind: "image" },
{ id: "png-to-hsi", kind: "image" },
{ id: "png-to-ycbcr", kind: "image" },
{ id: "png-to-lab", kind: "image" },
// ── geometry ───────────────────────────────────────────────
{ id: "flip-png", kind: "image" },
{ id: "rotate-png", kind: "image" },
{ id: "rotate-free-png", kind: "image" },
{ id: "fit-on-background-png", kind: "image" },
{ id: "add-padding-png", kind: "image" },
{ id: "add-border-png", kind: "image" },
{ id: "tile-png", kind: "image" },
{ id: "change-canvas-size-png", kind: "image" },
@@ -98,14 +121,14 @@ test.describe("smoke: tools produce output without errors", () => {
test(`tool ${id} (${kind})`, async ({ page }) => {
const sink = trackErrors(page);
const outputLocator = (): ReturnType<typeof page.locator> => {
const outputLocator = () => {
switch (kind) {
case "image":
return page.locator('img[alt="Result image"]');
return resultImage(page);
case "text-out":
return page.locator(".result-pre code");
return textResult(page);
case "verdict":
return page.locator(".verdict-text");
return verdictStatus(page);
}
};
+19 -47
View File
@@ -8,12 +8,11 @@
- `web/eslint-plugins/` — локальные ESLint-плагины.
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
- `isolation/` — правило изоляции old/new UI.
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`.
токенов) и мини-словари `lib/i18n/`.
- `web/scripts/`:
- `lint-all.mjs` — оркестратор `lint:all`.
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
@@ -28,16 +27,9 @@
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
`prefer: 'type-imports'`).
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
`eslint-plugin-svelte`) навешаны **только на новый код**
(`src/lib/components/**`, `src/routes/**`), чтобы старый код не засыпался
предсуществующими ошибками. Старый код (C17) — `src/lib/v1/**`,
`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/**`).
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
устаревшие пути в `eslint.config.js`.
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
`let`).
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
@@ -45,7 +37,6 @@
`__fixtures__/` дописать их туда же.
- design-tokens правила применяются к тем же scoped-путям
(`src/lib/components/**`, `src/routes/**`).
- isolation-правило включено на весь `**/*.{ts,svelte}`.
## Правила плагина design-tokens
@@ -66,10 +57,11 @@
## Правило плагина i18n (`i18n/dict-consistency`)
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md`
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый
словарь подхватывается без правки правила.
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
правила.
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
@@ -80,14 +72,21 @@
пропавшая целая секция репортится один раз (не по каждому потомку);
- **пустые значения**: `""` и строка из одних пробелов;
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
— ловит потерянную при переводе переменную ровно один раз, даже когда
отклоняется одна-единственная локаль.
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
когда отклоняется одна-единственная локаль.
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
(синтакс, без типов).
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
(осознанные отклонения до достижения zero-warn).
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
отсутствовать только в базовой локали; в текущей конфигурации это registry
metadata `tools.*.title` и `tools.*.description`. Пустые значения и
placeholders для присутствующих ключей всё равно проверяются.
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
проверяются на missing; в текущей конфигурации это неиспользуемые
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
@@ -143,9 +142,6 @@ hct-авторство.
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
`__fixtures__/src/app.css` (не из реального `src/app.css`).
- `__tests__/no-mixed-imports.test.ts` — isolation-правило через `Linter` API с
`cwd` на `__fixtures__`: правило резолвит импорты по реальным файлам, поэтому
цели импортов обязаны существовать на диске.
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
`process.cwd()` не трогается, реальные `src/` не читаются.
@@ -154,27 +150,3 @@ hct-авторство.
Фикстуры живут в `__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. -->
@@ -58,6 +58,70 @@ describe("i18n/dict-consistency", () => {
expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]);
});
const registryPolicy = {
baseLocaleFallback: ["tools.*.title", "tools.*.description"],
ignoreMissingPatterns: ["tools.*.params"],
};
it("allows registry metadata only in the base locale", () => {
const enMissing = fixture("en.ts")
.replace('\t\t\ttitle: "Add border",\n', "")
.replace('\t\t\tdescription: "Draws a {kind} frame.",\n', "");
expect(
messageIds(lint(enMissing, "lib/i18n/en.ts", registryPolicy)),
).toEqual([]);
const ruMissing = fixture("ru.ts").replace(
'\t\t\ttitle: "Добавить рамку",\n',
"",
);
expect(
messageIds(lint(ruMissing, "lib/i18n/ru.ts", registryPolicy)),
).toEqual(["missingKey"]);
});
it("does not warn for a missing tool object with only fallback metadata", () => {
const enMissingTool = fixture("en.ts").replace(
/\t\taddBorder: \{[\s\S]*?\n\t\t\},\n/,
"",
);
expect(
messageIds(
lint(enMissingTool, "lib/i18n/en.ts", {
...registryPolicy,
ignoreMissingPatterns: [
...registryPolicy.ignoreMissingPatterns,
"tools.*.results",
],
}),
),
).toEqual([]);
});
it("does not warn for configured legacy missing paths", () => {
const enMissingResults = fixture("en.ts").replace(
/\t\t\tresults: \{[\s\S]*?\n\t\t\t\},\n/,
"",
);
expect(
messageIds(
lint(enMissingResults, "lib/i18n/en.ts", {
ignoreMissingPatterns: ["tools.*.results"],
}),
),
).toEqual([]);
});
it("still reports empty fallback metadata", () => {
const enEmpty = fixture("en.ts").replace(
'title: "Add border",',
'title: "",',
);
expect(messageIds(lint(enEmpty, "lib/i18n/en.ts", registryPolicy))).toEqual(
["emptyValue"],
);
});
it("flags a leaf key missing in the current file", () => {
const enMissing = fixture("en.ts").replace(
'\t\trestoreLast: "Restore {title}",',
+4 -5
View File
@@ -3,11 +3,10 @@
// Two strategies, picked per rule in the test files:
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
// - Linter API + fixtures — needed when a rule reads real files. The isolation
// rule resolves imports via fs (targets must exist on disk) and
// no-undefined-in-svelte reads <cwd>/src/app.css for the token dictionary.
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
// work without touching process.cwd() or the real src/ tree.
// - Linter API + fixtures — needed when a rule reads real files, such as
// no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
// dictionary. `verifyInFixtures` runs a rule with cwd fixed to
// `__fixtures__/`, without touching process.cwd() or the real src/ tree.
import { Linter } from "eslint";
import type { Rule } from "eslint";
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",
);
});
});
+90 -20
View File
@@ -13,16 +13,17 @@
* section is reported once, not per descendant key);
* - empty values: `""` or whitespace-only values;
* - placeholders: for a shared key the `{name}` set is compared against the
* canonical set (the one shared by most locales, ties broken by LOCALES
* order) — catches a variable lost in translation exactly once, even when
* only one locale deviates.
* canonical set (the one shared by most locales, ties broken by BASE_LOCALE
* then LOCALES order) — catches a variable lost in translation exactly once,
* even when only one locale deviates.
*
* The rule self-filters: files whose basename is not one of LOCALES are
* ignored, so it can be attached to the whole `lib/i18n/` directory.
*
* Options (object, all optional):
* - allowPaths: dot-path keys excluded from every check (conscious
* deviations until the dict reaches zero-warn, see Фаза 8 §8.3).
* - allowPaths: dot-path keys excluded from every check.
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
* - ignoreMissingPatterns: legacy patterns excluded from missing-key checks.
*/
import fs from "node:fs";
import path from "node:path";
@@ -70,6 +71,22 @@ function findDictExport(ast) {
return null;
}
function findBaseLocale(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration") continue;
const decl = node.declaration;
if (!decl || decl.type !== "VariableDeclaration") continue;
for (const d of decl.declarations) {
if (d.id.type !== "Identifier" || d.id.name !== "BASE_LOCALE") {
continue;
}
const init = unwrap(d.init);
if (isStringLiteral(init)) return init.value;
}
}
return null;
}
/** The `LOCALES` list exported from `dict.ts`, or null. */
function findLocalesList(ast) {
for (const node of ast.body) {
@@ -167,6 +184,17 @@ function formatSet(set) {
return `{${[...set].sort().join(", ")}}`;
}
function matchesPattern(key, pattern) {
const keyParts = key.split(".");
const patternParts = pattern.split(".");
return (
keyParts.length >= patternParts.length &&
patternParts.every(
(part, index) => part === "*" || part === keyParts[index],
)
);
}
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
function* ancestorPaths(key) {
let i = key.lastIndexOf(".");
@@ -190,6 +218,7 @@ function readDir(dir) {
if (!fs.existsSync(dictPath)) return null;
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
const locales = findLocalesList(ast);
const baseLocale = findBaseLocale(ast);
if (!locales || locales.length === 0) return null;
const dicts = [];
@@ -218,31 +247,42 @@ function readDir(dir) {
unionPaths.add(key);
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
stringLeaves.get(key).push(placeholders(entry.value));
stringLeaves.get(key).push({
locale: d.locale,
set: placeholders(entry.value),
});
}
}
}
// Canonical placeholder set per key: the set shared by the most locales;
// on a tie the first one encountered (LOCALES order) wins.
// on a tie the base locale wins before LOCALES order.
const canonicalSets = new Map();
for (const [key, sets] of stringLeaves) {
for (const [key, entries] of stringLeaves) {
const counts = new Map();
for (const set of sets) {
for (const { set } of entries) {
const sig = [...set].sort().join("\u0000");
counts.set(sig, (counts.get(sig) || 0) + 1);
}
let bestCount = 0;
for (const set of sets) {
let bestLocale = null;
for (const { locale, set } of entries) {
const sig = [...set].sort().join("\u0000");
if (counts.get(sig) > bestCount) {
bestCount = counts.get(sig);
const count = counts.get(sig);
if (
count > bestCount ||
(count === bestCount &&
locale === baseLocale &&
bestLocale !== baseLocale)
) {
bestCount = count;
bestLocale = locale;
canonicalSets.set(key, set);
}
}
}
return { locales, dicts, unionPaths, canonicalSets };
return { locales, baseLocale, dicts, unionPaths, canonicalSets };
}
export default {
@@ -261,6 +301,14 @@ export default {
type: "array",
items: { type: "string" },
},
baseLocaleFallback: {
type: "array",
items: { type: "string" },
},
ignoreMissingPatterns: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
@@ -275,6 +323,8 @@ export default {
create(context) {
const options = context.options[0] || {};
const allowPaths = new Set(options.allowPaths || []);
const baseLocaleFallback = options.baseLocaleFallback || [];
const ignoreMissingPatterns = options.ignoreMissingPatterns || [];
const filename = path.resolve(
context.filename || context.physicalFilename || "",
@@ -287,16 +337,36 @@ export default {
if (!dictExport) return {};
const own = buildKeyMap(dictExport.object);
const isMissingAllowed = (key) => {
if (allowPaths.has(key)) return true;
if (
ignoreMissingPatterns.some((pattern) => matchesPattern(key, pattern))
) {
return true;
}
return (
locale === dirInfo.baseLocale &&
baseLocaleFallback.some((pattern) => matchesPattern(key, pattern))
);
};
// Missing keys: everything the union has but the current file lacks.
// Top-level gaps only — a missing ancestor already covers its subtree.
const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k));
const missingSet = new Set(missing);
const topMissing = missing.filter(
(k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)),
const missing = [...dirInfo.unionPaths].filter((key) => !own.has(key));
const requiredMissing = missing.filter((key) => {
if (isMissingAllowed(key)) return false;
const descendants = missing.filter((candidate) =>
candidate.startsWith(`${key}.`),
);
return (
descendants.length === 0 ||
descendants.some((candidate) => !isMissingAllowed(candidate))
);
});
const requiredSet = new Set(requiredMissing);
const topMissing = requiredMissing.filter(
(key) =>
![...ancestorPaths(key)].some((ancestor) => requiredSet.has(ancestor)),
);
for (const key of topMissing) {
if (allowPaths.has(key)) continue;
context.report({
node: dictExport.id,
messageId: "missingKey",
-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,
};
},
};
+12 -41
View File
@@ -5,16 +5,11 @@ import globals from "globals";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import designTokens from "./eslint-plugins/index.js";
import isolationPlugin from "./eslint-plugins/isolation/index.js";
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
import i18nPlugin from "./eslint-plugins/i18n/index.js";
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
// Когда старый дизайн удалим (C19), этот scoped-блок убирается и recommended
// включается на весь код (см. план-redesign §10, шаг 6).
const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
const oldCode = ["**/src/lib/v1/**", "**/src/routes/v1/**"];
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
const jsRecommended = Array.isArray(js.configs.recommended)
@@ -29,10 +24,6 @@ const newSvelteFiles = [
"**/src/lib/components/**/*.svelte",
"**/src/routes/**/*.svelte",
];
const oldSvelteFiles = [
"**/src/lib/v1/**/*.svelte",
"**/src/routes/v1/**/*.svelte",
];
// FIXME:
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
@@ -72,7 +63,7 @@ export default tseslint.config(
"e2e/text-and-verdicts.spec.ts",
"e2e/tools-smoke.spec.ts",
"e2e/generators.spec.ts",
"e2e/known-issues.spec.ts",
"e2e/png-fixtures.spec.ts",
"e2e/i18n.spec.ts",
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
// tsconfig), поэтому для типизированного парсинга резолвятся
@@ -82,21 +73,17 @@ export default tseslint.config(
"eslint-plugins/__tests__/interface-props.test.ts",
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
"eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
"eslint-plugins/__tests__/dict-consistency.test.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/ru.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/i18n/t.ts",
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
"eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte",
"eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte",
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
],
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
},
@@ -134,11 +121,9 @@ export default tseslint.config(
},
},
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
// Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн
// удалят, расширить glob на весь код, исключив (old)/.
// Применяется к новому коду редизайна.
{
files: newSvelteFiles,
ignores: oldSvelteFiles,
plugins: {
"design-tokens": designTokens,
},
@@ -151,10 +136,8 @@ export default tseslint.config(
},
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
// `let {...}: Props = $props()` (плагин conventions/interface-props).
// Только Svelte-файлы нового кода (см. newSvelteFiles).
{
files: newSvelteFiles,
ignores: oldSvelteFiles,
plugins: {
conventions: conventionsPlugin,
},
@@ -166,7 +149,6 @@ export default tseslint.config(
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
{
files: newCode,
ignores: oldCode,
plugins: {
conventions: conventionsPlugin,
},
@@ -175,16 +157,22 @@ export default tseslint.config(
},
},
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
// warn-only: пропущенный перевод не должен валить сборку. Список локалей
// берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются
// автоматически; правило игнорирует не-локали в этой папке само.
// Правило сообщает warning, а `lint:all` превращает новые warnings в
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
{
files: ["**/src/lib/i18n/*.ts"],
plugins: {
i18n: i18nPlugin,
},
rules: {
"i18n/dict-consistency": "warn",
"i18n/dict-consistency": [
"warn",
{
baseLocaleFallback: ["tools.*.title", "tools.*.description"],
ignoreMissingPatterns: ["tools.*.params"],
},
],
},
},
// Полные recommended-наборы — только на новый код.
@@ -192,17 +180,14 @@ export default tseslint.config(
...jsRecommended.map((cfg) => ({
...cfg,
files: newCode,
ignores: oldCode,
})),
...tseslint.configs.recommended.map((cfg) => ({
...cfg,
files: newCode,
ignores: oldCode,
})),
...svelteRecommended.map((cfg) => ({
...cfg,
files: newSvelteFiles,
ignores: oldSvelteFiles,
})),
],
@@ -214,20 +199,6 @@ export default tseslint.config(
"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,
);
+39
View File
@@ -1,11 +1,25 @@
import { defineConfig } from "playwright/test";
const browserContractTests = [
"**/pipeline.spec.ts",
"**/png-fixtures.spec.ts",
"**/text-and-verdicts.spec.ts",
];
const mobileTests = [
"**/navigation.spec.ts",
"**/catalog.spec.ts",
"**/generators.spec.ts",
"**/pipeline.spec.ts",
];
export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e-results",
fullyParallel: true,
timeout: 60_000,
retries: 0,
workers: process.env.CI ? 2 : 1,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: "http://127.0.0.1:4173",
@@ -19,6 +33,31 @@ export default defineConfig({
viewport: { width: 1440, height: 900 },
},
},
{
name: "firefox",
testMatch: browserContractTests,
use: {
browserName: "firefox",
viewport: { width: 1440, height: 900 },
},
},
{
name: "webkit",
testMatch: browserContractTests,
use: {
browserName: "webkit",
viewport: { width: 1440, height: 900 },
},
},
{
name: "mobile-chromium",
testMatch: mobileTests,
use: {
browserName: "chromium",
viewport: { width: 390, height: 844 },
hasTouch: true,
},
},
],
webServer: {
command: "pnpm build && node scripts/serve-static.mjs --port 4173",
+1 -1
View File
@@ -31,7 +31,7 @@ const bins = {
const steps = [
{
name: "ESLint (svelte) + design-tokens rules",
args: [bins.eslint, ...colorFlag, "."],
args: [bins.eslint, ...colorFlag, "--max-warnings=0", "."],
},
{
name: "Stylelint (css) — design-token style",
+2 -2
View File
@@ -199,8 +199,8 @@
--color-text-accent: hct(from var(--brand-alt) h 25 90);
--color-text-accent-muted: hct(from var(--brand-alt) h 22 70);
--color-main: hct(from var(--brand-main) h 60 50);
--color-accent: hct(from var(--brand-alt) h 60 50);
--color-main: hct(from var(--brand-main) h 60 56);
--color-accent: hct(from var(--brand-alt) h 60 56);
--color-success: hct(160 65 55);
--color-warning: hct(80 65 55);
--color-danger: hct(30 70 55);
-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` и порядок отображения в каталоге
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
* Этот файл — локальная копия подхода для нового UI; старый
* `lib/v1/categories` (массив + union вручную) не трогаем — он обслуживает
* старый UI в `/v1/*`.
*
* Человекочитаемые названия живут в словарях i18n: секция categories,
* ключ = CategoryId.
+3 -2
View File
@@ -12,7 +12,7 @@
<section class="catalog-group">
<div class="group-title">
<span>{label}</span>
<h2>{label}</h2>
<i>{count.toString().padStart(2, "0")} <!-- --> {t("catalog.toolsCount")}</i
>
</div>
@@ -35,7 +35,8 @@
padding-bottom: var(--space-m);
border-bottom: var(--size-border) solid var(--color-border);
}
.group-title span {
.group-title h2 {
margin: 0;
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-xl);
text-transform: uppercase;
+5 -2
View File
@@ -12,10 +12,13 @@
<h1>{t("catalog.heading")}</h1>
<p>{t("catalog.lead")}</p>
</div>
<div class="catalog-total">
<output
class="catalog-total"
aria-label={`${total} ${t("catalog.toolsAvailable")}`}
>
<b>{total}</b>
<span>{t("catalog.toolsAvailable")}</span>
</div>
</output>
</div>
<style>
@@ -38,6 +38,7 @@
<button
type="button"
class:active={category === f.value}
aria-pressed={category === f.value}
onclick={() => (category = f.value)}
>
{f.label}
+2 -32
View File
@@ -4,6 +4,7 @@
FieldSpecKind,
ToolSchema,
} from "$lib/registry-schema";
import { resolveLayoutGroups } from "$lib/registry-schema";
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { RotateCcw } from "@lucide/svelte";
@@ -57,38 +58,7 @@
}
let { schema, values, toolId, onchange, onreset }: Props = $props();
interface LayoutGroup {
key: string;
title?: string;
cols: number;
fields: string[];
}
const layoutGroups = $derived.by((): LayoutGroup[] => {
const all = Object.keys(schema.fields);
const groups = schema.layout?.groups ?? [];
const used: Record<string, true> = {};
const named = groups
.map((g, gi) => {
const fields = g.fields.filter((f) => {
if (used[f] || !(f in schema.fields)) return false;
used[f] = true;
return true;
});
return {
key: `${g.title ?? "group"}-${gi}`,
title: g.title,
cols: Math.max(1, Math.trunc(g.cols ?? 1)),
fields,
} satisfies LayoutGroup;
})
.filter((g) => g.fields.length > 0);
const rest = all.filter((f) => !used[f]);
if (rest.length > 0) {
named.push({ key: "__default", title: undefined, cols: 1, fields: rest });
}
return named;
});
const layoutGroups = $derived(resolveLayoutGroups(schema));
</script>
{#each layoutGroups as group (group.key)}
+10 -34
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import MetaList from "$lib/components/MetaList.svelte";
import { buildSchemaPreviewModel } from "$lib/components/schema-preview-model";
import SchemaActions from "$lib/components/SchemaActions.svelte";
import SchemaResultTile from "$lib/components/SchemaResultTile.svelte";
import SchemaSourceTile from "$lib/components/SchemaSourceTile.svelte";
@@ -46,36 +47,11 @@
ondownload,
}: Props = $props();
const sourceValue = $derived.by(() => {
switch (inputMode) {
case "none":
return "—";
case "text":
return t("textInput.heading");
case "image":
return source ? `${source.width} × ${source.height} px` : "—";
}
});
const resultValue = $derived(
resultKind === "image"
? result
? `${result.width} × ${result.height} px`
: "—"
: resultKind === "files"
? fileResult
? `${fileResult.files.length} ${t("resultCard.parts")}`
: "—"
: textResult
? t("textInput.heading")
: "—",
);
const formatValue = $derived(resultKind === "files" ? "ZIP (PNG)" : "PNG");
const hasResult = $derived(
resultKind === "image"
? Boolean(result)
: resultKind === "files"
? Boolean(fileResult)
: Boolean(textResult),
const model = $derived(
buildSchemaPreviewModel(
{ inputMode, resultKind, source, result, fileResult, textResult },
t,
),
);
</script>
@@ -83,7 +59,7 @@
<span class="label">{t("resultCard.previewPanel")}</span>
<SchemaActions
{inputMode}
canDownload={hasResult}
canDownload={model.hasResult}
{running}
{onupload}
{ondownload}
@@ -119,9 +95,9 @@
<div class="meta">
<MetaList
items={[
{ caption: t("sourceCard.source"), value: sourceValue },
{ caption: t("resultCard.result"), value: resultValue },
{ caption: t("resultCard.format"), value: formatValue },
{ caption: t("sourceCard.source"), value: model.sourceValue },
{ caption: t("resultCard.result"), value: model.resultValue },
{ caption: t("resultCard.format"), value: model.formatValue },
]}
/>
</div>
@@ -82,11 +82,17 @@
{/each}
</div>
{:else if resultKind === "image" && resultUrl}
<img src={resultUrl} alt={t("resultCard.alt")} />
<img
data-testid="result-image"
src={resultUrl}
alt={t("resultCard.alt")}
/>
{:else if running}
<Check size={22} />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
<span data-testid="empty-state" class="empty"
>{t("resultCard.noResult")}</span
>
{/if}
</div>
</PreviewTile>
@@ -37,7 +37,7 @@
/>
</div>
{:else if mode === "image" && sourceUrl}
<img src={sourceUrl} alt={t("sourceCard.alt")} />
<img data-testid="source-image" src={sourceUrl} alt={t("sourceCard.alt")} />
{:else if mode === "image"}
<span class="empty">{t("sourceCard.chooseImage")}</span>
{:else}
@@ -32,7 +32,11 @@
{#if kind === "verdict"}
<div class="verdict">
<span class="verdict-label">{t("resultCard.result")}</span>
<div class="badge badge-tone--{tone}" role="status">
<div
data-testid="result-verdict"
class="badge badge-tone--{tone}"
role="status"
>
<span class="verdict-text">{displayValue}</span>
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
</div>
@@ -50,7 +54,9 @@
/>
</div>
</div>
<pre class="result-pre"><code>{value}</code></pre>
<pre aria-label={t("textResult.outputAria")} class="result-pre"><code
>{value}</code
></pre>
</div>
{/if}
@@ -22,6 +22,7 @@
<div class="text-source">
<textarea
aria-label={t("textInput.aria")}
spellcheck="false"
{placeholder}
rows="6"
+66 -41
View File
@@ -2,8 +2,8 @@
import SchemaFields from "$lib/components/SchemaFields.svelte";
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
import { debounce } from "$lib/core/debounce";
import { ToolError } from "$lib/core/errors";
import { decodeFile, encode } from "$lib/core/io";
import { ToolError, type ErrorVars } from "$lib/core/errors";
import { decodeFile, downloadBlob, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor";
import {
@@ -14,6 +14,7 @@
import { t } from "$lib/i18n/t";
import type { FileResult, ToolEntry } from "$lib/registry";
import {
applySourceDefaults,
defaultSchemaParams,
sanitizeSchemaParams,
type ToolSchema,
@@ -23,6 +24,9 @@
interface Props {
tool: ToolEntry;
}
type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string };
let { tool }: Props = $props();
const schema = $derived(
@@ -42,9 +46,17 @@
undefined,
);
let running = $state(false);
let error = $state("");
let displayError = $state<DisplayError | null>(null);
let started = $state(false);
const errorText = $derived.by(() => {
const current = displayError;
if (!current) return "";
return current.kind === "i18n"
? t(current.key, current.vars)
: current.text;
});
const debouncedRun = debounce(() => run(), 200);
function init() {
@@ -58,30 +70,38 @@
}
function reset() {
values = schema ? defaultSchemaParams(schema) : {};
values = schema
? defaultSchemaParams(schema, { source: source ?? undefined })
: {};
result = null;
fileResult = null;
textResult = null;
verdictVars = undefined;
error = "";
displayError = null;
}
async function handleFile(file: File) {
error = "";
displayError = null;
try {
source = await decodeFile(file);
const decoded = await decodeFile(file);
source = decoded;
if (schema) {
values = applySourceDefaults(schema, values, { source: decoded });
}
} catch (e) {
error = errorText(e);
displayError = toDisplayError(e);
}
}
async function run() {
if (!schema) return;
error = "";
displayError = null;
running = true;
try {
const out = await execute(tool, {
params: sanitizeSchemaParams(schema, values),
params: sanitizeSchemaParams(schema, values, {
source: source ?? undefined,
}),
source: source ?? undefined,
text: textSource || undefined,
});
@@ -108,7 +128,7 @@
fileResult = null;
}
} catch (e) {
error = errorText(e);
displayError = toDisplayError(e);
} finally {
running = false;
}
@@ -116,23 +136,26 @@
async function download() {
if (!schema) return;
if (resultKind === "files") {
if (!fileResult) return;
await downloadZip(fileResult.files, `${tool.id}.zip`);
return;
running = true;
displayError = null;
try {
if (resultKind === "files") {
if (!fileResult) return;
await downloadZip(fileResult.files, `${tool.id}.zip`);
return;
}
if (!result) return;
const out = tool.output;
const quality = out?.qualityParamId
? Number(values[out.qualityParamId]) / 100
: undefined;
const blob = await encode(result, out?.mime ?? "image/png", quality);
downloadBlob(blob, `${tool.id}.${out?.ext ?? "png"}`);
} catch (e) {
displayError = toDisplayError(e);
} finally {
running = false;
}
if (!result) return;
const out = tool.output;
const quality = out?.qualityParamId
? Number(values[out.qualityParamId]) / 100
: undefined;
const blob = await encode(result, out?.mime ?? "image/png", quality);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tool.id}.${out?.ext ?? "png"}`;
a.click();
URL.revokeObjectURL(url);
}
async function copyText() {
@@ -144,19 +167,18 @@
try {
await navigator.clipboard.writeText(copyValue);
} catch (e) {
error = errorText(e);
displayError = toDisplayError(e);
}
}
async function downloadText() {
if (!textResult) return;
const blob = new Blob([textResult], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tool.id}.txt`;
a.click();
URL.revokeObjectURL(url);
try {
const blob = new Blob([textResult], { type: "text/plain" });
downloadBlob(blob, `${tool.id}.txt`);
} catch (e) {
displayError = toDisplayError(e);
}
}
$effect(() => {
@@ -164,18 +186,21 @@
});
$effect(() => {
// TODO: can any edge case start infinite loop?
if (!started) return;
if (inputMode === "image" && !source) return;
if (inputMode === "text" && !textSource.trim()) return;
void values;
void source;
debouncedRun();
return () => debouncedRun.cancel();
});
function errorText(e: unknown): string {
if (e instanceof ToolError) return t(e.key, e.vars);
if (e instanceof Error) return e.message;
return String(e);
function toDisplayError(e: unknown): DisplayError {
if (e instanceof ToolError) {
return { kind: "i18n", key: e.key, vars: e.vars };
}
if (e instanceof Error) return { kind: "plain", text: e.message };
return { kind: "plain", text: String(e) };
}
</script>
@@ -215,7 +240,7 @@
{inputMode}
{resultKind}
{running}
{error}
error={errorText}
onupload={handleFile}
ontextsource={(textValue) => {
textSource = textValue;
@@ -25,6 +25,7 @@
<output>{current}</output>
</span>
<input
aria-label={label}
type="range"
min={sp.min ?? 0}
max={sp.max ?? 100}
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { makeImage } from "../core/test-helpers";
import {
buildSchemaPreviewModel,
type PreviewTranslate,
} from "./schema-preview-model";
const image = makeImage(2, 3, [
[1, 2, 3, 255],
[4, 5, 6, 255],
[7, 8, 9, 255],
[10, 11, 12, 255],
[13, 14, 15, 255],
[16, 17, 18, 255],
]);
const translate: PreviewTranslate = (key) => {
if (key === "resultCard.parts") return "parts";
if (key === "textInput.heading") return "Text";
return key;
};
function input(
overrides: Partial<Parameters<typeof buildSchemaPreviewModel>[0]> = {},
) {
return {
inputMode: "image" as const,
resultKind: "image" as const,
source: null,
result: null,
fileResult: null,
textResult: null,
...overrides,
};
}
describe("buildSchemaPreviewModel", () => {
it("formats source values for every input mode", () => {
expect(
buildSchemaPreviewModel(input({ inputMode: "none" }), translate),
).toMatchObject({ sourceValue: "—" });
expect(
buildSchemaPreviewModel(input({ inputMode: "text" }), translate),
).toMatchObject({ sourceValue: "Text" });
expect(
buildSchemaPreviewModel(
input({ inputMode: "image", source: image }),
translate,
),
).toMatchObject({ sourceValue: "2 × 3 px" });
});
it("formats image results and enables download only with a result", () => {
expect(
buildSchemaPreviewModel(input({ result: image }), translate),
).toEqual({
sourceValue: "—",
resultValue: "2 × 3 px",
formatValue: "PNG",
hasResult: true,
});
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
resultValue: "—",
hasResult: false,
});
});
it("formats file results as ZIP and rejects empty file results", () => {
const fileResult = { files: [{ name: "part.png", image }] };
expect(
buildSchemaPreviewModel(
input({ resultKind: "files", fileResult }),
translate,
),
).toEqual({
sourceValue: "—",
resultValue: "1 parts",
formatValue: "ZIP (PNG)",
hasResult: true,
});
expect(
buildSchemaPreviewModel(
input({ resultKind: "files", fileResult: { files: [] } }),
translate,
),
).toMatchObject({ resultValue: "—", hasResult: false });
});
it.each(["text", "verdict"] as const)(
"uses text result presence for %s output",
(resultKind) => {
expect(
buildSchemaPreviewModel(
input({ resultKind, textResult: "done" }),
translate,
),
).toMatchObject({
resultValue: "Text",
formatValue: "PNG",
hasResult: true,
});
expect(
buildSchemaPreviewModel(input({ resultKind }), translate),
).toMatchObject({
resultValue: "—",
hasResult: false,
});
},
);
});
@@ -0,0 +1,60 @@
import type { PixelImage } from "../core/types";
import type { FileResult, InputMode, ResultKind } from "../registry";
export type PreviewTranslate = (key: string) => string;
export type SchemaPreviewModelInput = {
inputMode: InputMode;
resultKind: ResultKind;
source: PixelImage | null;
result: PixelImage | null;
fileResult: FileResult | null;
textResult: string | null;
};
export type SchemaPreviewModel = {
sourceValue: string;
resultValue: string;
formatValue: string;
hasResult: boolean;
};
export function buildSchemaPreviewModel(
input: SchemaPreviewModelInput,
translate: PreviewTranslate,
): SchemaPreviewModel {
const { inputMode, resultKind, source, result, fileResult, textResult } =
input;
const sourceValue =
inputMode === "none"
? "—"
: inputMode === "text"
? translate("textInput.heading")
: source
? `${source.width} × ${source.height} px`
: "—";
const hasFiles = Boolean(fileResult && fileResult.files.length > 0);
const resultValue =
resultKind === "image"
? result
? `${result.width} × ${result.height} px`
: "—"
: resultKind === "files"
? hasFiles
? `${fileResult?.files.length} ${translate("resultCard.parts")}`
: "—"
: textResult
? translate("textInput.heading")
: "—";
return {
sourceValue,
resultValue,
formatValue: resultKind === "files" ? "ZIP (PNG)" : "PNG",
hasResult:
resultKind === "image"
? Boolean(result)
: resultKind === "files"
? hasFiles
: Boolean(textResult),
};
}
+34
View File
@@ -72,6 +72,40 @@ describe("hasTransparency", () => {
});
});
describe("special decoded pixel data", () => {
it("палитра после browser-декодирования сохраняет количество цветов", () => {
const image = makeImage(2, 2, [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255],
[255, 255, 255, 255],
]);
expect(imageInfo(image)).toMatchObject({
width: 2,
height: 2,
hasAlpha: false,
colorCount: 4,
});
});
it("16-bit после downscale остаётся цветным и непрозрачным", () => {
const image = makeImage(
4,
4,
Array.from({ length: 16 }, (_, index) => {
const value = index * 8;
return [value, value, 128, 255];
}),
);
expect(imageInfo(image).colorCount).toBe(16);
expect(hasTransparency(image)).toBe(false);
expect(isGrayscale(image)).toBe(false);
expect(orientationOf(image)).toBe("square");
});
});
describe("orientationOf", () => {
it("определяет ориентацию", () => {
expect(
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { debounce } from "./debounce";
afterEach(() => {
vi.useRealTimers();
});
describe("debounce", () => {
it("coalesces calls until the deadline", () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(199);
debounced();
vi.advanceTimersByTime(199);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledTimes(1);
});
it("cancels a pending call and can be scheduled again", () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
debounced.cancel();
vi.advanceTimersByTime(200);
expect(fn).not.toHaveBeenCalled();
debounced();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
});
+35 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { isSupportedImage, unsupportedImageError } from "./io";
import {
isSupportedImage,
unsupportedImageError,
validateOutputQuality,
} from "./io";
describe("isSupportedImage", () => {
it.each([
@@ -38,3 +42,33 @@ describe("unsupportedImageError", () => {
expect(err.vars?.type).toBe("unknown");
});
});
describe("validateOutputQuality", () => {
it.each(["image/jpeg", "image/webp"] as const)(
"принимает границы 0 и 1 для %s",
(mime) => {
expect(() => validateOutputQuality(mime, 0)).not.toThrow();
expect(() => validateOutputQuality(mime, 1)).not.toThrow();
},
);
it.each(["image/jpeg", "image/webp"] as const)(
"отклоняет значение вне 0..1 для %s",
(mime) => {
expect(() => validateOutputQuality(mime, -0.01)).toThrow(
"errors.qualityRange",
);
expect(() => validateOutputQuality(mime, 1.01)).toThrow(
"errors.qualityRange",
);
expect(() => validateOutputQuality(mime, Number.NaN)).toThrow(
"errors.qualityRange",
);
},
);
it("не применяет quality к PNG и BMP", () => {
expect(() => validateOutputQuality("image/png", 2)).not.toThrow();
expect(() => validateOutputQuality("image/bmp", 2)).not.toThrow();
});
});
+14 -5
View File
@@ -84,6 +84,19 @@ export async function decodeTextImage(text: string): Promise<PixelImage> {
return await decodeBytes(bytes);
}
export function validateOutputQuality(
mime: OutputMime,
quality?: number,
): void {
if (mime !== "image/jpeg" && mime !== "image/webp") return;
if (
quality !== undefined &&
(!Number.isFinite(quality) || quality < 0 || quality > 1)
) {
throw new ToolError("errors.qualityRange");
}
}
export async function encode(
img: PixelImage,
mime: OutputMime = "image/png",
@@ -92,11 +105,7 @@ export async function encode(
if (mime === "image/bmp") {
return new Blob([encodeBmpBytes(img)], { type: mime });
}
if (mime === "image/jpeg" || mime === "image/webp") {
if (quality !== undefined && (quality < 0 || quality > 1)) {
throw new ToolError("errors.qualityRange");
}
}
validateOutputQuality(mime, quality);
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
+323
View File
@@ -0,0 +1,323 @@
import { describe, expect, it, vi } from "vitest";
import { ToolError } from "../core/errors";
import type { PixelImage } from "../core/types";
import {
createExecutor,
type ExecuteContext,
type WorkerFactory,
type WorkerLike,
} from "./executor";
import {
decodeWorkerResponse,
encodeWorkerResponse,
type WorkerRequest,
type WorkerResponse,
} from "./protocol";
import { handleWorkerRequest } from "./worker-handler";
import { field, toolSchema, type Dimension } from "../registry-schema";
import type { ToolEntry } from "../registry";
class FakeWorker implements WorkerLike {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
requests: WorkerRequest[] = [];
terminated = false;
throwOnPost = false;
postMessage(request: WorkerRequest): void {
this.requests.push(request);
if (this.throwOnPost) throw new Error("post failed");
}
respond(response: WorkerResponse): void {
this.onmessage?.({ data: response } as MessageEvent<WorkerResponse>);
}
fail(): void {
this.onerror?.(new Event("error"));
}
terminate(): void {
this.terminated = true;
}
}
function createFactory(throwOnPost = false): {
factory: WorkerFactory;
workers: FakeWorker[];
} {
const workers: FakeWorker[] = [];
return {
factory: () => {
const worker = new FakeWorker();
worker.throwOnPost = throwOnPost;
workers.push(worker);
return worker;
},
workers,
};
}
const emptySchema = toolSchema<Record<string, never>>({});
function makeTool(
run: ToolEntry["run"] = () => "direct",
overrides: Partial<ToolEntry> = {},
): ToolEntry {
return {
id: "test-tool",
title: "Test tool",
description: "Test tool",
category: "analyze",
schema: emptySchema,
input: "none",
run,
...overrides,
};
}
function image(width = 2, height = 2): WorkerRequest["source"] {
return {
width,
height,
data: new Uint8ClampedArray(width * height * 4),
};
}
describe("createExecutor", () => {
it("validates required input before creating a worker", async () => {
const factory = vi.fn<WorkerFactory>();
const execute = createExecutor({ workerFactory: factory });
const imageTool = makeTool(vi.fn(), { input: "image" });
const textTool = makeTool(vi.fn(), { input: "text" });
await expect(execute(imageTool, { params: {} })).rejects.toMatchObject({
key: "errors.sourceRequired",
});
await expect(
execute(textTool, { params: {}, text: " " }),
).rejects.toMatchObject({ key: "errors.textRequired" });
expect(factory).not.toHaveBeenCalled();
});
it("routes domOnly tools directly", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run, { domOnly: true });
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
expect(workers).toHaveLength(0);
});
it("passes source-aware sanitized params through the worker request", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const schema = toolSchema<{ size: Dimension }>({
size: field.dimension({
min: 1,
max: 100,
width: 1,
height: 1,
defaultFromSource: true,
}),
});
const tool = makeTool(() => "worker", { input: "image", schema });
const promise = execute(tool, {
params: { size: { width: 0, height: 0 } },
source: image(64, 48) as ExecuteContext["source"],
});
const worker = workers[0];
const request = worker.requests[0];
worker.respond({ id: request.id, ok: true, text: "worker" });
await expect(promise).resolves.toBe("worker");
expect(request.params).toEqual({ size: { width: 64, height: 48 } });
});
it("resolves concurrent worker responses by request id", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const tool = makeTool();
const first = execute(tool, { params: {} });
const second = execute(tool, { params: {} });
const worker = workers[0];
const [firstRequest, secondRequest] = worker.requests;
worker.respond({ id: secondRequest.id, ok: true, text: "second" });
worker.respond({ id: firstRequest.id, ok: true, text: "first" });
await expect(first).resolves.toBe("first");
await expect(second).resolves.toBe("second");
});
it("does not retry a worker ToolError directly", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run);
const promise = execute(tool, { params: {} });
const worker = workers[0];
const request = worker.requests[0];
worker.respond({
id: request.id,
ok: false,
errorKey: "errors.badPixelToken",
errorVars: { value: "x" },
});
await expect(promise).rejects.toMatchObject({
key: "errors.badPixelToken",
vars: { value: "x" },
});
expect(run).not.toHaveBeenCalled();
});
it("falls back when worker construction fails", async () => {
const factory = vi.fn<WorkerFactory>(() => {
throw new Error("construction failed");
});
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run);
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
expect(factory).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(2);
});
it("falls back after transport failures and disables the worker", async () => {
const { factory, workers } = createFactory(true);
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run);
const postFailure = execute(tool, { params: {} });
await expect(postFailure).resolves.toBe("direct");
expect(workers[0].terminated).toBe(true);
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
expect(workers).toHaveLength(1);
});
it("falls back after onerror and malformed responses", async () => {
for (const failure of ["error", "malformed"] as const) {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const tool = makeTool(() => "direct");
const promise = execute(tool, { params: {} });
const worker = workers[0];
if (failure === "error") {
worker.fail();
} else {
worker.respond({ id: worker.requests[0].id, ok: true });
}
await expect(promise).resolves.toBe("direct");
expect(worker.terminated).toBe(true);
}
});
it("decodes image, verdict, and files worker responses", () => {
const data = new Uint8ClampedArray([1, 2, 3, 4]);
const imageResult = decodeWorkerResponse({
id: 1,
ok: true,
width: 1,
height: 1,
data,
});
expect(imageResult).toMatchObject({ width: 1, height: 1 });
const verdict = decodeWorkerResponse({
id: 2,
ok: true,
text: "line",
textVars: { kb: "1" },
});
expect(verdict).toEqual({ key: "line", vars: { kb: "1" } });
const files = decodeWorkerResponse({
id: 3,
ok: true,
files: [{ name: "part.png", width: 1, height: 1, data }],
});
expect(files).toMatchObject({
files: [{ name: "part.png", image: { width: 1, height: 1 } }],
});
});
it("round-trips FileResult through the worker protocol", () => {
const fileImage: PixelImage = {
width: 1,
height: 1,
data: new Uint8ClampedArray([1, 2, 3, 4]),
};
const fileResult = {
files: [{ name: "part-1-1.png", image: fileImage }],
};
const encoded = encodeWorkerResponse(4, fileResult);
expect(encoded.transfer).toHaveLength(1);
expect(decodeWorkerResponse(encoded.response)).toEqual(fileResult);
});
it("falls back when a files payload is malformed", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const tool = makeTool(() => "direct");
const promise = execute(tool, { params: {} });
const worker = workers[0];
const request = worker.requests[0];
worker.respond({
id: request.id,
ok: true,
files: [{ name: "part.png", width: 1, height: 1 }],
} as unknown as WorkerResponse);
await expect(promise).resolves.toBe("direct");
expect(worker.terminated).toBe(true);
});
});
describe("handleWorkerRequest", () => {
it("serializes image results and preserves ToolError keys", async () => {
const imageResult = await handleWorkerRequest({
id: 1,
toolId: "flip-png",
params: {},
source: image(),
});
expect(imageResult.response).toMatchObject({
id: 1,
ok: true,
width: 2,
height: 2,
});
expect(imageResult.transfer).toHaveLength(1);
const errorResult = await handleWorkerRequest({
id: 2,
toolId: "bytes-to-png",
params: { width: 32 },
text: Array.from({ length: 132 }, () => "1").join(" "),
});
expect(errorResult.response).toMatchObject({
id: 2,
ok: false,
errorKey: "errors.pixelCountMismatch",
errorVars: { count: 33, width: 32 },
});
});
it("serializes FileResult transfers", async () => {
const result = await handleWorkerRequest({
id: 3,
toolId: "split-into-parts-png",
params: { columns: 2, rows: 2 },
source: image(),
});
expect(result.response).toMatchObject({ id: 3, ok: true });
expect(result.transfer).toHaveLength(4);
});
});
+154 -163
View File
@@ -1,178 +1,169 @@
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
import type { ToolContext, ToolEntry, ToolResult } from "$lib/registry";
import { sanitizeSchemaParams } from "$lib/registry-schema";
import { ToolError } from "../core/errors";
import type { PixelImage } from "../core/types";
import type { ToolContext, ToolEntry, ToolResult } from "../registry";
import { sanitizeSchemaParams } from "../registry-schema";
import {
decodeWorkerResponse,
isWorkerResponse,
type WorkerRequest,
type WorkerResponse,
} from "./protocol";
/** Вход единой точки исполнения. `source`/`text` — по контракту `tool.input`. */
export type ExecuteContext = {
params: Record<string, unknown>;
source?: PixelImage;
text?: string;
};
/**
* Единственная точка исполнения инструмента. Валидирует наличие входа по
* `tool.input`, санитит параметры и запускает `tool.run`. Выбор места
* исполнения (worker/поток) — внутренняя забота executor'а, диспетча методов
* нет: у любого инструмента один `run(ctx)`.
*/
export async function execute(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
const params = sanitizeSchemaParams(tool.schema, ctx.params);
if (tool.input === "image" && !ctx.source) {
throw new ToolError("errors.sourceRequired");
}
if (tool.input === "text" && !ctx.text?.trim()) {
throw new ToolError("errors.textRequired");
}
return route(tool, { params, source: ctx.source, text: ctx.text });
export type WorkerLike = {
postMessage(message: WorkerRequest): void;
terminate(): void;
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null;
onerror: ((event: Event) => void) | null;
};
export type WorkerFactory = () => WorkerLike;
type PendingRequest = {
resolve: (result: ToolResult) => void;
reject: (error: unknown) => void;
};
function defaultWorkerFactory(): WorkerLike {
return new Worker(new URL("./executor.worker.ts", import.meta.url), {
type: "module",
}) as unknown as WorkerLike;
}
/**
* Маршрутизация по способу исполнения. В worker уходит всё, кроме
* domOnly-инструментов: даже текстовые вердикты могут быть тяжёлыми
* (гистограммы, сложный анализ), поэтому ограничивать по типу результата
* неверно. Ограничение одно — доступ к DOM, которого в worker нет.
*/
async function route(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
if (tool.domOnly || typeof Worker === "undefined") {
return await runDirect(tool, ctx);
}
const worker = ensureWorker();
if (worker === null) {
return await runDirect(tool, ctx);
}
try {
return await runInWorker(worker, tool, ctx);
} catch (workerError) {
disableWorker();
void workerError;
return await runDirect(tool, ctx);
}
}
export function createExecutor(
options: { workerFactory?: WorkerFactory } = {},
): (tool: ToolEntry, ctx: ExecuteContext) => Promise<ToolResult> {
const workerFactory = options.workerFactory ?? defaultWorkerFactory;
let worker: WorkerLike | null = null;
let workerTried = false;
let nextRequestId = 1;
const pending = new Map<number, PendingRequest>();
async function runDirect(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
}
let worker: Worker | null = null;
let workerTried = false;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (result: ToolResult) => 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;
files?: {
name: string;
width: number;
height: number;
data: Uint8ClampedArray;
}[];
text?: string;
textVars?: Record<string, string | number>;
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.ok && Array.isArray(payload.files)) {
entry.resolve({
files: payload.files.map((file) => ({
name: file.name,
image: {
width: file.width,
height: file.height,
data: new Uint8ClampedArray(file.data),
},
})),
});
} else if (payload.ok && typeof payload.text === "string") {
entry.resolve(
payload.textVars
? { key: payload.text, vars: payload.textVars }
: payload.text,
);
} 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;
function failWorker(candidate: WorkerLike, error: unknown): void {
if (worker === candidate) worker = null;
candidate.terminate();
for (const [id, entry] of pending) {
pending.delete(id);
entry.reject(error);
}
}
return worker;
}
function disableWorker(): void {
if (worker) {
worker.terminate();
}
worker = null;
}
function runInWorker(
workerInstance: Worker,
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return new Promise((resolve, reject) => {
const id = nextRequestId++;
pending.set(id, { resolve, reject });
workerInstance.postMessage({
id,
toolId: tool.id,
params: ctx.params,
source: ctx.source
? {
width: ctx.source.width,
height: ctx.source.height,
data: new Uint8ClampedArray(ctx.source.data),
function ensureWorker(): WorkerLike | null {
if (workerTried) return worker;
workerTried = true;
let candidate: WorkerLike | undefined;
try {
candidate = workerFactory();
candidate.onmessage = (event) => {
const payload: unknown = event.data;
if (!isWorkerResponse(payload)) {
failWorker(
candidate as WorkerLike,
new Error("Invalid worker response"),
);
return;
}
const entry = pending.get(payload.id);
if (!entry) return;
try {
const result = decodeWorkerResponse(payload);
pending.delete(payload.id);
entry.resolve(result);
} catch (error) {
if (error instanceof ToolError) {
pending.delete(payload.id);
entry.reject(error);
} else {
failWorker(candidate as WorkerLike, error);
}
: undefined,
text: ctx.text,
}
};
candidate.onerror = () => {
failWorker(candidate as WorkerLike, new Error("Worker error"));
};
worker = candidate;
return candidate;
} catch (error) {
candidate?.terminate();
worker = null;
return null;
}
}
function disableWorker(candidate: WorkerLike): void {
if (worker !== candidate) return;
worker = null;
candidate.terminate();
}
async function runDirect(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
}
function runInWorker(
candidate: WorkerLike,
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return new Promise((resolve, reject) => {
const id = nextRequestId++;
pending.set(id, { resolve, reject });
try {
candidate.postMessage({
id,
toolId: tool.id,
params: ctx.params,
source: ctx.source
? {
width: ctx.source.width,
height: ctx.source.height,
data: new Uint8ClampedArray(ctx.source.data),
}
: undefined,
text: ctx.text,
});
} catch (error) {
failWorker(candidate, error);
}
});
});
}
async function route(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
if (tool.domOnly) return await runDirect(tool, ctx);
const candidate = ensureWorker();
if (candidate === null) return await runDirect(tool, ctx);
try {
return await runInWorker(candidate, tool, ctx);
} catch (error) {
if (error instanceof ToolError) throw error;
disableWorker(candidate);
return await runDirect(tool, ctx);
}
}
return async (tool, ctx) => {
const params = sanitizeSchemaParams(tool.schema, ctx.params, {
source: ctx.source,
});
if (tool.input === "image" && !ctx.source) {
throw new ToolError("errors.sourceRequired");
}
if (tool.input === "text" && !ctx.text?.trim()) {
throw new ToolError("errors.textRequired");
}
return await route(tool, { ...ctx, params });
};
}
export const execute = createExecutor();
+8 -107
View File
@@ -1,113 +1,14 @@
/// <reference lib="webworker" />
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
import { getTool } from "$lib/registry";
import { sanitizeSchemaParams } from "$lib/registry-schema";
type WorkerRequest = {
id: number;
toolId: string;
params: Record<string, unknown>;
source?: { width: number; height: number; data: Uint8ClampedArray };
text?: string;
};
type WorkerResponse =
| {
id: number;
ok: true;
width?: number;
height?: number;
data?: Uint8ClampedArray;
text?: string;
textVars?: Record<string, string | number>;
files?: WorkerImageFile[];
}
| {
id: number;
ok: false;
error?: string;
errorKey?: string;
errorVars?: Record<string, string | number>;
};
type WorkerImageFile = {
name: string;
width: number;
height: number;
data: Uint8ClampedArray;
};
import { handleWorkerRequest } from "./worker-handler";
import type { WorkerRequest, WorkerResponse } from "./protocol";
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
void handle(event.data);
void respond(event.data);
};
async function handle(request: WorkerRequest): Promise<void> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new Error("errors.noImageRun");
}
const source: PixelImage | undefined = request.source
? {
width: request.source.width,
height: request.source.height,
data: new Uint8ClampedArray(request.source.data),
}
: undefined;
const output = await tool.run({
params: sanitizeSchemaParams(tool.schema, request.params),
source,
text: request.text,
});
if (typeof output === "string") {
(self as unknown as Worker).postMessage({
id: request.id,
ok: true,
text: output,
} satisfies WorkerResponse);
return;
}
if ("key" in output) {
(self as unknown as Worker).postMessage({
id: request.id,
ok: true,
text: output.key,
textVars: output.vars,
} satisfies WorkerResponse);
return;
}
if ("files" in output) {
const files = output.files.map((file) => ({
name: file.name,
width: file.image.width,
height: file.image.height,
data: file.image.data,
}));
(self as unknown as Worker).postMessage(
{ id: request.id, ok: true, files } satisfies WorkerResponse,
files.map((file) => file.data.buffer),
);
return;
}
(self as unknown as Worker).postMessage(
{
id: request.id,
ok: true,
width: output.width,
height: output.height,
data: output.data,
} satisfies WorkerResponse,
[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,
} satisfies WorkerResponse);
}
async function respond(request: WorkerRequest): Promise<void> {
const result = await handleWorkerRequest(request);
(self as unknown as Worker).postMessage(result.response, result.transfer);
}
export type { WorkerRequest, WorkerResponse };
+161
View File
@@ -0,0 +1,161 @@
import { ToolError, type ErrorVars } from "../core/errors";
import type { FileResult, ToolResult } from "../registry";
export type WorkerImage = {
width: number;
height: number;
data: Uint8ClampedArray;
};
export type WorkerFile = {
name: string;
width: number;
height: number;
data: Uint8ClampedArray;
};
export type WorkerRequest = {
id: number;
toolId: string;
params: Record<string, unknown>;
source?: WorkerImage;
text?: string;
};
export type WorkerResponse =
| {
id: number;
ok: true;
width?: number;
height?: number;
data?: Uint8ClampedArray;
text?: string;
textVars?: ErrorVars;
files?: WorkerFile[];
}
| {
id: number;
ok: false;
error?: string;
errorKey?: string;
errorVars?: ErrorVars;
};
export type WorkerResult = {
response: WorkerResponse;
transfer: Transferable[];
};
function transferables(...values: Uint8ClampedArray[]): Transferable[] {
return values.map((value) => value.buffer as ArrayBuffer);
}
export function isWorkerResponse(value: unknown): value is WorkerResponse {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { id?: unknown; ok?: unknown };
return typeof candidate.id === "number" && typeof candidate.ok === "boolean";
}
export function encodeWorkerResponse(
id: number,
result: ToolResult,
): WorkerResult {
if (typeof result === "string") {
return { response: { id, ok: true, text: result }, transfer: [] };
}
if ("key" in result) {
return {
response: { id, ok: true, text: result.key, textVars: result.vars },
transfer: [],
};
}
if ("files" in result) {
const files = result.files.map((file) => ({
name: file.name,
width: file.image.width,
height: file.image.height,
data: file.image.data,
}));
return {
response: { id, ok: true, files },
transfer: transferables(...files.map((file) => file.data)),
};
}
return {
response: {
id,
ok: true,
width: result.width,
height: result.height,
data: result.data,
},
transfer: transferables(result.data),
};
}
export function encodeWorkerError(id: number, error: unknown): WorkerResult {
const toolError = error instanceof ToolError ? error : undefined;
return {
response: {
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
errorKey: toolError?.key,
errorVars: toolError?.vars,
},
transfer: [],
};
}
function isWorkerFile(value: WorkerFile): boolean {
return (
typeof value.name === "string" &&
typeof value.width === "number" &&
typeof value.height === "number" &&
value.data instanceof Uint8ClampedArray
);
}
export function decodeWorkerResponse(payload: WorkerResponse): ToolResult {
if (payload.ok) {
if (
typeof payload.width === "number" &&
typeof payload.height === "number" &&
payload.data instanceof Uint8ClampedArray
) {
return {
width: payload.width,
height: payload.height,
data: new Uint8ClampedArray(payload.data),
};
}
if (Array.isArray(payload.files)) {
if (!payload.files.every(isWorkerFile)) {
throw new Error("Invalid worker response");
}
return {
files: payload.files.map((file) => ({
name: file.name,
image: {
width: file.width,
height: file.height,
data: new Uint8ClampedArray(file.data),
},
})),
} satisfies FileResult;
}
if (typeof payload.text === "string") {
return payload.textVars
? { key: payload.text, vars: payload.textVars }
: payload.text;
}
throw new Error("Invalid worker response");
}
if (payload.errorKey) {
throw new ToolError(payload.errorKey, payload.errorVars);
}
if (payload.error?.startsWith("errors.")) {
throw new ToolError(payload.error);
}
throw new Error(payload.error ?? "Invalid worker response");
}
+36
View File
@@ -0,0 +1,36 @@
import { ToolError } from "../core/errors";
import type { PixelImage } from "../core/types";
import { getTool } from "../registry";
import { sanitizeSchemaParams } from "../registry-schema";
import {
encodeWorkerError,
encodeWorkerResponse,
type WorkerRequest,
type WorkerResult,
} from "./protocol";
export async function handleWorkerRequest(
request: WorkerRequest,
): Promise<WorkerResult> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new ToolError("errors.noImageRun");
}
const source: PixelImage | undefined = request.source
? {
width: request.source.width,
height: request.source.height,
data: new Uint8ClampedArray(request.source.data),
}
: undefined;
const output = await tool.run({
params: sanitizeSchemaParams(tool.schema, request.params, { source }),
source,
text: request.text,
});
return encodeWorkerResponse(request.id, output);
} catch (error) {
return encodeWorkerError(request.id, error);
}
}
+11
View File
@@ -36,6 +36,17 @@ describe("t", () => {
expect(t("home.onlyEnKey")).toBe("Only English string");
});
it("переводит ошибки с vars в активной локали", () => {
setLocale("ru");
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
"33",
);
setLocale("en");
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
"33",
);
});
it("неизвестный путь возвращает сам путь", () => {
expect(t("no.such.key")).toBe("no.such.key");
});
+3 -53
View File
@@ -724,9 +724,6 @@ export const ru: Dict = {
toColor: "Цвет конца",
direction: "Направление",
},
options: {
direction: { horizontal: "По горизонтали", vertical: "По вертикали" },
},
},
"color-wheel-png": {
title: "Цветовой круг PNG",
@@ -1051,13 +1048,6 @@ export const ru: Dict = {
backgroundColor: "Цвет фона",
padding: "Отступ, px",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
},
},
"emoji-to-png": {
title: "Эмодзи в PNG",
@@ -1135,8 +1125,11 @@ export const ru: Dict = {
"8": "8",
"16": "16",
"32": "32",
"44": "44 (сильный)",
"64": "64",
"96": "96 (сбалансированный)",
"128": "128",
"192": "192 (лёгкий)",
"256": "256",
},
},
@@ -1269,13 +1262,6 @@ export const ru: Dict = {
font: "Шрифт",
bold: "Жирный",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
},
},
"add-text-png": {
title: "Надпись на PNG",
@@ -1293,24 +1279,6 @@ export const ru: Dict = {
plateColor: "Цвет подложки",
plateOpacity: "Прозрачность плашки, %",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
position: {
"top-left": "Сверху слева",
"top-center": "Сверху по центру",
"top-right": "Сверху справа",
"middle-left": "По центру слева",
center: "По центру",
"middle-right": "По центру справа",
"bottom-left": "Снизу слева",
"bottom-center": "Снизу по центру",
"bottom-right": "Снизу справа",
},
},
},
"date-stamp-png": {
title: "Дата-штамп PNG",
@@ -1328,24 +1296,6 @@ export const ru: Dict = {
plateColor: "Цвет подложки",
plateOpacity: "Прозрачность плашки, %",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
position: {
"top-left": "Сверху слева",
"top-center": "Сверху по центру",
"top-right": "Сверху справа",
"middle-left": "По центру слева",
center: "По центру",
"middle-right": "По центру справа",
"bottom-left": "Снизу слева",
"bottom-center": "Снизу по центру",
"bottom-right": "Снизу справа",
},
},
},
"skew-png": {
title: "Наклонить PNG",
+47 -1
View File
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";
import { TOOLS } from "../registry";
import { setLocale } from "./locale.svelte";
import { searchTools, verdictText } from "./schema-tool-strings";
import {
searchTools,
toolDescription,
toolTitle,
verdictText,
} from "./schema-tool-strings";
afterEach(() => {
setLocale("ru");
@@ -45,6 +50,18 @@ describe("searchTools (кросс-языковой поиск каталога)"
});
});
describe("metadata fallback", () => {
it("uses registry title and description in the base locale", () => {
setLocale("en");
for (const tool of TOOLS) {
expect(toolTitle(tool), `${tool.id}: title`).toBe(tool.title);
expect(toolDescription(tool), `${tool.id}: description`).toBe(
tool.description,
);
}
});
});
describe("verdictText (вердикт с vars)", () => {
it("интерполирует {vars} в обоих локалях", () => {
setLocale("ru");
@@ -60,4 +77,33 @@ describe("verdictText (вердикт с vars)", () => {
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
expect(verdictText("png-file-size", "нет-такого")).toBe("нет-такого");
});
it.each([
[
"verify-is-png",
"verifyYes",
"Да — сигнатура настоящего PNG.",
"Yes — this is a valid PNG signature.",
],
[
"png-is-transparent",
"transparentNo",
"Нет — все пиксели полностью непрозрачны.",
"No — all pixels are fully opaque.",
],
[
"png-orientation",
"orientationLandscape",
"Ландшафт — ширина больше высоты.",
"Landscape — width is greater than height.",
],
])(
"переводит verdict %s/%s в обеих локалях",
(toolId, key, ruText, enText) => {
setLocale("ru");
expect(verdictText(toolId, key)).toBe(ruText);
setLocale("en");
expect(verdictText(toolId, key)).toBe(enText);
},
);
});
+32
View File
@@ -15,6 +15,22 @@ function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } {
return { fields, groups };
}
type SelectOption = { value: string; label: string };
function selectOptions(
tool: ToolEntry,
): { fieldId: string; option: SelectOption }[] {
const result: { fieldId: string; option: SelectOption }[] = [];
for (const [fieldId, field] of Object.entries(tool.schema?.fields ?? {})) {
const spec = (
field as { spec?: { kind?: string; options?: SelectOption[] } }
).spec;
if (spec?.kind !== "select") continue;
for (const option of spec.options ?? []) result.push({ fieldId, option });
}
return result;
}
describe("полнота словарей для нового registry", () => {
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
for (const tool of TOOLS) {
@@ -43,6 +59,22 @@ describe("полнота словарей для нового registry", () => {
}
});
it("все active select options переведены в en и ru", () => {
for (const tool of TOOLS) {
for (const { fieldId, option } of selectOptions(tool)) {
if (fieldId === "component") continue;
const key = `${tool.id}: ${fieldId}.${option.value}`;
expect(en.tools[tool.id]?.options?.[fieldId]?.[option.value], key).toBe(
option.label,
);
expect(
ru.tools[tool.id]?.options?.[fieldId]?.[option.value],
key,
).toBeTruthy();
}
}
});
it("в словарях нет лишних инструментов", () => {
const ids = new Set(TOOLS.map((tool) => tool.id));
for (const dict of [ru.tools, en.tools]) {
+142
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import {
applySourceDefaults,
defaultSchemaParams,
field,
resolveLayoutGroups,
sanitizeSchemaParams,
toolSchema,
type Dimension,
} from "./registry-schema";
interface FrameParams {
@@ -32,6 +35,24 @@ const frameSchema = toolSchema<FrameParams>(
},
);
interface SourceParams {
size: Dimension;
fixed: Dimension;
}
const sourceSchema = toolSchema<SourceParams>({
size: field.dimension({
min: 1,
max: 100,
width: 1,
height: 1,
defaultFromSource: true,
}),
fixed: field.dimension({ min: 1, max: 100, width: 10, height: 20 }),
});
const source = { width: 64, height: 48 };
describe("registry-schema: дефолты из схемы", () => {
it("собирает дефолты всех полей", () => {
expect(defaultSchemaParams(frameSchema)).toEqual({
@@ -43,6 +64,51 @@ describe("registry-schema: дефолты из схемы", () => {
});
});
describe("registry-schema: source-aware dimension defaults", () => {
it("uses static fallback before a source exists", () => {
expect(defaultSchemaParams(sourceSchema)).toEqual({
size: { width: 1, height: 1 },
fixed: { width: 10, height: 20 },
});
});
it("uses source dimensions for opted-in fields only", () => {
expect(defaultSchemaParams(sourceSchema, { source })).toEqual({
size: source,
fixed: { width: 10, height: 20 },
});
expect(
applySourceDefaults(
sourceSchema,
{ size: { width: 2, height: 3 }, fixed: { width: 30, height: 40 } },
{ source },
),
).toEqual({
size: source,
fixed: { width: 30, height: 40 },
});
});
it("uses source dimensions when sanitizing missing values", () => {
expect(sanitizeSchemaParams(sourceSchema, {}, { source })).toEqual({
size: source,
fixed: { width: 10, height: 20 },
});
const manual = sanitizeSchemaParams(
sourceSchema,
{ size: { width: 12, height: 13 } },
{ source },
);
expect(manual.size).toEqual({ width: 12, height: 13 });
const sentinel = sanitizeSchemaParams(
sourceSchema,
{ size: { width: 0, height: 0 } },
{ source },
);
expect(sentinel.size).toEqual(source);
});
});
describe("registry-schema: раскладка (schema.layout)", () => {
it("toolSchema сохраняет группы полей", () => {
expect(frameSchema.layout).toEqual({
@@ -65,6 +131,82 @@ describe("registry-schema: раскладка (schema.layout)", () => {
});
expect(plain.layout).toBeUndefined();
});
it("разрешает named-группы и хвост без layout", () => {
expect(resolveLayoutGroups(frameSchema)).toEqual([
{
key: "Frame-0",
title: "Frame",
cols: 1,
fields: ["thickness", "color"],
},
{ key: "__default", cols: 1, fields: ["enabled", "count"] },
]);
});
it("убирает неизвестные и повторные поля, нормализует cols", () => {
const schema = toolSchema<FrameParams>(
{
thickness: field.slider({ min: 1, max: 500, default: 5 }),
color: field.color({ default: "#000000" }),
enabled: field.checkbox({ default: true }),
count: field.select({
default: "two",
options: [
{ value: "one", label: "One" },
{ value: "two", label: "Two" },
],
}),
},
{
layout: {
groups: [
{
title: "Duplicate",
cols: 0,
fields: ["thickness", "thickness", "missing"],
},
{ title: "Empty", fields: ["missing"] },
{ cols: 3, fields: ["color"] },
],
},
},
);
expect(resolveLayoutGroups(schema)).toEqual([
{
key: "Duplicate-0",
title: "Duplicate",
cols: 1,
fields: ["thickness"],
},
{ key: "group-2", title: undefined, cols: 3, fields: ["color"] },
{ key: "__default", cols: 1, fields: ["enabled", "count"] },
]);
});
it("складывает все поля без layout в default-группу", () => {
const plain = toolSchema<FrameParams>({
thickness: field.slider({ min: 1, max: 500, default: 5 }),
color: field.color({ default: "#000000" }),
enabled: field.checkbox({ default: true }),
count: field.select({
default: "two",
options: [
{ value: "one", label: "One" },
{ value: "two", label: "Two" },
],
}),
});
expect(resolveLayoutGroups(plain)).toEqual([
{
key: "__default",
cols: 1,
fields: ["thickness", "color", "enabled", "count"],
},
]);
});
});
describe("registry-schema: санитайз", () => {
+81 -10
View File
@@ -1,8 +1,4 @@
// Новая типизированная схема параметров (для нового UI, старый UI не трогаем).
//
// Параллельный источник правды к старому `ParamDef[]`: старый pipeline
// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/
// валидацию поверх `ToolSchema<P>`.
// Типизированная схема параметров для нового UI.
//
// Связка типов на этапе компиляции (защита от рассинхрона):
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
@@ -73,6 +69,11 @@ export interface DimensionSpec extends FieldSpecBase {
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
}
export interface SchemaContext {
source?: Dimension;
}
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
@@ -274,6 +275,7 @@ export const field = {
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
}): Field<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
@@ -339,21 +341,68 @@ export function toolSchema<P>(
return { fields, ...(meta ?? {}) };
}
export interface ResolvedLayoutGroup {
key: string;
title?: string;
cols: number;
fields: string[];
}
export function resolveLayoutGroups<P>(
schema: ToolSchema<P>,
): ResolvedLayoutGroup[] {
const all = Object.keys(schema.fields);
const groups = schema.layout?.groups ?? [];
const used: Record<string, true> = {};
const named: ResolvedLayoutGroup[] = groups
.map((group, index) => {
const fields = group.fields.filter((fieldId) => {
if (used[fieldId] || !(fieldId in schema.fields)) return false;
used[fieldId] = true;
return true;
});
return {
key: `${group.title ?? "group"}-${index}`,
title: group.title,
cols: Math.max(1, Math.trunc(group.cols ?? 1)),
fields,
} satisfies ResolvedLayoutGroup;
})
.filter((group) => group.fields.length > 0);
const rest = all.filter((fieldId) => !used[fieldId]);
if (rest.length > 0) {
named.push({ key: "__default", cols: 1, fields: rest });
}
return named;
}
function clamp(value: number, min?: number, max?: number): number {
if (min !== undefined && value < min) return min;
if (max !== undefined && value > max) return max;
return value;
}
function resolveDimensionDefaults(
spec: DimensionSpec,
context?: SchemaContext,
): Dimension {
const source = spec.defaultFromSource ? context?.source : undefined;
return {
width: clamp(source?.width ?? spec.width, spec.min, spec.max),
height: clamp(source?.height ?? spec.height, spec.min, spec.max),
};
}
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
export function defaultSchemaParams<P>(
schema: ToolSchema<P>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "dimension") {
out[key] = { width: spec.width, height: spec.height };
out[key] = resolveDimensionDefaults(spec, context);
} else if (spec.kind === "color-pair") {
out[key] = { from: spec.from, to: spec.to };
} else if (spec.kind === "colors") {
@@ -386,10 +435,27 @@ export function defaultSchemaParams<P>(
return out;
}
export function applySourceDefaults<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context: SchemaContext,
): Record<keyof P, unknown> {
const defaults = defaultSchemaParams(schema, context);
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "dimension" && spec.defaultFromSource) continue;
if (Object.prototype.hasOwnProperty.call(values, key)) {
defaults[key] = values[key as string];
}
}
return defaults;
}
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
export function sanitizeSchemaParams<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
@@ -429,17 +495,22 @@ export function sanitizeSchemaParams<P>(
"height" in raw
? (raw as Record<string, unknown>)
: undefined;
const defaults = resolveDimensionDefaults(spec, context);
const w =
typeof r?.width === "number" && Number.isFinite(r.width)
? r.width
: spec.width;
: defaults.width;
const h =
typeof r?.height === "number" && Number.isFinite(r.height)
? r.height
: spec.height;
: defaults.height;
const sourceSize =
spec.defaultFromSource && context?.source && w === 0 && h === 0
? defaults
: { width: w, height: h };
out[key] = {
width: clamp(w, spec.min, spec.max),
height: clamp(h, spec.min, spec.max),
width: clamp(sourceSize.width, spec.min, spec.max),
height: clamp(sourceSize.height, spec.min, spec.max),
};
break;
}
+4
View File
@@ -105,6 +105,7 @@ const pngToBase64: ToolEntry<NoParams> = {
schema: emptySchema,
input: "image",
result: "text",
domOnly: true,
run: imgTool((img) => toBase64(img)),
};
@@ -117,6 +118,7 @@ const pngToDataUri: ToolEntry<NoParams> = {
schema: emptySchema,
input: "image",
result: "text",
domOnly: true,
run: imgTool((img) => toDataUrl(img)),
};
@@ -163,6 +165,7 @@ const base64ToPng: ToolEntry<NoParams> = {
category: "convert",
schema: emptySchema,
input: "text",
domOnly: true,
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
};
@@ -173,6 +176,7 @@ const dataUriToPng: ToolEntry<NoParams> = {
category: "convert",
schema: emptySchema,
input: "text",
domOnly: true,
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
};
+1
View File
@@ -253,6 +253,7 @@ const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
category: "filters",
schema: jpegArtifactsSchema,
input: "image",
domOnly: true,
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
};
+1
View File
@@ -487,6 +487,7 @@ const placeholder: ToolEntry<PlaceholderParams> = {
category: "generate",
schema: placeholderSchema,
input: "none",
domOnly: true,
run: genTool((p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
+7 -5
View File
@@ -194,8 +194,9 @@ export const resizeSchema = toolSchema<ResizeParams>(
label: "fields.canvasSize",
min: 0,
max: 20000,
width: 0,
height: 0,
width: 1,
height: 1,
defaultFromSource: true,
}),
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
},
@@ -263,10 +264,11 @@ export const cropSchema = toolSchema<CropParams>(
}),
size: field.dimension({
label: "fields.cropAreaSize",
min: 0,
min: 1,
max: 2000,
width: 0,
height: 0,
width: 1,
height: 1,
defaultFromSource: true,
}),
},
{
+86
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { TOOLS } from ".";
import { PREVIEW_GROUPS } from "../catalog";
import type { PixelImage } from "../core/types";
import type { OutputMime } from "../core/io";
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
import type { FileResult, ToolResult } from "./types";
@@ -65,6 +66,91 @@ describe("registry-new (переведённые инструменты)", () =>
}
});
it("DOM-dependent tools bypass the worker", () => {
for (const id of [
"png-to-base64",
"png-to-data-uri",
"base64-to-png",
"data-uri-to-png",
"jpeg-artifacts-png",
"placeholder-png",
]) {
expect(TOOLS.find((tool) => tool.id === id)?.domOnly, id).toBe(true);
}
});
it("output metadata согласован с MIME и quality-схемой", () => {
const extensions: Record<OutputMime, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/bmp": "bmp",
};
for (const tool of TOOLS) {
const output = tool.output;
if (!output) continue;
expect(extensions[output.mime], `${tool.id}: mime`).toBe(output.ext);
if (output.qualityParamId) {
const qualityField = tool.schema.fields[output.qualityParamId];
expect(
qualityField,
`${tool.id}.${output.qualityParamId}`,
).toBeDefined();
expect(["number", "slider"], `${tool.id}: quality kind`).toContain(
qualityField?.spec.kind,
);
} else if (output.mime === "image/jpeg" || output.mime === "image/webp") {
expect(output.qualityParamId, `${tool.id}: quality`).toBeTruthy();
}
if (output.mime === "image/bmp") {
expect(
output.qualityParamId,
`${tool.id}: bmp quality`,
).toBeUndefined();
}
}
});
it("source-aware dimension defaults применяются только к image tools", () => {
for (const tool of TOOLS) {
for (const field of Object.values(tool.schema.fields)) {
if (field.spec.kind === "dimension" && field.spec.defaultFromSource) {
expect(tool.input, tool.id).toBe("image");
}
}
}
});
it("resize и crop получают размеры текущего source", async () => {
const source = solid(64, 48);
const resize = TOOLS.find((t) => t.id === "resize-png")!;
const resizeDefaults = defaultSchemaParams(resize.schema, { source });
expect(resizeDefaults.size).toEqual({ width: 64, height: 48 });
const resized = asImage(
await resize.run({
source,
params: sanitizeSchemaParams(resize.schema, resizeDefaults, { source }),
}),
);
expect([resized.width, resized.height]).toEqual([64, 48]);
const crop = TOOLS.find((t) => t.id === "crop-png")!;
const cropDefaults = defaultSchemaParams(crop.schema, { source });
expect(cropDefaults).toMatchObject({
x: 0,
y: 0,
size: { width: 64, height: 48 },
});
const cropped = asImage(
await crop.run({
source,
params: sanitizeSchemaParams(crop.schema, cropDefaults, { source }),
}),
);
expect([cropped.width, cropped.height]).toEqual([64, 48]);
});
it("create-empty: dimension-дефолты корректны", () => {
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
const d = defaultSchemaParams(tool.schema);
-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>

Some files were not shown because too many files have changed in this diff Show More