mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-14 17:38:19 +00:00
Compare commits
16
Commits
becf03f416
...
0073d3d4cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0073d3d4cc | ||
|
|
6ab59897d1 | ||
|
|
cdb23fe1c1 | ||
|
|
ca016ada43 | ||
|
|
468beec998 | ||
|
|
712fd3cb2e | ||
|
|
746d6a6c8f | ||
|
|
0933f1cdea | ||
|
|
5f77c097e2 | ||
|
|
5313567e70 | ||
|
|
16d4288912 | ||
|
|
473b62ef53 | ||
|
|
c1d2957ba5 | ||
|
|
ebb2176a2c | ||
|
|
acf9f19f1b | ||
|
|
c187619933 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 строк), атомарными.
|
||||
|
||||
@@ -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**
|
||||
@@ -47,7 +57,8 @@ Static output is written to `web/build/`.
|
||||
### Other Commands
|
||||
|
||||
| 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) |
|
||||
@@ -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
|
||||
|
||||
@@ -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-критерии без явной сверки.
|
||||
- После каждого шага плана агент останавливается, показывает изменённые файлы,
|
||||
проверки и нерешённые решения, и ждёт ревью.
|
||||
@@ -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`.
|
||||
|
||||
Следующий шаг начинается только после ревью текущего изменения.
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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К) в двух
|
||||
|
||||
@@ -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, а какие —
|
||||
отдельным ночным прогоном?
|
||||
@@ -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: неверное название
|
||||
|
||||
|
||||
@@ -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
@@ -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 компонента. Тесты/билд прогонять после каждого коммита,
|
||||
|
||||
+25
-26
@@ -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* поверх обычных операций).
|
||||
@@ -80,12 +79,12 @@
|
||||
Чего нет (пробелы закрываются этапами ниже):
|
||||
|
||||
| Пробел | Где сейчас | Этап |
|
||||
| ----------------------------------------------------- | ----------------------------------- | ------ |
|
||||
| ----------------------------------------------------- | ---------------------------------------- | ---- |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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. Что осознанно НЕ делаем
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+28
-33
@@ -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 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 expect(toolLink(page, "flip-png")).toBeVisible();
|
||||
await expect(toolLink(page, "resize-png")).toBeHidden();
|
||||
});
|
||||
|
||||
+54
-16
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
+24
-21
@@ -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 () => {
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
await ruBtn.click();
|
||||
await expect(page.locator(".schema-tool h1")).toHaveText(
|
||||
"Изменить размер PNG",
|
||||
);
|
||||
}).toPass();
|
||||
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\./);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
+147
-46
@@ -1,3 +1,4 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
corruptPng,
|
||||
@@ -7,79 +8,176 @@ 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();
|
||||
try {
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(window, "Worker", {
|
||||
value: undefined,
|
||||
@@ -87,21 +185,24 @@ test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await useEnglish(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(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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 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 expect(async () => {
|
||||
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 () => {
|
||||
test("verify-is-png renders different verdicts for valid and invalid input", async ({
|
||||
page,
|
||||
}) => {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}",',
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,9 +3,6 @@
|
||||
*
|
||||
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
|
||||
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
|
||||
* Этот файл — локальная копия подхода для нового UI; старый
|
||||
* `lib/v1/categories` (массив + union вручную) не трогаем — он обслуживает
|
||||
* старый UI в `/v1/*`.
|
||||
*
|
||||
* Человекочитаемые названия живут в словарях i18n: секция categories,
|
||||
* ключ = CategoryId.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,6 +136,9 @@
|
||||
|
||||
async function download() {
|
||||
if (!schema) return;
|
||||
running = true;
|
||||
displayError = null;
|
||||
try {
|
||||
if (resultKind === "files") {
|
||||
if (!fileResult) return;
|
||||
await downloadZip(fileResult.files, `${tool.id}.zip`);
|
||||
@@ -127,12 +150,12 @@
|
||||
? 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);
|
||||
downloadBlob(blob, `${tool.id}.${out?.ext ?? "png"}`);
|
||||
} catch (e) {
|
||||
displayError = toDisplayError(e);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
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);
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+121
-130
@@ -1,167 +1,123 @@
|
||||
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>>);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
function ensureWorker(): WorkerLike | null {
|
||||
if (workerTried) return worker;
|
||||
workerTried = true;
|
||||
let candidate: WorkerLike | undefined;
|
||||
try {
|
||||
const candidate = new Worker(
|
||||
new URL("./executor.worker.ts", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
candidate = workerFactory();
|
||||
candidate.onmessage = (event) => {
|
||||
const payload: unknown = event.data;
|
||||
if (!isWorkerResponse(payload)) {
|
||||
failWorker(
|
||||
candidate as WorkerLike,
|
||||
new Error("Invalid worker response"),
|
||||
);
|
||||
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>;
|
||||
};
|
||||
return;
|
||||
}
|
||||
const entry = pending.get(payload.id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
const result = decodeWorkerResponse(payload);
|
||||
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));
|
||||
entry.resolve(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ToolError) {
|
||||
pending.delete(payload.id);
|
||||
entry.reject(error);
|
||||
} else {
|
||||
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
|
||||
failWorker(candidate as WorkerLike, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
candidate.onerror = () => {
|
||||
worker = null;
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(new Error("errors.workerUnavailable"));
|
||||
}
|
||||
pending.clear();
|
||||
failWorker(candidate as WorkerLike, new Error("Worker error"));
|
||||
};
|
||||
worker = candidate;
|
||||
} catch {
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
candidate?.terminate();
|
||||
worker = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function disableWorker(): void {
|
||||
if (worker) {
|
||||
worker.terminate();
|
||||
}
|
||||
function disableWorker(candidate: WorkerLike): void {
|
||||
if (worker !== candidate) return;
|
||||
worker = null;
|
||||
}
|
||||
candidate.terminate();
|
||||
}
|
||||
|
||||
function runInWorker(
|
||||
workerInstance: Worker,
|
||||
async function runDirect(
|
||||
tool: ToolEntry,
|
||||
ctx: ExecuteContext,
|
||||
): Promise<ToolResult> {
|
||||
): 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 });
|
||||
workerInstance.postMessage({
|
||||
try {
|
||||
candidate.postMessage({
|
||||
id,
|
||||
toolId: tool.id,
|
||||
params: ctx.params,
|
||||
@@ -174,5 +130,40 @@ function runInWorker(
|
||||
: 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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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: санитайз", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)))),
|
||||
};
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
|
||||
category: "filters",
|
||||
schema: jpegArtifactsSchema,
|
||||
input: "image",
|
||||
domOnly: true,
|
||||
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user