mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-15 17:58:18 +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
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- 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**. Запускать команды из `web/`, либо через обёртки в
|
||||||
корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать
|
корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать
|
||||||
npm.
|
npm.
|
||||||
- Сборка: `pnpm --dir web build`, проверка типов:
|
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web check`, тесты:
|
||||||
`pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, тесты:
|
`pnpm --dir web test` (Vitest), тесты lint-правил:
|
||||||
`pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
|
`pnpm --dir web test:rules`, e2e: `pnpm --dir web test:e2e` (Playwright,
|
||||||
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт
|
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт
|
||||||
4173).
|
4173).
|
||||||
- Форматирование: `pnpm format` в корне (оба прогона) или
|
- Форматирование: `pnpm format` в корне (оба прогона) или
|
||||||
`pnpm --dir web format` (только код web/). Проверка: `pnpm check:docs`
|
`pnpm --dir web format` (только код web/). Проверка: `pnpm check:docs`
|
||||||
(docs) + `pnpm --dir web exec prettier --check .` (код).
|
(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` (ESLint). Полный прогон дизайн-проверок:
|
||||||
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
|
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
|
||||||
выполняется, даже если предыдущий упал. Текущие «ожидаемые долги» (hardcoded
|
выполняется, даже если предыдущий упал. Новые изменения не должны добавлять
|
||||||
размеры в kit до C19, не-Prefix токены в `app_v1.css`) — техдолг: чинить
|
предупреждения без записи в baseline.
|
||||||
только по заведённым tasks, не игнорировать правилом.
|
|
||||||
|
|
||||||
## Правила кода
|
## Правила кода
|
||||||
|
|
||||||
Синтаксические ограничения описывать не нужно — они проверяются линтером
|
Синтаксические ограничения описывать не нужно — они проверяются линтером (весь
|
||||||
(весь список правил и токены-префиксы в `web/eslint-plugins/README.md`,
|
список правил и токены-префиксы в `web/eslint-plugins/README.md`, прогон:
|
||||||
прогон: `pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то,
|
`pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то, что
|
||||||
что линтер не умеет.
|
линтер не умеет.
|
||||||
|
|
||||||
### Svelte 5
|
### Svelte 5
|
||||||
|
|
||||||
Типизация пропсов через локальный `interface Props` закреплена правилом
|
Типизация пропсов через локальный `interface Props` закреплена правилом
|
||||||
`conventions/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` и т.д.)
|
строка `conventions/no-string-union-alias` и т.д.)
|
||||||
|
|
||||||
@@ -47,17 +66,25 @@
|
|||||||
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
||||||
которые линтер не проверяет:
|
которые линтер не проверяет:
|
||||||
|
|
||||||
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
|
- Новый дизайн живёт в `web/src/app.css` и применяется ко всем
|
||||||
`web/src/app_v1.css` (маршруты `/v1/*`).
|
production-маршрутам.
|
||||||
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
||||||
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
||||||
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
||||||
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
|
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: коммиты делает разработчик после ревью, самому не коммитить.
|
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
|
||||||
Изменения делать небольшими (< ~500 строк), атомарными.
|
Изменения делать небольшими (< ~500 строк), атомарными.
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
# easy-png-tools
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Pixel-level image processing** — format conversion, transparency/alpha channel, color transformations, geometry, filters/convolutions, morphology, quantization & palettes, image generation, text/watermarks, analysis & checks
|
- **Pixel-level image processing** — format conversion, transparency/alpha
|
||||||
- **Pipeline system (workspace)** — load image → chain multiple tool steps → sequential application → download result; pipelines saved to localStorage, exportable as JSON
|
channel, color transformations, geometry, filters/convolutions, morphology,
|
||||||
- **Web Worker execution** — heavy operations run off the main thread with automatic fallback to direct calls
|
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
|
- **i18n** — full Russian and English localization with search matching
|
||||||
- **Dark/Light themes** — persisted to localStorage
|
- **Dark/Light themes** — persisted to localStorage
|
||||||
- **Pure TypeScript core** — `lib/core/` operates on `ImageData`/`Uint8ClampedArray` with no DOM dependency
|
- **Pure TypeScript core** — `lib/core/` operates on
|
||||||
- **~30+ tools** across categories: transparency, color, geometry, filters, morphology, palettes, generation, text, analysis
|
`ImageData`/`Uint8ClampedArray` with no DOM dependency
|
||||||
|
- **~30+ tools** across categories: transparency, color, geometry, filters,
|
||||||
|
morphology, palettes, generation, text, analysis
|
||||||
|
|
||||||
## Tech Stack
|
## 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)
|
- **Vite** + **TypeScript** (strict)
|
||||||
- **Vitest** for unit tests
|
- **Vitest** for unit tests
|
||||||
- **ESLint** + **Prettier**
|
- **ESLint** + **Prettier**
|
||||||
@@ -47,7 +57,8 @@ Static output is written to `web/build/`.
|
|||||||
### Other Commands
|
### Other Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------ | ------------------------- |
|
| ------------------------ | ------------------------------ |
|
||||||
|
| `pnpm verify` | Run the full fast quality gate |
|
||||||
| `pnpm --dir web test` | Run unit tests (Vitest) |
|
| `pnpm --dir web test` | Run unit tests (Vitest) |
|
||||||
| `pnpm --dir web check` | Type-check (svelte-check) |
|
| `pnpm --dir web check` | Type-check (svelte-check) |
|
||||||
| `pnpm --dir web lint` | Lint (ESLint) |
|
| `pnpm --dir web lint` | Lint (ESLint) |
|
||||||
@@ -75,13 +86,18 @@ easy-png-tools/
|
|||||||
|
|
||||||
## Architecture
|
## 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
|
## 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)
|
1. **Phase 1** — TypeScript core + web UI (in progress)
|
||||||
2. **Phase 2** — Rust/WASM core for performance-critical operations
|
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-утилит для браузера
|
# Анализ: набор PNG-утилит для браузера
|
||||||
|
|
||||||
|
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
|
||||||
|
> ранний исследовательский разбор, не являющийся текущим планом Использование:
|
||||||
|
> только историческая справка
|
||||||
|
|
||||||
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере.
|
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере.
|
||||||
Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких
|
Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких
|
||||||
загрузок на сервер, всё считается локально. Стек: **SvelteKit +
|
загрузок на сервер, всё считается локально. Стек: **SvelteKit +
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
|
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
|
||||||
|
> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в
|
||||||
|
> backlog. Использование: только историческая справка
|
||||||
|
|
||||||
# План: i18n в preview (новый UI)
|
# План: i18n в preview (новый UI)
|
||||||
|
|
||||||
> Статус: готов к реализации (вопросы §5 решены). Соответствует беклог-задаче
|
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
|
||||||
> «добавить i18n в preview» (`docs/backlog.md`). Реализация не начата.
|
> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2
|
||||||
|
> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и
|
||||||
|
> ограничения — в `docs/plan-codebase-quality.md`.
|
||||||
|
|
||||||
## 1. Что получается
|
## 1. Что получается
|
||||||
|
|
||||||
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
|
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
|
||||||
`v1`) переходит на существующий модуль `lib/i18n`: переключатель RU/EN в шапке
|
`v1`) использует модуль `lib/i18n`: переключатель RU/EN в шапке работает по
|
||||||
работает по-настоящему, все строки локализованы, ошибки уже локализуются.
|
локали, строки и ошибки локализованы. Остаточные задачи — довести паритет
|
||||||
Инфраструктура i18n уже есть и проверена (v1-ветка) — задача в том, чтобы
|
словарей до zero-warning и спроектировать plural-формы.
|
||||||
подключить к ней preview и закрыть пробелы словарей.
|
|
||||||
|
|
||||||
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
|
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
|
||||||
копия `lib/i18n` → `lib/v1/i18n`, у нового UI — своя независимая ветка
|
копия `lib/i18n` → `lib/v1/i18n`, у нового UI — своя независимая ветка
|
||||||
@@ -278,11 +283,11 @@
|
|||||||
`empty value "x.y" in en`.
|
`empty value "x.y" in en`.
|
||||||
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
|
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
|
||||||
всех локалях — поймать потерянную при переводе переменную.
|
всех локалях — поймать потерянную при переводе переменную.
|
||||||
- **8.3** Цель — zero-warn. Сейчас `en.ts` неполный (в `tools` только
|
- **8.3** Политика источников: `tools.*.title` и `tools.*.description` в
|
||||||
результаты-помощники, остальной английский — из реестра), поэтому после
|
`BASE_LOCALE` намеренно берутся из registry English fallback; остальные локали
|
||||||
включения будет много warn'ов: доводим `en.ts` до полноты `ru.ts` (секции +
|
обязаны иметь физические переводы. Старый `tools.*.params` не используется
|
||||||
`tools` полностью, source — registry-English, без дрейфа). До достижения цели
|
новым UI и пока исключён policy-pattern, а не списком id. Target — zero-warn
|
||||||
допускается allowlist-механизм в ядре правила для осознанных отклонений.
|
без молчаливых подавлений.
|
||||||
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
|
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
|
||||||
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
|
`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) — это
|
Идея выложить на Vercel и использовать Vercel KV (на базе Upstash Redis) — это
|
||||||
отличный, рабочий и очень быстрый в реализации вариант. Для 150 инструментов
|
отличный, рабочий и очень быстрый в реализации вариант. Для 150 инструментов
|
||||||
объем данных будет крошечным, но здесь есть пара критически важных нюансов по
|
объем данных будет крошечным, но здесь есть пара критически важных нюансов по
|
||||||
+68
-58
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`.
|
||||||
`docs/plan-preview-i18n.md`
|
Правило реализовано; Q2 перевёл i18n baseline к zero-warning через
|
||||||
|
policy-aware fallback.
|
||||||
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
||||||
что за `SonarLint` или написать кастомный линтер)
|
что за `SonarLint` или написать кастомный линтер)
|
||||||
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
- [ ] проверка текстов в 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)
|
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
||||||
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
|
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
|
||||||
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
|
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
|
||||||
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность
|
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
||||||
(range?)
|
|
||||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
||||||
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
||||||
значения
|
значения
|
||||||
@@ -72,8 +74,7 @@
|
|||||||
|
|
||||||
- [x] добавить i18n в preview
|
- [x] добавить i18n в preview
|
||||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||||
`tPlural()` и формат `PluralForms`, детали в §Фаза 9
|
`tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog
|
||||||
`docs/plan-preview-i18n.md`
|
|
||||||
|
|
||||||
## SEO/GEO
|
## SEO/GEO
|
||||||
|
|
||||||
@@ -100,38 +101,44 @@
|
|||||||
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
|
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
|
||||||
(фазы 3–8) — здесь только новые треки.
|
(фазы 3–8) — здесь только новые треки.
|
||||||
|
|
||||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1;
|
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
|
||||||
C19 отложен — не блокер)
|
|
||||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||||
(после wasm-фаз 5–7)
|
(после wasm-фаз 5–7)
|
||||||
|
|
||||||
## Баги preview
|
## Качество production UI
|
||||||
|
|
||||||
Зарегистрированы при написании e2e (`web/e2e/`, `pnpm --dir web test:e2e`) и
|
Q5–Q6f.3 закрывают известные preview-проблемы, special fixtures, smoke,
|
||||||
ручного чек-листа — см. `docs/checklist-manual-testing.md` (раздел G). Каждый
|
layout/view-model инварианты и browser matrix; активных `test.fixme` в e2e
|
||||||
зафиксирован как `test.fixme`. После фикса бага: убрать соответствующий
|
больше нет.
|
||||||
`test.fixme` и отметить пункт G в чек-листе как пройденный.
|
|
||||||
|
|
||||||
1. **Генераторы без кнопки «Generate»** — все 21 инструмент категории GENERATE
|
- [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`.
|
||||||
открываются, но в UI нет кнопки Generate и полей схемы (только RU/EN/Reset);
|
- [x] `resize-png` и `crop-png` используют размеры текущего source через общий
|
||||||
результат через UI недостижим. В `SchemaToolView.svelte` `isGenerator`
|
source-aware schema resolver — `web/e2e/pipeline.spec.ts`.
|
||||||
определяется как `tool.generate && !tool.run`, кнопка рисуется только если
|
- [x] Reset сохраняет source/text, восстанавливает defaults и запускает deferred
|
||||||
`ongenerate` прокинут, а для генераторов с дефолтным `input: "file"` — нет.
|
auto-run — `web/e2e/pipeline.spec.ts`.
|
||||||
Ожидание: поля схемы + кнопка «Generate», результат-картинка по дефолтам.
|
- [x] Ошибки хранят i18n key/vars и переводятся при выводе; изменение языка
|
||||||
Fixme: `web/e2e/generators.spec.ts`. Кнопки и не должно быть, все процессы
|
обновляет alert — `web/e2e/i18n.spec.ts`.
|
||||||
должны запускаться автоматически
|
- [x] Malformed/special PNG fixtures: CRC, truncated, palette и 16-bit проходят
|
||||||
2. **resize-png с дефолтами падает** — схема по умолчанию `size: 0×0` +
|
через upload без краша — `web/e2e/png-fixtures.spec.ts`.
|
||||||
`keepAspect: true` → при загрузке ЛЮБОГО PNG сразу `errors.resizeSize`
|
- [x] Registry-wide smoke покрывает все 122 инструмента по input/result-типу —
|
||||||
(alert), результата нет. `registry-new/geometry.ts`. Ожидание: осмысленный
|
`web/e2e/tools-smoke.spec.ts`, `generators.spec.ts`,
|
||||||
дефолт-размер (например, исходный) либо кнопка, а не авто-ошибка.
|
`text-and-verdicts.spec.ts`.
|
||||||
3. **crop-png с дефолтами падает** — аналогично: `width: 0, height: 0` → сразу
|
- [x] Layout-группировка вынесена в `resolveLayoutGroups` и покрыта
|
||||||
`errors.cropSize`. `registry-new/geometry.ts`. Ожидание: базовый кроп по
|
unit-тестами; `SchemaFields.svelte` использует общий seam.
|
||||||
умолчанию либо внятное предзаполнение.
|
- [x] `SchemaPreview` view-model вынесена в `buildSchemaPreviewModel` и покрыта
|
||||||
4. **Сырые ключи ошибок в UI** — alert на tool-странице показывает сырой ключ
|
unit-тестами для всех input/result-типов.
|
||||||
i18n (`errors.resizeSize`), а не сообщение из `en.ts` / `ru.ts` (там
|
- [x] Browser matrix: полный Chromium, Firefox/WebKit для browser-critical
|
||||||
`resizeSize: "Width and/or height must be positive"`). Проверяется на
|
спецификаций и mobile Chromium 390×844 — `web/playwright.config.ts`.
|
||||||
resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке.
|
- [x] Расхождение декодеров зафиксировано как ожидаемое поведение: Firefox и
|
||||||
Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»).
|
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), а
|
**Download**: юзер скачивает результат в нужном формате (mime/ext/quality), а
|
||||||
pipeline при этом не усложняется лишним звеном. **Сделано (2026-09):**
|
pipeline при этом не усложняется лишним звеном. **Сделано (2026-09):**
|
||||||
механическая часть реализована в preview — `ToolEntry.output`
|
механическая часть реализована в 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 уже настроены;
|
`SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены;
|
||||||
`png-to-bmp` переведён сюда же). **Осталось (UX-райз «Download»):**
|
`png-to-bmp` переведён сюда же). Q6c покрывает этот фиксированный контракт
|
||||||
|
unit/E2E-проверками; переключатель форматов и quality остаётся отдельным
|
||||||
|
UX-райзом. **Осталось (UX-райз «Download»):**
|
||||||
- выбор формата/качества прямо в кнопке/диалоге Download для любого
|
- выбор формата/качества прямо в кнопке/диалоге Download для любого
|
||||||
результата (не отдельными конвертерами);
|
результата (не отдельными конвертерами);
|
||||||
- экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png`
|
- экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png`
|
||||||
@@ -207,39 +216,40 @@
|
|||||||
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
|
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
|
||||||
`archive/plan-gap-waves.md`.
|
`archive/plan-gap-waves.md`.
|
||||||
|
|
||||||
13. **Контраст текста на акцентных кнопках — пересмотреть после готовности
|
13. **Контраст текста на акцентных кнопках — частично закрыт**. Для dark-темы
|
||||||
сайта**. Текст `--color-background` на фоне `--color-main`/`--color-accent`
|
`--color-main`/`--color-accent` подняты до tone 56, чтобы пройти текущий
|
||||||
(тон 48) даёт контраст ~4.1–4.3 (light) — ниже AA 4.5 для обычного текста;
|
порог `palette.test.ts` 4.0; targeted-тест проходит. Light-тема сохраняет
|
||||||
`palette.test.ts` держит для кнопок порог 4.0 (AA large-text) осознанно, как
|
контраст ~4.1–4.3, что ниже AA 4.5 для обычного текста, поэтому после
|
||||||
компромисс текущего дизайна. После полного завершения редизайна пересмотреть
|
завершения редизайна нужно повторно поднять тона и вернуть порог 4.5. Также
|
||||||
тона (например поднять tone кнопок до ~55) и вернуть порог 4.5. Также
|
|
||||||
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
|
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
|
||||||
light-danger тон 48 = 4.25 на границе.
|
light-danger tone 48 = 4.25 на границе.
|
||||||
|
|
||||||
14. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
|
14. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
|
||||||
реализовать процесс показа маски результата. **Контекст:** в старом
|
реализовать процесс показа маски результата. **Контекст:** в старом
|
||||||
контракте поле `preview` у `remove-color`/`remove-background` показывало
|
контракте поле `preview` у `remove-color`/`remove-background` показывало
|
||||||
быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что
|
быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что
|
||||||
будет вырезано. При миграции на единый `run(ctx)` поле убрано (шаг 8,
|
будет вырезано. При миграции на единый `run(ctx)` поле убрано (шаг 8,
|
||||||
`registry-new/alpha.ts`) — новый executor и schema-превью зовут `run()`
|
`web/src/lib/registry/alpha.ts`) — новый executor и schema-превью зовут
|
||||||
напрямую, маска пока не нужна. **Идея 1:** дать каждому `run` возможность
|
`run()` напрямую, маска пока не нужна. **Идея 1:** дать каждому `run`
|
||||||
вернуть (или инструмент — объявить) `runMask` — ч/б изображение маски
|
возможность вернуть (или инструмент — объявить) `runMask` — ч/б изображение
|
||||||
изменений либо иной подход (отдельное поле `mask` + функция маски, второй
|
маски изменений либо иной подход (отдельное поле `mask` + функция маски,
|
||||||
результат, overlay). Оценка — на усмотрение при проработке (S/M). Пока живём
|
второй результат, overlay). Оценка — на усмотрение при проработке (S/M).
|
||||||
без неё. **Идея 2:** каждый `run` возвращает два изображения - для
|
Пока живём без неё. **Идея 2:** каждый `run` возвращает два изображения -
|
||||||
отображения (показывает маску или сам результат), и для передачи в следующий
|
для отображения (показывает маску или сам результат), и для передачи в
|
||||||
инструмент (всегда результат. Оценка — на усмотрение при проработке (S/M).
|
следующий инструмент (всегда результат. Оценка — на усмотрение при
|
||||||
Пока живём без неё.
|
проработке (S/M). Пока живём без неё.
|
||||||
|
|
||||||
15. **Процесс работы с инструментами 1 → many и many → 1** — абстракция
|
15. **Процесс работы с инструментами 1 → many и many → 1** — абстракция
|
||||||
результата «набор файлов» оформлена и работает: тип `FileResult`,
|
результата «набор файлов» оформлена и работает: тип `FileResult`,
|
||||||
`result: "files"` в реестре, сериализация через executor, скачивание
|
`result: "files"` в реестре, сериализация через executor, скачивание
|
||||||
zip-архивом, грид-превью частей. Реализовано на первом потребителе
|
zip-архивом, грид-превью частей. Реализовано на первом потребителе
|
||||||
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
|
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
|
||||||
**1 → many:** подключение остальных потребителей (gif-to-frames,
|
Q6b закрывает контракт ZIP/download: unit-тесты `web/src/lib/zip.test.ts` и
|
||||||
separate-colors) при необходимости; формат/качество файлов внутри zip —
|
browser-сценарий `web/e2e/pipeline.spec.ts` проверяют имена частей и
|
||||||
отложено до UX-райза «Download» (п.7). **many → 1** (вход = архив/несколько
|
содержимое архива. **1 → many:** подключение остальных потребителей
|
||||||
картинок → единый результат): не реализовано. Зависит от batch-обработки
|
(gif-to-frames, separate-colors) при необходимости; формат/качество файлов
|
||||||
архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна отдельная
|
внутри zip — отложено до UX-райза «Download» (п.7). **many → 1** (вход =
|
||||||
проработка входа (распаковка zip в браузере, порядок файлов, поведение в
|
архив/несколько картинок → единый результат): не реализовано. Зависит от
|
||||||
пайплайне — такие инструменты терминальны, как и 1 → many).
|
batch-обработки архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна
|
||||||
|
отдельная проработка входа (распаковка zip в браузере, порядок файлов,
|
||||||
|
поведение в пайплайне — такие инструменты терминальны, как и 1 → many).
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Чек-лист ручного тестирования preview
|
# Чек-лист ручного тестирования production UI
|
||||||
|
|
||||||
> Вспомогательный документ к `docs/archive/plan-testing-preview.md` и e2e-тестам
|
> Вспомогательный документ к e2e-тестам (`web/e2e/`, `pnpm --dir web test:e2e`)
|
||||||
> (`web/e2e/`, `pnpm --dir web test:e2e`). Автоматизированная часть закрывает
|
> и стратегии `docs/testing-strategy.md`. Автоматизированная часть закрывает
|
||||||
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
|
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
|
||||||
>
|
>
|
||||||
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
|
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
|
||||||
> → `node scripts/serve-static.mjs`).
|
> → `node scripts/serve-static.mjs`).
|
||||||
>
|
>
|
||||||
> Критерий приёмки большинства пунктов: **нет зависит, нет «залипания»,
|
> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания»,
|
||||||
> сообщения об ошибках человекочитаемые**.
|
> сообщения об ошибках человекочитаемые**.
|
||||||
|
|
||||||
## A. Краевые PNG-файлы
|
## A. Краевые PNG-файлы
|
||||||
@@ -19,12 +19,13 @@
|
|||||||
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
|
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
|
||||||
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
|
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
|
||||||
remove-background, feather-edges, clean-edges дают корректные края.
|
remove-background, feather-edges, clean-edges дают корректные края.
|
||||||
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется.
|
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется
|
||||||
|
(авто-покрытие: `web/e2e/png-fixtures.spec.ts`).
|
||||||
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
||||||
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
||||||
pipeline.spec.ts).
|
`pipeline.spec.ts` и `png-fixtures.spec.ts`).
|
||||||
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
|
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
|
||||||
размер, прозрачность).
|
прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`.
|
||||||
|
|
||||||
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
||||||
|
|
||||||
@@ -59,8 +60,7 @@
|
|||||||
красного alert-текста в виде `errors.*`.
|
красного alert-текста в виде `errors.*`.
|
||||||
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
|
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
|
||||||
именные цвета) — silent repair или понятная ошибка.
|
именные цвета) — silent repair или понятная ошибка.
|
||||||
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет
|
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет.
|
||||||
(кейс из плана п.14).
|
|
||||||
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
||||||
|
|
||||||
## C. Буфер обмена и drag-n-drop
|
## C. Буфер обмена и drag-n-drop
|
||||||
@@ -96,6 +96,38 @@
|
|||||||
корректный (не «кракозябры»).
|
корректный (не «кракозябры»).
|
||||||
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
- [ ] 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К) в двух
|
- Полный прогон: ~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 — обязательные фиксы (баги + дефолты)
|
## 3. Волна C — обязательные фиксы (баги + дефолты)
|
||||||
|
|
||||||
### C1. resize-png: дефолт 0×0 → осмысленный
|
### C1. Source-aware defaults для dimension-полей
|
||||||
|
|
||||||
`geometry.ts`: `width: 0, height: 0` → `width: 512, height: 512` (или
|
Q5 добавил общий `defaultFromSource` resolver в `registry-schema`. Для
|
||||||
`width: imgWidth, height: imgHeight` — но дефолт в схеме не может знать размер
|
`resize-png` и `crop-png` дефолт после загрузки равен размерам текущего source;
|
||||||
изображения). **Решение:** дефолт `512×512`.
|
до загрузки используется положительный fallback `1×1`. Один-sided `0` у resize
|
||||||
|
сохраняет режим auto.
|
||||||
|
|
||||||
### C2. symmetric-copy-png: "keep side" не работает
|
### 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` ∈
|
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
|
||||||
{top, bottom} → заменить на `left`.
|
{top, bottom} → заменить на `left`.
|
||||||
|
|
||||||
### C3. Механизм проброса размеров картинки в схему
|
### C3. Source-aware bounds (отдельный следующий шаг)
|
||||||
|
|
||||||
**Проблема.** Поля, значения которых зависят от размеров исходника (crop x/y,
|
Q5 реализовал только defaults dimension-полей через `SchemaContext` и
|
||||||
resize, offsets), объявлены в статической схеме (`-100000…100000`), а реальная
|
`defaultFromSource`. Динамические min/max для crop x/y, shift и resize остаются
|
||||||
картинка обычно 800–4000px. Диапазон надо считать от фактического размера
|
отдельной задачей: bounds не следует смешивать с default resolver.
|
||||||
изображения, а не от потолков.
|
|
||||||
|
|
||||||
**Что нужно спроектировать и завести:**
|
**Что нужно спроектировать и завести:**
|
||||||
|
|
||||||
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
||||||
→ `PixelImage` (width/height). Сейчас он никуда не пробрасывается — надо
|
→ `PixelImage` (width/height); для bounds понадобится передать этот context в
|
||||||
прокинуть в `SchemaFields` и дальше в контролы, например через context или
|
`SchemaFields` и контролы.
|
||||||
проп `sourceSize`.
|
|
||||||
|
|
||||||
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
|
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
|
||||||
источника вместо жёстких чисел. Кандидат:
|
источника вместо жёстких чисел. Кандидат:
|
||||||
@@ -228,15 +227,13 @@ resize, offsets), объявлены в статической схеме (`-100
|
|||||||
|
|
||||||
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
|
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
|
||||||
размера источника; при смене картинки диапазон пересчитывается, текущее
|
размера источника; при смене картинки диапазон пересчитывается, текущее
|
||||||
значение пере-клампится. Дефолт и `sanitize` остаются на статической основе
|
значение пере-клампится.
|
||||||
(или тоже учат bound) — решить при проектировании.
|
|
||||||
|
|
||||||
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
|
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
|
||||||
(offsetX/offsetY), resize (max → maxSide).
|
(offsetX/offsetY), resize (max → maxSide).
|
||||||
|
|
||||||
Это отдельный шаг плана: **завести механизм**, спроектировать на ревью интерфейс
|
Это отдельный refactor после Q6/Q7; source-aware defaults из Q5 не заменяют
|
||||||
спека (`bound` vs `range: (size) => [min, max]`), затем применить к crop и
|
bounds.
|
||||||
остальным полям размерности. Пока механизма нет — поля crop остаются как есть.
|
|
||||||
|
|
||||||
### C4. verify-is-png: неверное название
|
### C4. verify-is-png: неверное название
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
||||||
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
||||||
|
|
||||||
## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер)
|
## 2. Фаза 10 — PWA (после S1)
|
||||||
|
|
||||||
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
||||||
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
||||||
@@ -64,8 +64,7 @@
|
|||||||
## 3. Порядок и связи
|
## 3. Порядок и связи
|
||||||
|
|
||||||
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
|
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
|
||||||
- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19
|
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
|
||||||
отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7.
|
|
||||||
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
||||||
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
||||||
GEO-текстов (`llms.txt`, описания категорий).
|
GEO-текстов (`llms.txt`, описания категорий).
|
||||||
|
|||||||
+50
-115
@@ -1,43 +1,26 @@
|
|||||||
# План: переезд на новый дизайн (refs) — параллельная сборка
|
# План: переезд на новый дизайн (refs) — параллельная сборка
|
||||||
|
|
||||||
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**.
|
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**, Q3
|
||||||
> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21
|
> (удаление старого UI) **выполнен**. Остался C20 (шаг 6, проверка нового
|
||||||
> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не
|
> дизайна) — **следующий этап**. C21 (удалить refs) остаётся отдельной уборкой.
|
||||||
> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт,
|
> Общий tech-debt baseline и следующие quality-задачи зафиксированы в
|
||||||
> аудит против рефа прекращён. Tech debt закрыт
|
> `docs/plan-codebase-quality.md`. Покупка домена НЕ была блокером C17: домен
|
||||||
> (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд старых файлов в
|
> нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
||||||
> `old/`) — **выполнено**. Покупка домена НЕ была блокером C17: домен нужен для
|
|
||||||
> S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
|
||||||
>
|
>
|
||||||
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
||||||
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
||||||
>
|
>
|
||||||
> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка
|
> **Историческая стратегия перехода:** сначала новый UI собирался отдельно,
|
||||||
> общих компонентов на месте с алиасами токенов) — **параллельная сборка**:
|
> затем стал production, после чего legacy-ветка была удалена Q3. Текущие
|
||||||
> новый дизайн строится отдельно, изолируется от старого, проверяется целиком,
|
> критерии относятся только к production UI; старые решения и детали перехода
|
||||||
> затем становится основным и старый удаляется. Старое приложение остаётся
|
> остаются в архивных планах.
|
||||||
> рабочим и шippable на всём протяжении перехода.
|
|
||||||
|
|
||||||
## 0. Почему меняем стратегию
|
## 0. Почему меняем стратегию
|
||||||
|
|
||||||
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые токены
|
- Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить
|
||||||
как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие компоненты
|
независимо; теперь production использует только новые маршруты и `app.css`.
|
||||||
редактируются вживую → регресс старого UI во время работы, новый дизайн нельзя
|
- Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout
|
||||||
принять изолированно.
|
для удалённого UI-v1 больше не нужны.
|
||||||
- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые страницы
|
|
||||||
рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем его основным,
|
|
||||||
(5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги.
|
|
||||||
- **Главный технический риск и его решение.** Сейчас `web/src/app.css`
|
|
||||||
импортируется в **корневом** `web/src/routes/+layout.svelte:2` и потому
|
|
||||||
грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать новый
|
|
||||||
CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без
|
|
||||||
наследования**: (а) убрать `import '../app.css'` из корневого layout; (б)
|
|
||||||
старые маршруты обернуть группой `(old)/` с собственным layout, импортирующим
|
|
||||||
`app.css`; (в) новые маршруты (`preview/*`) — собственный layout,
|
|
||||||
импортирующий `design2.css`. SvelteKit делает code-splitting CSS по маршрутам,
|
|
||||||
а стили компонентов скоупятся сами — поэтому глобальные стили каждого дизайна
|
|
||||||
живут только в своей ветке, и префикс-обёртка `.ds2` не нужна. Старые `ui/` и
|
|
||||||
страницы не трогаются до фазы 5.
|
|
||||||
|
|
||||||
## 1. Источники и их оценка
|
## 1. Источники и их оценка
|
||||||
|
|
||||||
@@ -188,68 +171,29 @@
|
|||||||
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
|
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
|
||||||
> пригодится для сброса родительского layout.
|
> пригодится для сброса родительского layout.
|
||||||
|
|
||||||
## 6. Маршрутизация параллельных страниц
|
## 6. Маршрутизация: историческая схема перехода
|
||||||
|
|
||||||
Новые страницы живут в **отдельной ветке `preview/*`**, не трогая старые:
|
Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были
|
||||||
|
перенесены на production-маршруты. Эта схема нужна только для восстановления
|
||||||
|
контекста C17; текущая структура описана в разделе 7, отдельных preview- или
|
||||||
|
legacy-layouts в production больше нет.
|
||||||
|
|
||||||
```txt
|
## 7. Фазы выполнения
|
||||||
src/routes/+layout.svelte # нейтральный, БЕЗ app.css
|
|
||||||
src/routes/(old)/+layout.svelte # импорт app.css + старая шапка/подвал
|
|
||||||
src/routes/(old)/+page.svelte # / (старое)
|
|
||||||
src/routes/(old)/demo/+page.svelte # /demo
|
|
||||||
src/routes/(old)/list-tools/...
|
|
||||||
src/routes/(old)/tools/[id]/...
|
|
||||||
src/routes/preview/+layout.svelte # импорт design2.css, <AppShell>
|
|
||||||
src/routes/preview/+page.svelte # /preview — воркспейс (index/demo)
|
|
||||||
src/routes/preview/demo/+page.svelte # /preview/demo
|
|
||||||
src/routes/preview/list-tools/+page.svelte
|
|
||||||
src/routes/preview/tools/[id]/+page.svelte
|
|
||||||
```
|
|
||||||
|
|
||||||
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы не
|
- **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и
|
||||||
изменяются (только перемещены в `(old)/`).
|
интерактивные состояния приняты; детали перехода остаются в архивном
|
||||||
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты (move-файлы),
|
`plan-design-fix.md`.
|
||||||
группа `(old)/` удаляется, `design2.css` подключается в корневом
|
- **C17/C18 — production-переход.** Новые страницы перенесены на реальные
|
||||||
`+layout.svelte` (как новый основной слой), временный флаг/кнопка убираются.
|
корневые маршруты; `app.css` стал единственным production-стилем.
|
||||||
|
- **Q3/C19 — cleanup.** Удалены UI-v1, legacy CSS, v1-тесты, fixtures и
|
||||||
|
isolation-конфигурация; production build и verify проверяются отдельно.
|
||||||
|
- **C20 — следующий этап.** Финальный проход по брейкпоинтам и визуальной
|
||||||
|
приёмке production UI.
|
||||||
|
- **C21 — отдельная уборка.** Удалить `refs/` и `refs-html/`, если они больше не
|
||||||
|
нужны.
|
||||||
|
|
||||||
## 7. Фазы выполнения (1–5 по запросу)
|
Критерии каждого этапа — зелёные `test`/`check`/`build`, отсутствие остаточных
|
||||||
|
legacy-путей в production-коде и ревью в браузере для UI-изменений.
|
||||||
Каждая фаза заканчивается зелёными `test`/`check`/`build` и ревью в браузере
|
|
||||||
(обе темы) против `refs-html/`.
|
|
||||||
|
|
||||||
- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все примитивы
|
|
||||||
§3 и контролы §4 как отдельные компоненты. Критерий: демо-страница kit'а
|
|
||||||
(`/preview` или временная) показывает все состояния (light/dark,
|
|
||||||
hover/disabled, все варианты Button/Segmented/Badge); unit-тесты форм не
|
|
||||||
падают; **старые страницы визуально не изменились** (изоляция работает).
|
|
||||||
|
|
||||||
- **Фаза 2 — Новые страницы.** Под `preview/*` (§6): воркспейс, demo,
|
|
||||||
list-tools, tool-page (двухпанельная раскладка из рефа: settings-panel +
|
|
||||||
preview-panel, step-cards для chain). Критерий: разметка соответствует
|
|
||||||
`refs-html/index.html`, `gradient.html`, `background-remover.html`,
|
|
||||||
`list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает в
|
|
||||||
новом стиле; старые маршруты продолжают работать как раньше.
|
|
||||||
|
|
||||||
- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран, 800px),
|
|
||||||
скриншоты против `refs-html/`, прогон старого тест-сьюта для доказательства
|
|
||||||
отсутствия регресса, RU/EN-локализация новых лейблов. Критерий: визуальное
|
|
||||||
совпадение с рефом, зелёные тесты, записанные скриншоты приняты.
|
|
||||||
|
|
||||||
- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*` → реальные
|
|
||||||
маршруты; `design2.css` подключается в корневом `+layout.svelte`; удаление
|
|
||||||
preview-группы и временного флага/кнопки. Старые страницы теперь не
|
|
||||||
используются (мёртвый код), но физически ещё в репо. Критерий: реальные
|
|
||||||
маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений.
|
|
||||||
|
|
||||||
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`,
|
|
||||||
старого `app.css` (и его токенов), неиспользуемых классов; наведение порядка в
|
|
||||||
`components/` (имя каталога и подпапок — решается на фазе 1; к C17 `kit/` уже
|
|
||||||
разложен в `components/`). Финальный проход по брейкпоинтам 1200/1100/800/480.
|
|
||||||
Критерий: `grep` по старым токенам/классам пуст, build чистый, старый дизайн
|
|
||||||
нигде не упоминается.
|
|
||||||
|
|
||||||
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня),
|
|
||||||
Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня).
|
|
||||||
|
|
||||||
## 8. Смоук (после фазы 3, контроль на фазе 4)
|
## 8. Смоук (после фазы 3, контроль на фазе 4)
|
||||||
|
|
||||||
@@ -267,14 +211,10 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
## 9. Открытые вопросы
|
## 9. Открытые вопросы
|
||||||
|
|
||||||
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
|
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
|
||||||
разложил его в `components/` (подпапки fields/layout/ui). На C19 — только
|
разложил его в `components/` (подпапки fields/layout/ui). На C21 — только
|
||||||
навести порядок внутри `components/`, переименование не требуется.
|
навести порядок внутри `components/`, переименование не требуется.
|
||||||
- **Изоляция через layout:** `app.css` вынесен из корневого layout в
|
- **Единый production CSS:** `app.css` подключается в корневом layout; отдельных
|
||||||
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
|
legacy-layouts и переключателей версий больше нет.
|
||||||
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
|
|
||||||
(DevTools → вкладка Styles, нет правил из `app.css`).
|
|
||||||
- **Переключатель для проверки:** рекомендую `localStorage`-флаг + кнопка
|
|
||||||
«Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
|
|
||||||
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
|
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
|
||||||
ключи, но допустить часть английских брендовых («приборная панель»).
|
ключи, но допустить часть английских брендовых («приборная панель»).
|
||||||
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
|
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
|
||||||
@@ -282,8 +222,8 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
|
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
|
||||||
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
|
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
|
||||||
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
|
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
|
||||||
не нужен и не создаётся. `refs/` и `refs-html/` можно удалить из репо на фазе
|
не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка;
|
||||||
5 (по желанию), они не являются частью рантайма.
|
их можно удалить в C21, они не являются частью рантайма.
|
||||||
|
|
||||||
## 10. План исполнения (мелкие коммиты)
|
## 10. План исполнения (мелкие коммиты)
|
||||||
|
|
||||||
@@ -361,10 +301,10 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
|
|
||||||
### Шаг 5. Сделать новый дизайн основным
|
### Шаг 5. Сделать новый дизайн основным
|
||||||
|
|
||||||
> C17 — **выполнен** (`docs/archive/plan-c17.md`): старый `(old)/` → `/v1/`,
|
> C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты
|
||||||
> preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки
|
> перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов
|
||||||
> импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались:
|
> после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и
|
||||||
> C19 (удаление старого, раскрытие `recommended` на весь код), C20, C21.
|
> код. Остались C20 и C21.
|
||||||
|
|
||||||
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
||||||
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
|
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
|
||||||
@@ -386,26 +326,21 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
|
|
||||||
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
||||||
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
|
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
|
||||||
отсутствия регресса: старый `v1/` работает и не затронут.
|
отсутствия регресса: production-маршруты и основные пользовательские
|
||||||
|
потоки работают.
|
||||||
|
|
||||||
### Шаг 7. Опциональная чистка (отложен, нет срока)
|
### Шаг 7. Cleanup
|
||||||
|
|
||||||
|
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
||||||
|
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
|
||||||
|
из конфигурации и активной документации.
|
||||||
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
|
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
|
||||||
правды.
|
правды.
|
||||||
- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
|
||||||
`app_v1.css` (и его токены), неиспользуемые классы. **И раскрыть ESLint
|
|
||||||
`recommended` на весь код** — убрать scoped-блок `newCode` в
|
|
||||||
`web/eslint.config.js`, прогнать `lint` и починить всплывшие ошибки.
|
|
||||||
_Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) —
|
|
||||||
это не «старый дизайн», у них свой линт-долг; расширение `recommended` на
|
|
||||||
них может выдать много ошибок. Решить на C19: либо чиним сразу, либо
|
|
||||||
расширяем scoped только на `src/routes/**` + `src/lib/components/**`._
|
|
||||||
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
|
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
|
||||||
каталога).
|
каталога).
|
||||||
|
|
||||||
> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской
|
> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
|
||||||
> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит
|
> После удаления проверяются только production-маршруты и общие модули.
|
||||||
> только когда нет горящих задач и хочется навести порядок.
|
|
||||||
>
|
>
|
||||||
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
||||||
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
||||||
|
|||||||
+25
-26
@@ -50,11 +50,10 @@
|
|||||||
клиенте (localStorage) — для SEO не учитываем.
|
клиенте (localStorage) — для SEO не учитываем.
|
||||||
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`) ломает
|
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`) ломает
|
||||||
абсолютные URL, canonical и «корневость» страниц. Свой домен + корень.
|
абсолютные URL, canonical и «корневость» страниц. Свой домен + корень.
|
||||||
- **URL-схема инструментов:** короткие плоские адреса. После C17 вынести
|
- **URL-схема инструментов:** короткие плоские адреса. После C17 страницы
|
||||||
страницы инструментов в корень: `/crop-png` (как у конкурента), каталог —
|
инструментов находятся в корне на `/tools/[id]`, каталог — на `/list-tools`;
|
||||||
`/tools`. Переезд `/preview/tools/[id]` → `/[id]` сделать **одновременно с
|
текущую схему не менять без отдельного SEO-плана: после индексации
|
||||||
C17** (в одном атомарном коммите): после индексации переименование URL = 301 и
|
переименование URL означает 301 и потерю позиций.
|
||||||
потеря позиций.
|
|
||||||
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или
|
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или
|
||||||
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя
|
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя
|
||||||
подрывать собственными скриптами.
|
подрывать собственными скриптами.
|
||||||
@@ -68,10 +67,10 @@
|
|||||||
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
|
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
|
||||||
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
|
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
|
||||||
JS. Это главный технический козырь.
|
JS. Это главный технический козырь.
|
||||||
- 121 инструмент в `registry-new` с «SEO-шными» id (`crop-png`,
|
- 122 инструмента в `web/src/lib/registry` с «SEO-шными» id (`crop-png`,
|
||||||
`add-border-png`, …) — URL уже повторяют паттерн конкурента.
|
`add-border-png`, …) — URL уже повторяют паттерн конкурента.
|
||||||
- Уникальные `<title>` + `<meta description>` на страницах инструментов старой
|
- На корневых страницах инструментов есть базовые `<title>` и `robots`; полный
|
||||||
ветки; механика переносится на новую при C17.
|
SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2.
|
||||||
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
|
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
|
||||||
инструментов против конкурента: `docs/tools-map.md` (у них 311, из них 107 —
|
инструментов против конкурента: `docs/tools-map.md` (у них 311, из них 107 —
|
||||||
нишевые клоны-серии Logo*/Icon*/Stamp* поверх обычных операций).
|
нишевые клоны-серии Logo*/Icon*/Stamp* поверх обычных операций).
|
||||||
@@ -80,12 +79,12 @@
|
|||||||
Чего нет (пробелы закрываются этапами ниже):
|
Чего нет (пробелы закрываются этапами ниже):
|
||||||
|
|
||||||
| Пробел | Где сейчас | Этап |
|
| Пробел | Где сейчас | Этап |
|
||||||
| ----------------------------------------------------- | ----------------------------------- | ------ |
|
| ----------------------------------------------------- | ---------------------------------------- | ---- |
|
||||||
| `sitemap.xml` | нет | S1 |
|
| `sitemap.xml` | нет | S1 |
|
||||||
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
|
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
|
||||||
| `rel=canonical` | нет | S1 |
|
| `rel=canonical` | нет | S1 |
|
||||||
| OG/Twitter-теги, OG-картинки | нет | 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 |
|
| Контент страницы (H1-текст, how-to, FAQ, related) | только title/description | S2 |
|
||||||
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
|
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
|
||||||
| `llms.txt`, хабы категорий с текстом | нет | S3 |
|
| `llms.txt`, хабы категорий с текстом | нет | S3 |
|
||||||
@@ -111,7 +110,7 @@
|
|||||||
## 3. Стратегия
|
## 3. Стратегия
|
||||||
|
|
||||||
1. **Не лобовая, а ядро интентов.** 80% объёма поиска приносят ~30–50 запросов.
|
1. **Не лобовая, а ядро интентов.** 80% объёма поиска приносят ~30–50 запросов.
|
||||||
Покрытие инструментов у нас почти полное (121); приоритет — контент и
|
Покрытие инструментов у нас почти полное (122); приоритет — контент и
|
||||||
качество страниц ядра, затем хвосты.
|
качество страниц ядра, затем хвосты.
|
||||||
2. **Страница = лендинг, а не «пустая коробка».** Шаблон контента: H1 → 1–2
|
2. **Страница = лендинг, а не «пустая коробка».** Шаблон контента: H1 → 1–2
|
||||||
абзаца «что делает» → как использовать (шаги) → параметры → FAQ (3–5 вопросов
|
абзаца «что делает» → как использовать (шаги) → параметры → FAQ (3–5 вопросов
|
||||||
@@ -139,7 +138,7 @@
|
|||||||
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
|
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
|
||||||
**domain-required** (после покупки и DNS-настройки).
|
**domain-required** (после покупки и DNS-настройки).
|
||||||
|
|
||||||
#### S1-free: без домена (параллельно с C18–C19)
|
#### S1-free: без домена (параллельно с quality-планом)
|
||||||
|
|
||||||
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
|
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
|
||||||
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
|
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
|
||||||
@@ -150,7 +149,7 @@
|
|||||||
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть
|
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть
|
||||||
(backlog №7, райз «выбор формата/качества»). Продуктовая основа «image
|
(backlog №7, райз «выбор формата/качества»). Продуктовая основа «image
|
||||||
tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
|
tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
|
||||||
параллельно с C18–C19.
|
параллельно с текущими quality-задачами.
|
||||||
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
|
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
|
||||||
формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в
|
формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в
|
||||||
png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`,
|
png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`,
|
||||||
@@ -163,12 +162,12 @@
|
|||||||
(`tools/[id]/+page.svelte`) — делается одновременно с S1d (canonical)
|
(`tools/[id]/+page.svelte`) — делается одновременно с S1d (canonical)
|
||||||
чтобы Google сразу проиндексировал с правильным доменом.
|
чтобы Google сразу проиндексировал с правильным доменом.
|
||||||
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
|
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
|
||||||
`build/` из `TOOLS` реестра (`registry-new`) + статических страниц. Вызов
|
`build/` из `TOOLS` реестра (`web/src/lib/registry`) + статических
|
||||||
из `pnpm build` после `vite build` (adapter-static пишет в `build/`,
|
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в
|
||||||
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
|
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`),
|
||||||
опционального `BASE_PATH`.
|
учёт опционального `BASE_PATH`.
|
||||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до
|
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
|
||||||
C19; после — вернуть минимальный allow-all).
|
cleanup — вернуть минимальный allow-all.
|
||||||
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
||||||
актуальному маршруту), OG-теги (title/description/type/url/image),
|
актуальному маршруту), OG-теги (title/description/type/url/image),
|
||||||
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
||||||
@@ -178,12 +177,12 @@
|
|||||||
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
|
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
|
||||||
> canonical/OG присутствуют на всех страницах; e2e зелёные.
|
> canonical/OG присутствуют на всех страницах; e2e зелёные.
|
||||||
|
|
||||||
### S2. Контентный слой (параллельно C18–C19, волнами по категориям)
|
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
|
||||||
|
|
||||||
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
||||||
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
||||||
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
|
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
|
||||||
вручную пачками по категориям (самый трудоёмкий этап: ~121 × 2 локали; RU
|
вручную пачками по категориям (самый трудоёмкий этап: ~122 × 2 локали; RU
|
||||||
— отложить до решения о `/ru/`).
|
— отложить до решения о `/ru/`).
|
||||||
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
|
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
|
||||||
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
|
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
|
||||||
@@ -239,7 +238,7 @@
|
|||||||
|
|
||||||
## 5. Приоритизация инструментов (первая волна контента)
|
## 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]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
||||||
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
||||||
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
||||||
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2
|
- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
|
||||||
(домен) — блокер только для S1-domain (canonical + sitemap + снятие
|
блокируют). R2 (домен) — блокер только для S1-domain (canonical + sitemap +
|
||||||
`noindex`). После индексации переименование = переезд с 301, потеря позиций и
|
снятие `noindex`). После индексации переименование = переезд с 301, потеря
|
||||||
ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17.
|
позиций и ссылок — не делать.
|
||||||
|
|
||||||
## 7. Что осознанно НЕ делаем
|
## 7. Что осознанно НЕ делаем
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# План разработки: easy-png-tools
|
||||||
|
|
||||||
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
||||||
> каталог переведён в типизированный `registry` (121/125), новый дизайн по
|
> каталог переведён в типизированный `registry` (122 текущих инструмента), новый
|
||||||
> `plan-redesign.md` стал основным на корневых маршрутах (C17/C18 выполнены),
|
> дизайн по `plan-redesign.md` стал единственным production UI на корневых
|
||||||
> старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19 в бэклоге).
|
> маршрутах; UI-v1 удалён в Q3. Tech debt не закрыт полностью; текущий baseline
|
||||||
> Tech debt закрыт. Следующий этап: C20 (проверка нового дизайна,
|
> и следующие quality-задачи зафиксированы в `docs/plan-codebase-quality.md`.
|
||||||
> `plan-redesign.md`, §10) + SEO S1-free (`plan-seo.md`) + баги. Домен нужен для
|
> Следующий этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO
|
||||||
> SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
|
> S1-free (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`,
|
||||||
|
> S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
|
||||||
> `docs/plan-platform.md`.
|
> `docs/plan-platform.md`.
|
||||||
|
|
||||||
## 0. Решения
|
## 0. Решения
|
||||||
@@ -161,8 +162,8 @@ wasm.
|
|||||||
### Фаза 10 — PWA — см. `docs/plan-platform.md`
|
### Фаза 10 — PWA — см. `docs/plan-platform.md`
|
||||||
|
|
||||||
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
|
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
|
||||||
устройство»), установка как приложение. Wasm-фаз не требует, идёт после C17/C19
|
устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из
|
||||||
и S1 из `plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
|
`plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
|
||||||
|
|
||||||
## 3. Правила сравнения (для фаз 4–7)
|
## 3. Правила сравнения (для фаз 4–7)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- resize-png — width/height (размеры source по умолчанию; 0=авто для одной
|
||||||
- crop-png — x, y, width, height
|
стороны), keepAspect
|
||||||
|
- crop-png — x, y, width/height (полная область source по умолчанию)
|
||||||
- rotate-png — angle (90/180/270)
|
- rotate-png — angle (90/180/270)
|
||||||
- flip-png — axis (h/v)
|
- flip-png — axis (h/v)
|
||||||
- skew-png — degX, degY
|
- skew-png — degX, degY
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "pnpm --dir web test",
|
"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",
|
"dev": "pnpm --dir web dev",
|
||||||
"build": "pnpm --dir web build",
|
"build": "pnpm --dir web build",
|
||||||
"format": "pnpm format:docs && pnpm --dir web format",
|
"format": "pnpm format:docs && pnpm --dir web format",
|
||||||
|
|||||||
+28
-33
@@ -1,55 +1,50 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { expectNoErrors, trackErrors } from "./helpers/page";
|
import {
|
||||||
|
expectNoErrors,
|
||||||
|
toolLink,
|
||||||
|
trackErrors,
|
||||||
|
useEnglish,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
const TOTAL = 122;
|
test.beforeEach(async ({ page }) => {
|
||||||
const GROUPS = 8;
|
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);
|
const sink = trackErrors(page);
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
await expect(page.locator(".catalog-total b")).toHaveText(String(TOTAL));
|
const total = page.getByRole("status", { name: /tools available/i });
|
||||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
await expect(total).toBeVisible();
|
||||||
|
await expect(total).toHaveAttribute("aria-label", /^\d+ /);
|
||||||
|
await expect(page.getByRole("heading", { level: 2 }).first()).toBeVisible();
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("catalog search narrows results", async ({ page }) => {
|
test("catalog search narrows results", async ({ page }) => {
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
await expect(async () => {
|
|
||||||
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
||||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
await expect(toolLink(page, "resize-png")).toBeVisible();
|
||||||
}).toPass();
|
await expect(toolLink(page, "flip-png")).toBeHidden();
|
||||||
await expect(page.locator(".tool-card").first()).toContainText("Resize PNG");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("category filter shows only matching group and resets on ALL", async ({
|
test("category filter narrows the catalog and resets", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
const allButtons = page.locator(".tool-card");
|
const convert = page.getByRole("button", { name: /convert/i, exact: true });
|
||||||
const allCount = await allButtons.count();
|
const all = page.getByRole("button", { name: /^all$/i });
|
||||||
expect(allCount).toBe(TOTAL);
|
|
||||||
|
|
||||||
await expect(async () => {
|
await convert.click();
|
||||||
await page.getByRole("button", { name: "CONVERT" }).click();
|
await expect(convert).toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
await expect(toolLink(page, "convert-png-to-jpg")).toBeVisible();
|
||||||
}).toPass();
|
await expect(toolLink(page, "flip-png")).toBeHidden();
|
||||||
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 expect(async () => {
|
await all.click();
|
||||||
await page.getByRole("button", { name: "ALL" }).click();
|
await expect(all).toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
await expect(toolLink(page, "flip-png")).toBeVisible();
|
||||||
}).toPass();
|
|
||||||
await expect(page.locator(".tool-card")).toHaveCount(allCount);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace search matches a tool by title", async ({ page }) => {
|
test("workspace search matches a tool by title", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(async () => {
|
|
||||||
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
||||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
await expect(toolLink(page, "flip-png")).toBeVisible();
|
||||||
}).toPass();
|
await expect(toolLink(page, "resize-png")).toBeHidden();
|
||||||
await expect(page.locator(".tool-card").first()).toContainText("Flip PNG");
|
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-16
@@ -1,24 +1,62 @@
|
|||||||
import { expect, test } from "playwright/test";
|
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)", () => {
|
const GENERATOR_IDS = [
|
||||||
// FIXME: Генераторы (21/121) открываются, но в UI нет ни полей схемы, ни кнопки
|
"create-empty-png",
|
||||||
// «Generate» — только RU/EN/Reset. Пока баг открыт — fixme с ожидаемым
|
"random-noise-png",
|
||||||
// сценарием; после фикса убрать fixme.
|
"linear-gradient-png",
|
||||||
test.fixme("single-color-png renders Generate controls and produces a result", async ({
|
"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,
|
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);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "create-empty-png");
|
await openTool(page, "single-color-png");
|
||||||
await expect(page.locator("h1")).toContainText("Create");
|
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);
|
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;
|
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 {
|
function chunk(type: string, data: Buffer): Buffer {
|
||||||
const out = Buffer.alloc(8 + data.length + 4);
|
const out = Buffer.alloc(8 + data.length + 4);
|
||||||
out.writeUInt32BE(data.length, 0);
|
out.writeUInt32BE(data.length, 0);
|
||||||
@@ -32,18 +48,23 @@ function chunk(type: string, data: Buffer): Buffer {
|
|||||||
return out;
|
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). */
|
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
|
||||||
export function makePng(
|
export function makePng(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||||
): Buffer {
|
): 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 stride = width * 4 + 1;
|
||||||
const raw = Buffer.alloc(height * stride);
|
const raw = Buffer.alloc(height * stride);
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
@@ -57,13 +78,100 @@ export function makePng(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
sig,
|
PNG_SIGNATURE,
|
||||||
chunk("IHDR", ihdr),
|
chunk("IHDR", makeIhdr(width, height, 8, 6)),
|
||||||
chunk("IDAT", deflateSync(raw)),
|
chunk("IDAT", deflateSync(raw)),
|
||||||
chunk("IEND", Buffer.alloc(0)),
|
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] =>
|
const checker = (x: number, y: number): [number, number, number, number] =>
|
||||||
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
|
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"),
|
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 tinyBase64 = makePng(8, 8, solidRed).toString("base64");
|
||||||
|
|
||||||
export const svgMarkup =
|
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";
|
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 type ErrorSink = { errors: string[] };
|
||||||
|
|
||||||
export function trackErrors(page: Page): ErrorSink {
|
export function trackErrors(page: Page): ErrorSink {
|
||||||
@@ -12,39 +25,94 @@ export function trackErrors(page: Page): ErrorSink {
|
|||||||
return { errors };
|
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> {
|
export async function openTool(page: Page, id: string): Promise<void> {
|
||||||
|
await useEnglish(page);
|
||||||
await page.goto(`/tools/${id}`);
|
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> {
|
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,
|
name: file.name,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
buffer: file.buffer,
|
buffer: file.buffer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function metaValue(page: Page, caption: string): Promise<string> {
|
export async function downloadResultFile(page: Page): Promise<Download> {
|
||||||
const row = page.locator(".meta-row", { hasText: caption });
|
const button = page.getByRole("button", { name: "Download result" });
|
||||||
await expect(row).toBeVisible();
|
await expect(button).toBeVisible();
|
||||||
const value = await row.locator(".meta-value").textContent();
|
|
||||||
return value?.trim() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function suggestedDownloadName(
|
|
||||||
page: Page,
|
|
||||||
selector: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent("download"),
|
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> {
|
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> {
|
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
||||||
|
|||||||
+24
-21
@@ -1,13 +1,14 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { opaquePng } from "./helpers/fixtures";
|
import { pixelRow } from "./helpers/fixtures";
|
||||||
import { openTool, uploadImage } from "./helpers/page";
|
import { openTool, toolLink, useEnglish } from "./helpers/page";
|
||||||
|
|
||||||
test("поиск каталога находит по названию из другой локали", async ({
|
test("поиск каталога находит по названию из другой локали", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await useEnglish(page);
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
const input = page.locator(".catalog-search input");
|
const input = page.getByRole("textbox");
|
||||||
const flipCard = page.locator('a.tool-card[href*="flip-png"]');
|
const flipCard = toolLink(page, "flip-png");
|
||||||
await input.fill("отразить");
|
await input.fill("отразить");
|
||||||
await expect(flipCard).toContainText("Flip PNG");
|
await expect(flipCard).toContainText("Flip PNG");
|
||||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||||
@@ -22,27 +23,29 @@ test("RU/EN: переключение переводит заголовок ин
|
|||||||
await openTool(page, "resize-png");
|
await openTool(page, "resize-png");
|
||||||
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
|
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
|
||||||
const enBtn = page.getByRole("button", { name: "EN", 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 ruBtn.click();
|
||||||
await expect(page.locator(".schema-tool h1")).toHaveText(
|
await expect(heading).toHaveText("Изменить размер PNG");
|
||||||
"Изменить размер PNG",
|
|
||||||
);
|
|
||||||
}).toPass();
|
|
||||||
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
||||||
await enBtn.click();
|
await enBtn.click();
|
||||||
await expect(page.locator(".schema-tool h1")).toHaveText("Resize PNG");
|
await expect(heading).toHaveText("Resize PNG");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Валится на известном баге resize-png (дефолтные 0-параметры дают ошибку при
|
test("ошибка run'а локализуется и переводится при смене языка", async ({
|
||||||
// аплоаде, см. known-issues.spec.ts). Здесь он используется как детерминированная
|
page,
|
||||||
// ошибка: важно, что её текст локализован, а не сырой ключ.
|
}) => {
|
||||||
// TODO: найти другую ошибку, эта будет исправлена при фиксе resize-png
|
await openTool(page, "bytes-to-png");
|
||||||
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
|
await page
|
||||||
await openTool(page, "resize-png");
|
.getByRole("textbox", { name: "Text data" })
|
||||||
await uploadImage(page, opaquePng);
|
.fill(pixelRow(33, [255, 0, 0, 255]));
|
||||||
const alert = page.locator("[role='alert']");
|
await page.getByRole("button", { name: "Render text" }).click();
|
||||||
|
const alert = page.getByRole("alert");
|
||||||
await expect(alert).toBeVisible();
|
await expect(alert).toBeVisible();
|
||||||
const text = (await alert.textContent()) ?? "";
|
await expect(alert).toHaveText(/\S/);
|
||||||
expect(text).not.toMatch(/^errors\./);
|
await expect(alert).not.toHaveText(/^errors\./);
|
||||||
expect(text.length).toBeGreaterThan(3);
|
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 { 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"];
|
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useEnglish(page);
|
||||||
|
});
|
||||||
|
|
||||||
for (const route of ROUTES) {
|
for (const route of ROUTES) {
|
||||||
test(`page ${route} loads without errors`, async ({ page }) => {
|
test(`page ${route} loads without errors`, async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
const resp = await page.goto(route);
|
const resp = await page.goto(route);
|
||||||
expect(resp?.status()).toBe(200);
|
expect(resp?.status()).toBe(200);
|
||||||
await page.waitForLoadState("networkidle");
|
await expect(page.getByRole("main")).toBeVisible();
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -18,40 +22,33 @@ test("unknown tool route responds 404 on static build", async ({ page }) => {
|
|||||||
expect(resp?.status()).toBe(404);
|
expect(resp?.status()).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXME:
|
test("theme toggle persists the selected theme", async ({ page }) => {
|
||||||
// Error: expect(received).toBe(expected) // Object.is equality
|
const sink = trackErrors(page);
|
||||||
|
|
||||||
// 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,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const root = page.locator("main.preview-root");
|
const main = page.getByRole("main");
|
||||||
const before = await root.getAttribute("data-theme");
|
const before = await main.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"),
|
|
||||||
);
|
|
||||||
expect(["light", "dark"]).toContain(before);
|
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(["light", "dark"]).toContain(after);
|
||||||
expect(stored).toBe(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 }) => {
|
test("language toggle marks the active button", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const group = page.getByRole("group", { name: "Language" });
|
const group = page.getByRole("group", { name: "Language" });
|
||||||
await expect(async () => {
|
const en = group.getByRole("button", { name: "EN" });
|
||||||
await group.getByRole("button", { name: "EN" }).click();
|
await en.click();
|
||||||
await expect(page.locator(".lang-btn.active")).toHaveText("EN");
|
await expect(en).toHaveAttribute("aria-pressed", "true");
|
||||||
}).toPass();
|
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 { expect, test } from "playwright/test";
|
||||||
import {
|
import {
|
||||||
corruptPng,
|
corruptPng,
|
||||||
@@ -7,79 +8,176 @@ import {
|
|||||||
opaquePng,
|
opaquePng,
|
||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
|
downloadResultBytes,
|
||||||
|
errorAlert,
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
metaValue,
|
|
||||||
openTool,
|
openTool,
|
||||||
suggestedDownloadName,
|
rangeInput,
|
||||||
|
resultImage,
|
||||||
|
sourceImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
useEnglish,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
test("flip-png: full flow - upload, result, meta, download", async ({
|
test("resize-png uses the source dimensions by default", async ({ page }) => {
|
||||||
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);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, landscapePng);
|
await uploadImage(page, landscapePng);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
await expect(page.locator('img[alt="Result image"]')).toHaveAttribute(
|
const { name, bytes } = await downloadResultBytes(page);
|
||||||
"src",
|
expect(name).toBe("flip-png.png");
|
||||||
/^(blob:|data:image\/png;base64,)/,
|
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
||||||
);
|
137, 80, 78, 71, 13, 10, 26, 10,
|
||||||
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 expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
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);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "convert-png-to-jpg");
|
await openTool(page, "convert-png-to-jpg");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, largePng);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
const downloadName = await suggestedDownloadName(
|
const quality = rangeInput(page, "Quality");
|
||||||
page,
|
await quality.fill("10");
|
||||||
'button[aria-label="Download result"]',
|
const low = await downloadResultBytes(page);
|
||||||
);
|
expect(low.name).toBe("convert-png-to-jpg.jpg");
|
||||||
expect(downloadName).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);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.fixme("reset clears result but keeps source", async ({ page }) => {
|
test("convert-png-to-webp: produces WebP and quality affects size", async ({
|
||||||
await openTool(page, "flip-png");
|
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 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 page.getByRole("button", { name: "Reset" }).click();
|
||||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
await expect(radius).toHaveValue("4");
|
||||||
await expect(page.locator(".empty")).toContainText("no result yet");
|
await expect(sourceImage(page)).toBeVisible();
|
||||||
await expect(page.locator('img[alt="source"]')).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 }) => {
|
test("blur: changing slider updates result image", async ({ page }) => {
|
||||||
await openTool(page, "blur-png");
|
await openTool(page, "blur-png");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
const src1 = await page
|
const firstSource = await resultImage(page).getAttribute("src");
|
||||||
.locator('img[alt="Result image"]')
|
await rangeInput(page, /radius/i).fill("20");
|
||||||
.getAttribute("src");
|
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
|
||||||
await expect(async () => {
|
|
||||||
await page.locator('input[type="range"]').fill("20");
|
|
||||||
await expect(page.locator('img[alt="Result image"]')).not.toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
src1 ?? "",
|
|
||||||
);
|
|
||||||
}).toPass();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
|
try {
|
||||||
await context.addInitScript(() => {
|
await context.addInitScript(() => {
|
||||||
Object.defineProperty(window, "Worker", {
|
Object.defineProperty(window, "Worker", {
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -87,21 +185,24 @@ test("runs without Web Worker (fallback)", async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
await useEnglish(page);
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, landscapePng);
|
await uploadImage(page, landscapePng);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, corruptPng);
|
await uploadImage(page, corruptPng);
|
||||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
await expect(errorAlert(page)).toBeVisible();
|
||||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
await expect(resultImage(page)).toHaveCount(0);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +211,7 @@ for (const fixture of [onePixelPng, largePng]) {
|
|||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, fixture);
|
await uploadImage(page, fixture);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
expectNoErrors(sink);
|
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,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
openTool,
|
openTool,
|
||||||
|
renderText,
|
||||||
|
resultImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
verdictStatus,
|
||||||
} from "./helpers/page";
|
} 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 [
|
for (const [id, input] of [
|
||||||
["base64-to-png", tinyBase64],
|
["base64-to-png", tinyBase64],
|
||||||
["hex-to-png", "ff0000ff"],
|
["hex-to-png", "ff0000ff"],
|
||||||
@@ -22,15 +31,15 @@ for (const [id, input] of [
|
|||||||
"rgb-values-to-png",
|
"rgb-values-to-png",
|
||||||
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
|
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
|
||||||
],
|
],
|
||||||
|
["data-uri-to-png", `data:image/png;base64,${tinyBase64}`],
|
||||||
["svg-to-png", svgMarkup],
|
["svg-to-png", svgMarkup],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`text input → ${id} produces result image`, async ({ page }) => {
|
test(`text input → ${id} produces result image`, async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await expect(async () => {
|
|
||||||
await openTool(page, id);
|
await openTool(page, id);
|
||||||
await page.locator(".text-source textarea").fill(input);
|
await expect(async () => {
|
||||||
await page.getByRole("button", { name: "Render text" }).click();
|
await renderText(page, input);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
}).toPass({ timeout: 25_000 });
|
}).toPass({ timeout: 25_000 });
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
@@ -41,62 +50,55 @@ test("png-to-base64 shows decoded text result", async ({ page }) => {
|
|||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "png-to-base64");
|
await openTool(page, "png-to-base64");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
const code = page.locator(".result-pre code");
|
const output = page.getByLabel("Text result");
|
||||||
await expect(code).toBeVisible();
|
await expect(output).toBeVisible();
|
||||||
const text = (await code.textContent()) ?? "";
|
await expect(output).toHaveText(/\S/);
|
||||||
expect(text.length).toBeGreaterThan(20);
|
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [input, expected] of [
|
test("verify-is-png renders different verdicts for valid and invalid input", async ({
|
||||||
[tinyBase64, "Yes — this is a valid PNG signature."],
|
page,
|
||||||
["aGVsbG8=", "No — the signature does not match a PNG file."],
|
}) => {
|
||||||
] as const) {
|
|
||||||
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
|
||||||
await expect(async () => {
|
|
||||||
await openTool(page, "verify-is-png");
|
await openTool(page, "verify-is-png");
|
||||||
await page.locator(".text-source textarea").fill(input);
|
const verdict = verdictStatus(page);
|
||||||
await page.getByRole("button", { name: "Render text" }).click();
|
|
||||||
await expect(page.locator(".verdict-text")).toContainText(expected);
|
|
||||||
}).toPass({ timeout: 25_000 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
|
await renderText(page, tinyBase64);
|
||||||
await openTool(page, "png-is-transparent");
|
await expect(verdict).toBeVisible();
|
||||||
await uploadImage(page, opaquePng);
|
const validText = await verdict.textContent();
|
||||||
await expect(page.locator(".verdict-text")).toContainText(
|
|
||||||
"No — all pixels are fully opaque.",
|
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 openTool(page, "png-is-transparent");
|
||||||
await uploadImage(page, transparentPng);
|
await uploadImage(page, transparentPng);
|
||||||
await expect(page.locator(".verdict-text")).toContainText(
|
await expectVerdict(page);
|
||||||
"Yes — there are transparent or semi-transparent pixels.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 openTool(page, "png-is-grayscale");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator(".verdict-text")).toContainText(
|
await expectVerdict(page);
|
||||||
"No — colored pixels were found.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("png-orientation: landscape image → Landscape", async ({ page }) => {
|
test("png-orientation: landscape image renders a verdict", async ({ page }) => {
|
||||||
await openTool(page, "png-orientation");
|
await openTool(page, "png-orientation");
|
||||||
await uploadImage(page, opaquePng); // 64×48 → landscape
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator(".verdict-text")).toContainText("Landscape");
|
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 openTool(page, "png-file-size");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
const verdict = page.locator(".verdict-text");
|
await expectVerdict(page);
|
||||||
await expect(verdict).toBeVisible();
|
|
||||||
await expect(verdict).toContainText("KB");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
openTool,
|
openTool,
|
||||||
|
resultImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
|
textResult,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
verdictStatus,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
type Kind = "image" | "text-out" | "verdict";
|
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: "convert-png-to-webp", kind: "image" },
|
||||||
{ id: "png-to-bmp", kind: "image" },
|
{ id: "png-to-bmp", kind: "image" },
|
||||||
{ id: "png-to-base64", kind: "text-out" },
|
{ 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 ──────────────────────────────────────────────────
|
// ── alpha ──────────────────────────────────────────────────
|
||||||
{ id: "remove-background-png", kind: "image", file: transparentPng },
|
{ id: "remove-background-png", kind: "image", file: transparentPng },
|
||||||
{ id: "remove-color-from-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: "auto-contrast-png", kind: "image" },
|
||||||
{ id: "png-to-hsl", kind: "image" },
|
{ id: "png-to-hsl", kind: "image" },
|
||||||
{ id: "png-to-cmyk", 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 ───────────────────────────────────────────────
|
// ── geometry ───────────────────────────────────────────────
|
||||||
{ id: "flip-png", kind: "image" },
|
{ id: "flip-png", kind: "image" },
|
||||||
{ id: "rotate-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: "add-border-png", kind: "image" },
|
||||||
{ id: "tile-png", kind: "image" },
|
{ id: "tile-png", kind: "image" },
|
||||||
{ id: "change-canvas-size-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 }) => {
|
test(`tool ${id} (${kind})`, async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
|
|
||||||
const outputLocator = (): ReturnType<typeof page.locator> => {
|
const outputLocator = () => {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
return page.locator('img[alt="Result image"]');
|
return resultImage(page);
|
||||||
case "text-out":
|
case "text-out":
|
||||||
return page.locator(".result-pre code");
|
return textResult(page);
|
||||||
case "verdict":
|
case "verdict":
|
||||||
return page.locator(".verdict-text");
|
return verdictStatus(page);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@
|
|||||||
|
|
||||||
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
||||||
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
||||||
- `isolation/` — правило изоляции old/new UI.
|
|
||||||
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
||||||
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
||||||
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
||||||
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
||||||
токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`.
|
токенов) и мини-словари `lib/i18n/`.
|
||||||
- `web/scripts/`:
|
- `web/scripts/`:
|
||||||
- `lint-all.mjs` — оркестратор `lint:all`.
|
- `lint-all.mjs` — оркестратор `lint:all`.
|
||||||
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
||||||
@@ -28,16 +27,9 @@
|
|||||||
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
|
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
|
||||||
`prefer: 'type-imports'`).
|
`prefer: 'type-imports'`).
|
||||||
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
|
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
|
||||||
`eslint-plugin-svelte`) навешаны **только на новый код**
|
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
|
||||||
(`src/lib/components/**`, `src/routes/**`), чтобы старый код не засыпался
|
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
|
||||||
предсуществующими ошибками. Старый код (C17) — `src/lib/v1/**`,
|
устаревшие пути в `eslint.config.js`.
|
||||||
`src/routes/v1/**`. Когда старый дизайн удалён (C19), scoped-блок убирается и
|
|
||||||
`recommended` включается на весь код.
|
|
||||||
- **Scoped-пути двигаются вместе с папками** (см. `plan-redesign.md` §10): не
|
|
||||||
оставлять устаревшие пути в `eslint.config.js` (C17 уже двигал
|
|
||||||
`routes/preview/**` → `routes/**` и `components/kit/**` → `components/**`; на
|
|
||||||
C19 при наведении порядка в `components/` paths останутся `components/**` +
|
|
||||||
`routes/**`).
|
|
||||||
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
||||||
`let`).
|
`let`).
|
||||||
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
||||||
@@ -45,7 +37,6 @@
|
|||||||
`__fixtures__/` дописать их туда же.
|
`__fixtures__/` дописать их туда же.
|
||||||
- design-tokens правила применяются к тем же scoped-путям
|
- design-tokens правила применяются к тем же scoped-путям
|
||||||
(`src/lib/components/**`, `src/routes/**`).
|
(`src/lib/components/**`, `src/routes/**`).
|
||||||
- isolation-правило включено на весь `**/*.{ts,svelte}`.
|
|
||||||
|
|
||||||
## Правила плагина design-tokens
|
## Правила плагина design-tokens
|
||||||
|
|
||||||
@@ -66,10 +57,11 @@
|
|||||||
|
|
||||||
## Правило плагина i18n (`i18n/dict-consistency`)
|
## Правило плагина i18n (`i18n/dict-consistency`)
|
||||||
|
|
||||||
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md`
|
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
||||||
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только
|
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
||||||
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый
|
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
|
||||||
словарь подхватывается без правки правила.
|
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
|
||||||
|
правила.
|
||||||
|
|
||||||
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
||||||
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
||||||
@@ -80,14 +72,21 @@
|
|||||||
пропавшая целая секция репортится один раз (не по каждому потомку);
|
пропавшая целая секция репортится один раз (не по каждому потомку);
|
||||||
- **пустые значения**: `""` и строка из одних пробелов;
|
- **пустые значения**: `""` и строка из одних пробелов;
|
||||||
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
||||||
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
|
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
|
||||||
— ловит потерянную при переводе переменную ровно один раз, даже когда
|
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
|
||||||
отклоняется одна-единственная локаль.
|
когда отклоняется одна-единственная локаль.
|
||||||
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
||||||
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
||||||
(синтакс, без типов).
|
(синтакс, без типов).
|
||||||
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
||||||
(осознанные отклонения до достижения zero-warn).
|
(осознанные отклонения до достижения zero-warn).
|
||||||
|
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
|
||||||
|
отсутствовать только в базовой локали; в текущей конфигурации это registry
|
||||||
|
metadata `tools.*.title` и `tools.*.description`. Пустые значения и
|
||||||
|
placeholders для присутствующих ключей всё равно проверяются.
|
||||||
|
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
|
||||||
|
проверяются на missing; в текущей конфигурации это неиспользуемые
|
||||||
|
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
|
||||||
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||||
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||||
@@ -143,9 +142,6 @@ hct-авторство.
|
|||||||
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
|
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
|
||||||
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
|
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
|
||||||
`__fixtures__/src/app.css` (не из реального `src/app.css`).
|
`__fixtures__/src/app.css` (не из реального `src/app.css`).
|
||||||
- `__tests__/no-mixed-imports.test.ts` — isolation-правило через `Linter` API с
|
|
||||||
`cwd` на `__fixtures__`: правило резолвит импорты по реальным файлам, поэтому
|
|
||||||
цели импортов обязаны существовать на диске.
|
|
||||||
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
|
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
|
||||||
`process.cwd()` не трогается, реальные `src/` не читаются.
|
`process.cwd()` не трогается, реальные `src/` не читаются.
|
||||||
|
|
||||||
@@ -154,27 +150,3 @@ hct-авторство.
|
|||||||
|
|
||||||
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
|
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
|
||||||
добавление/правка стабов — тоже работа с валидным кодом.
|
добавление/правка стабов — тоже работа с валидным кодом.
|
||||||
|
|
||||||
## Isolation-правило (`isolation/no-mixed-imports`)
|
|
||||||
|
|
||||||
**Полная взаимная изоляция** старого (v1) и нового (корень) UI. В отличие от
|
|
||||||
`no-restricted-imports`, правило **резолвит** каждый импорт (и `$lib/...`, и
|
|
||||||
относительные `./`/`../`) до реального файла и классифицирует стороны по
|
|
||||||
фактическому пути, поэтому относительным импортом правило не обойти.
|
|
||||||
|
|
||||||
- Старое: `routes/v1/**`, `lib/v1/**`.
|
|
||||||
- Новое: `routes/**` без `v1/**`; корневой `lib` целиком (`lib/**`) без
|
|
||||||
`lib/v1/**` и без общих пакетов.
|
|
||||||
- Общее (разрешено обоим) — только служебные пакеты: `core/`, `theme`. v1 не
|
|
||||||
высовывается: из `lib/v1/**` наружу разрешены импорты лишь в общие пакеты и в
|
|
||||||
собственный форк i18n (`lib/v1/i18n/**`).
|
|
||||||
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
|
|
||||||
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
|
|
||||||
glob-паттернов в настройке, а не код правила.
|
|
||||||
- Изоляция уже достигнута: старый `lib/v1/registry.ts`/`registry-helpers.ts` не
|
|
||||||
тянут `registry-schema` (пилоты add-border/add-stroke работают через
|
|
||||||
`params`), `registry.ts` не импортирует `ToolSchema`; новый `list-tools`
|
|
||||||
использует `$lib/tool-icons.ts`, а не старые `lib/v1/tools/tool-icons.ts`.
|
|
||||||
|
|
||||||
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
|
|
||||||
`app_v1.css`) — известный техдолг, его НЕ чинить и не игнорировать правилами.
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Fixture stub for lint-rule tests: content is ignored (the test supplies the
|
|
||||||
// source code), this file only has to EXIST for import resolution.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
|
|
||||||
@@ -58,6 +58,70 @@ describe("i18n/dict-consistency", () => {
|
|||||||
expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]);
|
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", () => {
|
it("flags a leaf key missing in the current file", () => {
|
||||||
const enMissing = fixture("en.ts").replace(
|
const enMissing = fixture("en.ts").replace(
|
||||||
'\t\trestoreLast: "Restore {title}",',
|
'\t\trestoreLast: "Restore {title}",',
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
// Two strategies, picked per rule in the test files:
|
// Two strategies, picked per rule in the test files:
|
||||||
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
|
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
|
||||||
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
|
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
|
||||||
// - Linter API + fixtures — needed when a rule reads real files. The isolation
|
// - Linter API + fixtures — needed when a rule reads real files, such as
|
||||||
// rule resolves imports via fs (targets must exist on disk) and
|
// no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
|
||||||
// no-undefined-in-svelte reads <cwd>/src/app.css for the token dictionary.
|
// dictionary. `verifyInFixtures` runs a rule with cwd fixed to
|
||||||
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
|
// `__fixtures__/`, without touching process.cwd() or the real src/ tree.
|
||||||
// work without touching process.cwd() or the real src/ tree.
|
|
||||||
import { Linter } from "eslint";
|
import { Linter } from "eslint";
|
||||||
import type { Rule } from "eslint";
|
import type { Rule } from "eslint";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
// Tests for the isolation rule (web/eslint-plugins/isolation/no-mixed-imports.js).
|
|
||||||
//
|
|
||||||
// The rule RESOLVES import specifiers to real files (via $lib alias and
|
|
||||||
// relative ../ paths), so every import target must exist on disk. It therefore
|
|
||||||
// runs through the Linter API with cwd pinned to the committed fixture tree
|
|
||||||
// (__fixtures__/src) — see __tests__/helpers.ts.
|
|
||||||
//
|
|
||||||
// Side map (mirrors the defaults in no-mixed-imports.js):
|
|
||||||
// old -> routes/v1/**, lib/v1/**
|
|
||||||
// new -> routes/** (minus v1), lib/** (minus lib/v1/** and shared)
|
|
||||||
// shared-> everything else: core/, theme
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import noMixedImports from "../isolation/no-mixed-imports.js";
|
|
||||||
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
|
|
||||||
|
|
||||||
const isolationConfig: FlatConfig = [
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,svelte}"],
|
|
||||||
plugins: {
|
|
||||||
isolation: {
|
|
||||||
rules: { "no-mixed-imports": asRuleModule(noMixedImports) },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: { "isolation/no-mixed-imports": "error" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const messagesFor = (relFile: string, code: string) =>
|
|
||||||
verifyInFixtures(isolationConfig, code, relFile);
|
|
||||||
|
|
||||||
/** Assert a single "noMixed" violation with the given sides. */
|
|
||||||
function expectViolation(
|
|
||||||
relFile: string,
|
|
||||||
code: string,
|
|
||||||
side: string,
|
|
||||||
target: string,
|
|
||||||
) {
|
|
||||||
const messages = messagesFor(relFile, code);
|
|
||||||
expect(messages).toHaveLength(1);
|
|
||||||
expect(messages[0].messageId).toBe("noMixed");
|
|
||||||
// ESLint interpolates `data` into the message text (no `data` field on the
|
|
||||||
// returned message), so assert on the rendered message instead.
|
|
||||||
expect(messages[0].message).toContain(`"${side}" file imports "${target}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assert the import is allowed (no violations). */
|
|
||||||
function expectClean(relFile: string, code: string) {
|
|
||||||
expect(messagesFor(relFile, code)).toEqual([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("isolation/no-mixed-imports", () => {
|
|
||||||
it("flags an old file importing a new component", () => {
|
|
||||||
expectViolation(
|
|
||||||
"lib/v1/old.ts",
|
|
||||||
'import { c } from "../components/CheckerCanvas.svelte";',
|
|
||||||
"old",
|
|
||||||
"new",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a new route importing an old module through the $lib alias", () => {
|
|
||||||
expectViolation(
|
|
||||||
"routes/+page.svelte",
|
|
||||||
'import { o } from "$lib/v1/old";',
|
|
||||||
"new",
|
|
||||||
"old",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a new component importing old code via a relative path", () => {
|
|
||||||
expectViolation(
|
|
||||||
"lib/components/CheckerCanvas.svelte",
|
|
||||||
'import { o } from "../v1/old.ts";',
|
|
||||||
"new",
|
|
||||||
"old",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows old -> shared (core via relative path)", () => {
|
|
||||||
expectClean("lib/v1/old.ts", 'import { e } from "../core/errors";');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags old -> root i18n ($lib/i18n is the new branch now)", () => {
|
|
||||||
expectViolation(
|
|
||||||
"routes/v1/+layout.svelte",
|
|
||||||
'import { t } from "$lib/i18n/t";',
|
|
||||||
"old",
|
|
||||||
"new",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows old -> own i18n copy and theme via $lib", () => {
|
|
||||||
expectClean(
|
|
||||||
"routes/v1/+layout.svelte",
|
|
||||||
'import { t } from "$lib/v1/i18n/t";',
|
|
||||||
);
|
|
||||||
expectClean(
|
|
||||||
"routes/v1/+layout.svelte",
|
|
||||||
'import { g } from "$lib/theme.svelte";',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows new -> new (root i18n and components)", () => {
|
|
||||||
expectClean("routes/+page.svelte", 'import { t } from "$lib/i18n/t";');
|
|
||||||
expectClean(
|
|
||||||
"routes/+page.svelte",
|
|
||||||
'import { b } from "$lib/components/ui/Button.svelte";',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags new i18n importing old and leaves core shared", () => {
|
|
||||||
expectViolation(
|
|
||||||
"lib/i18n/t.ts",
|
|
||||||
'import { o } from "$lib/v1/old";',
|
|
||||||
"new",
|
|
||||||
"old",
|
|
||||||
);
|
|
||||||
expectClean(
|
|
||||||
"lib/core/errors.ts",
|
|
||||||
'import { c } from "$lib/components/CheckerCanvas.svelte";',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the old i18n copy on the old side", () => {
|
|
||||||
expectClean("lib/v1/i18n/t.ts", 'import { o } from "$lib/v1/old";');
|
|
||||||
expectViolation(
|
|
||||||
"lib/v1/i18n/t.ts",
|
|
||||||
'import { c } from "$lib/components/CheckerCanvas.svelte";',
|
|
||||||
"old",
|
|
||||||
"new",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,16 +13,17 @@
|
|||||||
* section is reported once, not per descendant key);
|
* section is reported once, not per descendant key);
|
||||||
* - empty values: `""` or whitespace-only values;
|
* - empty values: `""` or whitespace-only values;
|
||||||
* - placeholders: for a shared key the `{name}` set is compared against the
|
* - placeholders: for a shared key the `{name}` set is compared against the
|
||||||
* canonical set (the one shared by most locales, ties broken by LOCALES
|
* canonical set (the one shared by most locales, ties broken by BASE_LOCALE
|
||||||
* order) — catches a variable lost in translation exactly once, even when
|
* then LOCALES order) — catches a variable lost in translation exactly once,
|
||||||
* only one locale deviates.
|
* even when only one locale deviates.
|
||||||
*
|
*
|
||||||
* The rule self-filters: files whose basename is not one of LOCALES are
|
* 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.
|
* ignored, so it can be attached to the whole `lib/i18n/` directory.
|
||||||
*
|
*
|
||||||
* Options (object, all optional):
|
* Options (object, all optional):
|
||||||
* - allowPaths: dot-path keys excluded from every check (conscious
|
* - allowPaths: dot-path keys excluded from every check.
|
||||||
* deviations until the dict reaches zero-warn, see Фаза 8 §8.3).
|
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
|
||||||
|
* - ignoreMissingPatterns: legacy patterns excluded from missing-key checks.
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -70,6 +71,22 @@ function findDictExport(ast) {
|
|||||||
return null;
|
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. */
|
/** The `LOCALES` list exported from `dict.ts`, or null. */
|
||||||
function findLocalesList(ast) {
|
function findLocalesList(ast) {
|
||||||
for (const node of ast.body) {
|
for (const node of ast.body) {
|
||||||
@@ -167,6 +184,17 @@ function formatSet(set) {
|
|||||||
return `{${[...set].sort().join(", ")}}`;
|
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`). */
|
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
|
||||||
function* ancestorPaths(key) {
|
function* ancestorPaths(key) {
|
||||||
let i = key.lastIndexOf(".");
|
let i = key.lastIndexOf(".");
|
||||||
@@ -190,6 +218,7 @@ function readDir(dir) {
|
|||||||
if (!fs.existsSync(dictPath)) return null;
|
if (!fs.existsSync(dictPath)) return null;
|
||||||
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
|
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
|
||||||
const locales = findLocalesList(ast);
|
const locales = findLocalesList(ast);
|
||||||
|
const baseLocale = findBaseLocale(ast);
|
||||||
if (!locales || locales.length === 0) return null;
|
if (!locales || locales.length === 0) return null;
|
||||||
|
|
||||||
const dicts = [];
|
const dicts = [];
|
||||||
@@ -218,31 +247,42 @@ function readDir(dir) {
|
|||||||
unionPaths.add(key);
|
unionPaths.add(key);
|
||||||
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
|
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
|
||||||
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
|
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;
|
// 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();
|
const canonicalSets = new Map();
|
||||||
for (const [key, sets] of stringLeaves) {
|
for (const [key, entries] of stringLeaves) {
|
||||||
const counts = new Map();
|
const counts = new Map();
|
||||||
for (const set of sets) {
|
for (const { set } of entries) {
|
||||||
const sig = [...set].sort().join("\u0000");
|
const sig = [...set].sort().join("\u0000");
|
||||||
counts.set(sig, (counts.get(sig) || 0) + 1);
|
counts.set(sig, (counts.get(sig) || 0) + 1);
|
||||||
}
|
}
|
||||||
let bestCount = 0;
|
let bestCount = 0;
|
||||||
for (const set of sets) {
|
let bestLocale = null;
|
||||||
|
for (const { locale, set } of entries) {
|
||||||
const sig = [...set].sort().join("\u0000");
|
const sig = [...set].sort().join("\u0000");
|
||||||
if (counts.get(sig) > bestCount) {
|
const count = counts.get(sig);
|
||||||
bestCount = counts.get(sig);
|
if (
|
||||||
|
count > bestCount ||
|
||||||
|
(count === bestCount &&
|
||||||
|
locale === baseLocale &&
|
||||||
|
bestLocale !== baseLocale)
|
||||||
|
) {
|
||||||
|
bestCount = count;
|
||||||
|
bestLocale = locale;
|
||||||
canonicalSets.set(key, set);
|
canonicalSets.set(key, set);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { locales, dicts, unionPaths, canonicalSets };
|
return { locales, baseLocale, dicts, unionPaths, canonicalSets };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -261,6 +301,14 @@ export default {
|
|||||||
type: "array",
|
type: "array",
|
||||||
items: { type: "string" },
|
items: { type: "string" },
|
||||||
},
|
},
|
||||||
|
baseLocaleFallback: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
ignoreMissingPatterns: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
@@ -275,6 +323,8 @@ export default {
|
|||||||
create(context) {
|
create(context) {
|
||||||
const options = context.options[0] || {};
|
const options = context.options[0] || {};
|
||||||
const allowPaths = new Set(options.allowPaths || []);
|
const allowPaths = new Set(options.allowPaths || []);
|
||||||
|
const baseLocaleFallback = options.baseLocaleFallback || [];
|
||||||
|
const ignoreMissingPatterns = options.ignoreMissingPatterns || [];
|
||||||
|
|
||||||
const filename = path.resolve(
|
const filename = path.resolve(
|
||||||
context.filename || context.physicalFilename || "",
|
context.filename || context.physicalFilename || "",
|
||||||
@@ -287,16 +337,36 @@ export default {
|
|||||||
if (!dictExport) return {};
|
if (!dictExport) return {};
|
||||||
|
|
||||||
const own = buildKeyMap(dictExport.object);
|
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.
|
const missing = [...dirInfo.unionPaths].filter((key) => !own.has(key));
|
||||||
// Top-level gaps only — a missing ancestor already covers its subtree.
|
const requiredMissing = missing.filter((key) => {
|
||||||
const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k));
|
if (isMissingAllowed(key)) return false;
|
||||||
const missingSet = new Set(missing);
|
const descendants = missing.filter((candidate) =>
|
||||||
const topMissing = missing.filter(
|
candidate.startsWith(`${key}.`),
|
||||||
(k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)),
|
);
|
||||||
|
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) {
|
for (const key of topMissing) {
|
||||||
if (allowPaths.has(key)) continue;
|
|
||||||
context.report({
|
context.report({
|
||||||
node: dictExport.id,
|
node: dictExport.id,
|
||||||
messageId: "missingKey",
|
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 svelteParser from "svelte-eslint-parser";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import designTokens from "./eslint-plugins/index.js";
|
import designTokens from "./eslint-plugins/index.js";
|
||||||
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
|
||||||
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
||||||
import i18nPlugin from "./eslint-plugins/i18n/index.js";
|
import i18nPlugin from "./eslint-plugins/i18n/index.js";
|
||||||
|
|
||||||
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
|
||||||
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
||||||
// Когда старый дизайн удалим (C19), этот scoped-блок убирается и recommended
|
|
||||||
// включается на весь код (см. план-redesign §10, шаг 6).
|
|
||||||
const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
|
const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
|
||||||
const oldCode = ["**/src/lib/v1/**", "**/src/routes/v1/**"];
|
|
||||||
|
|
||||||
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
|
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
|
||||||
const jsRecommended = Array.isArray(js.configs.recommended)
|
const jsRecommended = Array.isArray(js.configs.recommended)
|
||||||
@@ -29,10 +24,6 @@ const newSvelteFiles = [
|
|||||||
"**/src/lib/components/**/*.svelte",
|
"**/src/lib/components/**/*.svelte",
|
||||||
"**/src/routes/**/*.svelte",
|
"**/src/routes/**/*.svelte",
|
||||||
];
|
];
|
||||||
const oldSvelteFiles = [
|
|
||||||
"**/src/lib/v1/**/*.svelte",
|
|
||||||
"**/src/routes/v1/**/*.svelte",
|
|
||||||
];
|
|
||||||
|
|
||||||
// FIXME:
|
// FIXME:
|
||||||
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
|
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
|
||||||
@@ -72,7 +63,7 @@ export default tseslint.config(
|
|||||||
"e2e/text-and-verdicts.spec.ts",
|
"e2e/text-and-verdicts.spec.ts",
|
||||||
"e2e/tools-smoke.spec.ts",
|
"e2e/tools-smoke.spec.ts",
|
||||||
"e2e/generators.spec.ts",
|
"e2e/generators.spec.ts",
|
||||||
"e2e/known-issues.spec.ts",
|
"e2e/png-fixtures.spec.ts",
|
||||||
"e2e/i18n.spec.ts",
|
"e2e/i18n.spec.ts",
|
||||||
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
||||||
// tsconfig), поэтому для типизированного парсинга резолвятся
|
// tsconfig), поэтому для типизированного парсинга резолвятся
|
||||||
@@ -82,21 +73,17 @@ export default tseslint.config(
|
|||||||
"eslint-plugins/__tests__/interface-props.test.ts",
|
"eslint-plugins/__tests__/interface-props.test.ts",
|
||||||
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
||||||
"eslint-plugins/__tests__/helpers.ts",
|
"eslint-plugins/__tests__/helpers.ts",
|
||||||
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
|
|
||||||
"eslint-plugins/__tests__/dict-consistency.test.ts",
|
"eslint-plugins/__tests__/dict-consistency.test.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/de.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/de.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
|
|
||||||
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/t.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/t.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
|
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte",
|
"eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte",
|
"eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
|
|
||||||
],
|
],
|
||||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
||||||
},
|
},
|
||||||
@@ -134,11 +121,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
|
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
|
||||||
// Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн
|
// Применяется к новому коду редизайна.
|
||||||
// удалят, расширить glob на весь код, исключив (old)/.
|
|
||||||
{
|
{
|
||||||
files: newSvelteFiles,
|
files: newSvelteFiles,
|
||||||
ignores: oldSvelteFiles,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
"design-tokens": designTokens,
|
"design-tokens": designTokens,
|
||||||
},
|
},
|
||||||
@@ -151,10 +136,8 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
|
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
|
||||||
// `let {...}: Props = $props()` (плагин conventions/interface-props).
|
// `let {...}: Props = $props()` (плагин conventions/interface-props).
|
||||||
// Только Svelte-файлы нового кода (см. newSvelteFiles).
|
|
||||||
{
|
{
|
||||||
files: newSvelteFiles,
|
files: newSvelteFiles,
|
||||||
ignores: oldSvelteFiles,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
conventions: conventionsPlugin,
|
conventions: conventionsPlugin,
|
||||||
},
|
},
|
||||||
@@ -166,7 +149,6 @@ export default tseslint.config(
|
|||||||
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
|
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
|
||||||
{
|
{
|
||||||
files: newCode,
|
files: newCode,
|
||||||
ignores: oldCode,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
conventions: conventionsPlugin,
|
conventions: conventionsPlugin,
|
||||||
},
|
},
|
||||||
@@ -175,16 +157,22 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
||||||
// warn-only: пропущенный перевод не должен валить сборку. Список локалей
|
// Правило сообщает warning, а `lint:all` превращает новые warnings в
|
||||||
// берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются
|
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
|
||||||
// автоматически; правило игнорирует не-локали в этой папке само.
|
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
|
||||||
{
|
{
|
||||||
files: ["**/src/lib/i18n/*.ts"],
|
files: ["**/src/lib/i18n/*.ts"],
|
||||||
plugins: {
|
plugins: {
|
||||||
i18n: i18nPlugin,
|
i18n: i18nPlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"i18n/dict-consistency": "warn",
|
"i18n/dict-consistency": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
baseLocaleFallback: ["tools.*.title", "tools.*.description"],
|
||||||
|
ignoreMissingPatterns: ["tools.*.params"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Полные recommended-наборы — только на новый код.
|
// Полные recommended-наборы — только на новый код.
|
||||||
@@ -192,17 +180,14 @@ export default tseslint.config(
|
|||||||
...jsRecommended.map((cfg) => ({
|
...jsRecommended.map((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
files: newCode,
|
files: newCode,
|
||||||
ignores: oldCode,
|
|
||||||
})),
|
})),
|
||||||
...tseslint.configs.recommended.map((cfg) => ({
|
...tseslint.configs.recommended.map((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
files: newCode,
|
files: newCode,
|
||||||
ignores: oldCode,
|
|
||||||
})),
|
})),
|
||||||
...svelteRecommended.map((cfg) => ({
|
...svelteRecommended.map((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
files: newSvelteFiles,
|
files: newSvelteFiles,
|
||||||
ignores: oldSvelteFiles,
|
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -214,20 +199,6 @@ export default tseslint.config(
|
|||||||
"prefer-const": "off",
|
"prefer-const": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// ===== Изоляция старого UI (old) и нового preview =====================
|
|
||||||
// Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5).
|
|
||||||
// Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному
|
|
||||||
// пути (и $lib, и относительные) и ругается на old→new / new→old.
|
|
||||||
// Общее (core/, theme) разрешено обоим.
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,svelte}"],
|
|
||||||
plugins: {
|
|
||||||
isolation: isolationPlugin,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"isolation/no-mixed-imports": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// prettier — последним, чтобы гасить форматирующие правила из recommended.
|
// prettier — последним, чтобы гасить форматирующие правила из recommended.
|
||||||
prettier,
|
prettier,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { defineConfig } from "playwright/test";
|
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({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
outputDir: "./e2e-results",
|
outputDir: "./e2e-results",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
workers: process.env.CI ? 2 : 1,
|
||||||
reporter: [["list"], ["html", { open: "never" }]],
|
reporter: [["list"], ["html", { open: "never" }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://127.0.0.1:4173",
|
baseURL: "http://127.0.0.1:4173",
|
||||||
@@ -19,6 +33,31 @@ export default defineConfig({
|
|||||||
viewport: { width: 1440, height: 900 },
|
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: {
|
webServer: {
|
||||||
command: "pnpm build && node scripts/serve-static.mjs --port 4173",
|
command: "pnpm build && node scripts/serve-static.mjs --port 4173",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const bins = {
|
|||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
name: "ESLint (svelte) + design-tokens rules",
|
name: "ESLint (svelte) + design-tokens rules",
|
||||||
args: [bins.eslint, ...colorFlag, "."],
|
args: [bins.eslint, ...colorFlag, "--max-warnings=0", "."],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Stylelint (css) — design-token style",
|
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: hct(from var(--brand-alt) h 25 90);
|
||||||
--color-text-accent-muted: hct(from var(--brand-alt) h 22 70);
|
--color-text-accent-muted: hct(from var(--brand-alt) h 22 70);
|
||||||
|
|
||||||
--color-main: hct(from var(--brand-main) h 60 50);
|
--color-main: hct(from var(--brand-main) h 60 56);
|
||||||
--color-accent: hct(from var(--brand-alt) h 60 50);
|
--color-accent: hct(from var(--brand-alt) h 60 56);
|
||||||
--color-success: hct(160 65 55);
|
--color-success: hct(160 65 55);
|
||||||
--color-warning: hct(80 65 55);
|
--color-warning: hct(80 65 55);
|
||||||
--color-danger: hct(30 70 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` и порядок отображения в каталоге
|
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
|
||||||
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
|
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
|
||||||
* Этот файл — локальная копия подхода для нового UI; старый
|
|
||||||
* `lib/v1/categories` (массив + union вручную) не трогаем — он обслуживает
|
|
||||||
* старый UI в `/v1/*`.
|
|
||||||
*
|
*
|
||||||
* Человекочитаемые названия живут в словарях i18n: секция categories,
|
* Человекочитаемые названия живут в словарях i18n: секция categories,
|
||||||
* ключ = CategoryId.
|
* ключ = CategoryId.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<section class="catalog-group">
|
<section class="catalog-group">
|
||||||
<div class="group-title">
|
<div class="group-title">
|
||||||
<span>{label}</span>
|
<h2>{label}</h2>
|
||||||
<i>{count.toString().padStart(2, "0")} <!-- --> {t("catalog.toolsCount")}</i
|
<i>{count.toString().padStart(2, "0")} <!-- --> {t("catalog.toolsCount")}</i
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
padding-bottom: var(--space-m);
|
padding-bottom: var(--space-m);
|
||||||
border-bottom: var(--size-border) solid var(--color-border);
|
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);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
letter-spacing: var(--space-text-xl);
|
letter-spacing: var(--space-text-xl);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
<h1>{t("catalog.heading")}</h1>
|
<h1>{t("catalog.heading")}</h1>
|
||||||
<p>{t("catalog.lead")}</p>
|
<p>{t("catalog.lead")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="catalog-total">
|
<output
|
||||||
|
class="catalog-total"
|
||||||
|
aria-label={`${total} ${t("catalog.toolsAvailable")}`}
|
||||||
|
>
|
||||||
<b>{total}</b>
|
<b>{total}</b>
|
||||||
<span>{t("catalog.toolsAvailable")}</span>
|
<span>{t("catalog.toolsAvailable")}</span>
|
||||||
</div>
|
</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={category === f.value}
|
class:active={category === f.value}
|
||||||
|
aria-pressed={category === f.value}
|
||||||
onclick={() => (category = f.value)}
|
onclick={() => (category = f.value)}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
FieldSpecKind,
|
FieldSpecKind,
|
||||||
ToolSchema,
|
ToolSchema,
|
||||||
} from "$lib/registry-schema";
|
} from "$lib/registry-schema";
|
||||||
|
import { resolveLayoutGroups } from "$lib/registry-schema";
|
||||||
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
|
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { RotateCcw } from "@lucide/svelte";
|
import { RotateCcw } from "@lucide/svelte";
|
||||||
@@ -57,38 +58,7 @@
|
|||||||
}
|
}
|
||||||
let { schema, values, toolId, onchange, onreset }: Props = $props();
|
let { schema, values, toolId, onchange, onreset }: Props = $props();
|
||||||
|
|
||||||
interface LayoutGroup {
|
const layoutGroups = $derived(resolveLayoutGroups(schema));
|
||||||
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;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each layoutGroups as group (group.key)}
|
{#each layoutGroups as group (group.key)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MetaList from "$lib/components/MetaList.svelte";
|
import MetaList from "$lib/components/MetaList.svelte";
|
||||||
|
import { buildSchemaPreviewModel } from "$lib/components/schema-preview-model";
|
||||||
import SchemaActions from "$lib/components/SchemaActions.svelte";
|
import SchemaActions from "$lib/components/SchemaActions.svelte";
|
||||||
import SchemaResultTile from "$lib/components/SchemaResultTile.svelte";
|
import SchemaResultTile from "$lib/components/SchemaResultTile.svelte";
|
||||||
import SchemaSourceTile from "$lib/components/SchemaSourceTile.svelte";
|
import SchemaSourceTile from "$lib/components/SchemaSourceTile.svelte";
|
||||||
@@ -46,36 +47,11 @@
|
|||||||
ondownload,
|
ondownload,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const sourceValue = $derived.by(() => {
|
const model = $derived(
|
||||||
switch (inputMode) {
|
buildSchemaPreviewModel(
|
||||||
case "none":
|
{ inputMode, resultKind, source, result, fileResult, textResult },
|
||||||
return "—";
|
t,
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -83,7 +59,7 @@
|
|||||||
<span class="label">{t("resultCard.previewPanel")}</span>
|
<span class="label">{t("resultCard.previewPanel")}</span>
|
||||||
<SchemaActions
|
<SchemaActions
|
||||||
{inputMode}
|
{inputMode}
|
||||||
canDownload={hasResult}
|
canDownload={model.hasResult}
|
||||||
{running}
|
{running}
|
||||||
{onupload}
|
{onupload}
|
||||||
{ondownload}
|
{ondownload}
|
||||||
@@ -119,9 +95,9 @@
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
<MetaList
|
<MetaList
|
||||||
items={[
|
items={[
|
||||||
{ caption: t("sourceCard.source"), value: sourceValue },
|
{ caption: t("sourceCard.source"), value: model.sourceValue },
|
||||||
{ caption: t("resultCard.result"), value: resultValue },
|
{ caption: t("resultCard.result"), value: model.resultValue },
|
||||||
{ caption: t("resultCard.format"), value: formatValue },
|
{ caption: t("resultCard.format"), value: model.formatValue },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,11 +82,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if resultKind === "image" && resultUrl}
|
{: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}
|
{:else if running}
|
||||||
<Check size={22} />
|
<Check size={22} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="empty">{t("resultCard.noResult")}</span>
|
<span data-testid="empty-state" class="empty"
|
||||||
|
>{t("resultCard.noResult")}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</PreviewTile>
|
</PreviewTile>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if mode === "image" && sourceUrl}
|
{: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"}
|
{:else if mode === "image"}
|
||||||
<span class="empty">{t("sourceCard.chooseImage")}</span>
|
<span class="empty">{t("sourceCard.chooseImage")}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -32,7 +32,11 @@
|
|||||||
{#if kind === "verdict"}
|
{#if kind === "verdict"}
|
||||||
<div class="verdict">
|
<div class="verdict">
|
||||||
<span class="verdict-label">{t("resultCard.result")}</span>
|
<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>
|
<span class="verdict-text">{displayValue}</span>
|
||||||
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
|
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +54,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre class="result-pre"><code>{value}</code></pre>
|
<pre aria-label={t("textResult.outputAria")} class="result-pre"><code
|
||||||
|
>{value}</code
|
||||||
|
></pre>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
<div class="text-source">
|
<div class="text-source">
|
||||||
<textarea
|
<textarea
|
||||||
|
aria-label={t("textInput.aria")}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
{placeholder}
|
{placeholder}
|
||||||
rows="6"
|
rows="6"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import SchemaFields from "$lib/components/SchemaFields.svelte";
|
import SchemaFields from "$lib/components/SchemaFields.svelte";
|
||||||
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
|
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
|
||||||
import { debounce } from "$lib/core/debounce";
|
import { debounce } from "$lib/core/debounce";
|
||||||
import { ToolError } from "$lib/core/errors";
|
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
||||||
import { decodeFile, encode } from "$lib/core/io";
|
import { decodeFile, downloadBlob, encode } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { execute } from "$lib/executor";
|
import { execute } from "$lib/executor";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, ToolEntry } from "$lib/registry";
|
import type { FileResult, ToolEntry } from "$lib/registry";
|
||||||
import {
|
import {
|
||||||
|
applySourceDefaults,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
sanitizeSchemaParams,
|
sanitizeSchemaParams,
|
||||||
type ToolSchema,
|
type ToolSchema,
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
}
|
}
|
||||||
|
type DisplayError =
|
||||||
|
| { kind: "i18n"; key: string; vars?: ErrorVars }
|
||||||
|
| { kind: "plain"; text: string };
|
||||||
let { tool }: Props = $props();
|
let { tool }: Props = $props();
|
||||||
|
|
||||||
const schema = $derived(
|
const schema = $derived(
|
||||||
@@ -42,9 +46,17 @@
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let error = $state("");
|
let displayError = $state<DisplayError | null>(null);
|
||||||
let started = $state(false);
|
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);
|
const debouncedRun = debounce(() => run(), 200);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -58,30 +70,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
values = schema ? defaultSchemaParams(schema) : {};
|
values = schema
|
||||||
|
? defaultSchemaParams(schema, { source: source ?? undefined })
|
||||||
|
: {};
|
||||||
result = null;
|
result = null;
|
||||||
fileResult = null;
|
fileResult = null;
|
||||||
textResult = null;
|
textResult = null;
|
||||||
verdictVars = undefined;
|
verdictVars = undefined;
|
||||||
error = "";
|
displayError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
error = "";
|
displayError = null;
|
||||||
try {
|
try {
|
||||||
source = await decodeFile(file);
|
const decoded = await decodeFile(file);
|
||||||
|
source = decoded;
|
||||||
|
if (schema) {
|
||||||
|
values = applySourceDefaults(schema, values, { source: decoded });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
displayError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
error = "";
|
displayError = null;
|
||||||
running = true;
|
running = true;
|
||||||
try {
|
try {
|
||||||
const out = await execute(tool, {
|
const out = await execute(tool, {
|
||||||
params: sanitizeSchemaParams(schema, values),
|
params: sanitizeSchemaParams(schema, values, {
|
||||||
|
source: source ?? undefined,
|
||||||
|
}),
|
||||||
source: source ?? undefined,
|
source: source ?? undefined,
|
||||||
text: textSource || undefined,
|
text: textSource || undefined,
|
||||||
});
|
});
|
||||||
@@ -108,7 +128,7 @@
|
|||||||
fileResult = null;
|
fileResult = null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
displayError = toDisplayError(e);
|
||||||
} finally {
|
} finally {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
@@ -116,6 +136,9 @@
|
|||||||
|
|
||||||
async function download() {
|
async function download() {
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
|
running = true;
|
||||||
|
displayError = null;
|
||||||
|
try {
|
||||||
if (resultKind === "files") {
|
if (resultKind === "files") {
|
||||||
if (!fileResult) return;
|
if (!fileResult) return;
|
||||||
await downloadZip(fileResult.files, `${tool.id}.zip`);
|
await downloadZip(fileResult.files, `${tool.id}.zip`);
|
||||||
@@ -127,12 +150,12 @@
|
|||||||
? Number(values[out.qualityParamId]) / 100
|
? Number(values[out.qualityParamId]) / 100
|
||||||
: undefined;
|
: undefined;
|
||||||
const blob = await encode(result, out?.mime ?? "image/png", quality);
|
const blob = await encode(result, out?.mime ?? "image/png", quality);
|
||||||
const url = URL.createObjectURL(blob);
|
downloadBlob(blob, `${tool.id}.${out?.ext ?? "png"}`);
|
||||||
const a = document.createElement("a");
|
} catch (e) {
|
||||||
a.href = url;
|
displayError = toDisplayError(e);
|
||||||
a.download = `${tool.id}.${out?.ext ?? "png"}`;
|
} finally {
|
||||||
a.click();
|
running = false;
|
||||||
URL.revokeObjectURL(url);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText() {
|
async function copyText() {
|
||||||
@@ -144,19 +167,18 @@
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(copyValue);
|
await navigator.clipboard.writeText(copyValue);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
displayError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadText() {
|
async function downloadText() {
|
||||||
if (!textResult) return;
|
if (!textResult) return;
|
||||||
|
try {
|
||||||
const blob = new Blob([textResult], { type: "text/plain" });
|
const blob = new Blob([textResult], { type: "text/plain" });
|
||||||
const url = URL.createObjectURL(blob);
|
downloadBlob(blob, `${tool.id}.txt`);
|
||||||
const a = document.createElement("a");
|
} catch (e) {
|
||||||
a.href = url;
|
displayError = toDisplayError(e);
|
||||||
a.download = `${tool.id}.txt`;
|
}
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -164,18 +186,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// TODO: can any edge case start infinite loop?
|
|
||||||
if (!started) return;
|
if (!started) return;
|
||||||
|
if (inputMode === "image" && !source) return;
|
||||||
|
if (inputMode === "text" && !textSource.trim()) return;
|
||||||
void values;
|
void values;
|
||||||
void source;
|
void source;
|
||||||
debouncedRun();
|
debouncedRun();
|
||||||
return () => debouncedRun.cancel();
|
return () => debouncedRun.cancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
function errorText(e: unknown): string {
|
function toDisplayError(e: unknown): DisplayError {
|
||||||
if (e instanceof ToolError) return t(e.key, e.vars);
|
if (e instanceof ToolError) {
|
||||||
if (e instanceof Error) return e.message;
|
return { kind: "i18n", key: e.key, vars: e.vars };
|
||||||
return String(e);
|
}
|
||||||
|
if (e instanceof Error) return { kind: "plain", text: e.message };
|
||||||
|
return { kind: "plain", text: String(e) };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -215,7 +240,7 @@
|
|||||||
{inputMode}
|
{inputMode}
|
||||||
{resultKind}
|
{resultKind}
|
||||||
{running}
|
{running}
|
||||||
{error}
|
error={errorText}
|
||||||
onupload={handleFile}
|
onupload={handleFile}
|
||||||
ontextsource={(textValue) => {
|
ontextsource={(textValue) => {
|
||||||
textSource = textValue;
|
textSource = textValue;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<output>{current}</output>
|
<output>{current}</output>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
|
aria-label={label}
|
||||||
type="range"
|
type="range"
|
||||||
min={sp.min ?? 0}
|
min={sp.min ?? 0}
|
||||||
max={sp.max ?? 100}
|
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", () => {
|
describe("orientationOf", () => {
|
||||||
it("определяет ориентацию", () => {
|
it("определяет ориентацию", () => {
|
||||||
expect(
|
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 { describe, expect, it } from "vitest";
|
||||||
import { isSupportedImage, unsupportedImageError } from "./io";
|
import {
|
||||||
|
isSupportedImage,
|
||||||
|
unsupportedImageError,
|
||||||
|
validateOutputQuality,
|
||||||
|
} from "./io";
|
||||||
|
|
||||||
describe("isSupportedImage", () => {
|
describe("isSupportedImage", () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -38,3 +42,33 @@ describe("unsupportedImageError", () => {
|
|||||||
expect(err.vars?.type).toBe("unknown");
|
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);
|
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(
|
export async function encode(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
mime: OutputMime = "image/png",
|
mime: OutputMime = "image/png",
|
||||||
@@ -92,11 +105,7 @@ export async function encode(
|
|||||||
if (mime === "image/bmp") {
|
if (mime === "image/bmp") {
|
||||||
return new Blob([encodeBmpBytes(img)], { type: mime });
|
return new Blob([encodeBmpBytes(img)], { type: mime });
|
||||||
}
|
}
|
||||||
if (mime === "image/jpeg" || mime === "image/webp") {
|
validateOutputQuality(mime, quality);
|
||||||
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
|
||||||
throw new ToolError("errors.qualityRange");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = img.width;
|
canvas.width = img.width;
|
||||||
canvas.height = img.height;
|
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 { ToolError } from "../core/errors";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "../core/types";
|
||||||
import type { ToolContext, ToolEntry, ToolResult } from "$lib/registry";
|
import type { ToolContext, ToolEntry, ToolResult } from "../registry";
|
||||||
import { sanitizeSchemaParams } from "$lib/registry-schema";
|
import { sanitizeSchemaParams } from "../registry-schema";
|
||||||
|
import {
|
||||||
|
decodeWorkerResponse,
|
||||||
|
isWorkerResponse,
|
||||||
|
type WorkerRequest,
|
||||||
|
type WorkerResponse,
|
||||||
|
} from "./protocol";
|
||||||
|
|
||||||
/** Вход единой точки исполнения. `source`/`text` — по контракту `tool.input`. */
|
|
||||||
export type ExecuteContext = {
|
export type ExecuteContext = {
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
source?: PixelImage;
|
source?: PixelImage;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type WorkerLike = {
|
||||||
* Единственная точка исполнения инструмента. Валидирует наличие входа по
|
postMessage(message: WorkerRequest): void;
|
||||||
* `tool.input`, санитит параметры и запускает `tool.run`. Выбор места
|
terminate(): void;
|
||||||
* исполнения (worker/поток) — внутренняя забота executor'а, диспетча методов
|
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null;
|
||||||
* нет: у любого инструмента один `run(ctx)`.
|
onerror: ((event: Event) => void) | null;
|
||||||
*/
|
};
|
||||||
export async function execute(
|
|
||||||
tool: ToolEntry,
|
export type WorkerFactory = () => WorkerLike;
|
||||||
ctx: ExecuteContext,
|
|
||||||
): Promise<ToolResult> {
|
type PendingRequest = {
|
||||||
const params = sanitizeSchemaParams(tool.schema, ctx.params);
|
resolve: (result: ToolResult) => void;
|
||||||
if (tool.input === "image" && !ctx.source) {
|
reject: (error: unknown) => void;
|
||||||
throw new ToolError("errors.sourceRequired");
|
};
|
||||||
}
|
|
||||||
if (tool.input === "text" && !ctx.text?.trim()) {
|
function defaultWorkerFactory(): WorkerLike {
|
||||||
throw new ToolError("errors.textRequired");
|
return new Worker(new URL("./executor.worker.ts", import.meta.url), {
|
||||||
}
|
type: "module",
|
||||||
return route(tool, { params, source: ctx.source, text: ctx.text });
|
}) as unknown as WorkerLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function createExecutor(
|
||||||
* Маршрутизация по способу исполнения. В worker уходит всё, кроме
|
options: { workerFactory?: WorkerFactory } = {},
|
||||||
* domOnly-инструментов: даже текстовые вердикты могут быть тяжёлыми
|
): (tool: ToolEntry, ctx: ExecuteContext) => Promise<ToolResult> {
|
||||||
* (гистограммы, сложный анализ), поэтому ограничивать по типу результата
|
const workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||||
* неверно. Ограничение одно — доступ к DOM, которого в worker нет.
|
let worker: WorkerLike | null = null;
|
||||||
*/
|
let workerTried = false;
|
||||||
async function route(
|
let nextRequestId = 1;
|
||||||
tool: ToolEntry,
|
const pending = new Map<number, PendingRequest>();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDirect(
|
function failWorker(candidate: WorkerLike, error: unknown): void {
|
||||||
tool: ToolEntry,
|
if (worker === candidate) worker = null;
|
||||||
ctx: ExecuteContext,
|
candidate.terminate();
|
||||||
): Promise<ToolResult> {
|
for (const [id, entry] of pending) {
|
||||||
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
|
pending.delete(id);
|
||||||
}
|
entry.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let worker: Worker | null = null;
|
function ensureWorker(): WorkerLike | null {
|
||||||
let workerTried = false;
|
|
||||||
let nextRequestId = 1;
|
|
||||||
const pending = new Map<
|
|
||||||
number,
|
|
||||||
{ resolve: (result: ToolResult) => void; reject: (e: Error) => void }
|
|
||||||
>();
|
|
||||||
|
|
||||||
function ensureWorker(): Worker | null {
|
|
||||||
if (workerTried) return worker;
|
if (workerTried) return worker;
|
||||||
workerTried = true;
|
workerTried = true;
|
||||||
|
let candidate: WorkerLike | undefined;
|
||||||
try {
|
try {
|
||||||
const candidate = new Worker(
|
candidate = workerFactory();
|
||||||
new URL("./executor.worker.ts", import.meta.url),
|
candidate.onmessage = (event) => {
|
||||||
{
|
const payload: unknown = event.data;
|
||||||
type: "module",
|
if (!isWorkerResponse(payload)) {
|
||||||
},
|
failWorker(
|
||||||
|
candidate as WorkerLike,
|
||||||
|
new Error("Invalid worker response"),
|
||||||
);
|
);
|
||||||
candidate.onmessage = (event: MessageEvent) => {
|
return;
|
||||||
const payload = event.data as {
|
}
|
||||||
id: number;
|
|
||||||
ok: boolean;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
data?: Uint8ClampedArray;
|
|
||||||
files?: {
|
|
||||||
name: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
data: Uint8ClampedArray;
|
|
||||||
}[];
|
|
||||||
text?: string;
|
|
||||||
textVars?: Record<string, string | number>;
|
|
||||||
error?: string;
|
|
||||||
errorKey?: string;
|
|
||||||
errorVars?: Record<string, string | number>;
|
|
||||||
};
|
|
||||||
const entry = pending.get(payload.id);
|
const entry = pending.get(payload.id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
try {
|
||||||
|
const result = decodeWorkerResponse(payload);
|
||||||
pending.delete(payload.id);
|
pending.delete(payload.id);
|
||||||
if (payload.ok && payload.data && payload.width && payload.height) {
|
entry.resolve(result);
|
||||||
entry.resolve({
|
} catch (error) {
|
||||||
width: payload.width,
|
if (error instanceof ToolError) {
|
||||||
height: payload.height,
|
pending.delete(payload.id);
|
||||||
data: new Uint8ClampedArray(payload.data),
|
entry.reject(error);
|
||||||
});
|
|
||||||
} else if (payload.ok && Array.isArray(payload.files)) {
|
|
||||||
entry.resolve({
|
|
||||||
files: payload.files.map((file) => ({
|
|
||||||
name: file.name,
|
|
||||||
image: {
|
|
||||||
width: file.width,
|
|
||||||
height: file.height,
|
|
||||||
data: new Uint8ClampedArray(file.data),
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
} else if (payload.ok && typeof payload.text === "string") {
|
|
||||||
entry.resolve(
|
|
||||||
payload.textVars
|
|
||||||
? { key: payload.text, vars: payload.textVars }
|
|
||||||
: payload.text,
|
|
||||||
);
|
|
||||||
} else if (payload.errorKey) {
|
|
||||||
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
|
||||||
} else {
|
} else {
|
||||||
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
|
failWorker(candidate as WorkerLike, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
candidate.onerror = () => {
|
candidate.onerror = () => {
|
||||||
worker = null;
|
failWorker(candidate as WorkerLike, new Error("Worker error"));
|
||||||
for (const entry of pending.values()) {
|
|
||||||
entry.reject(new Error("errors.workerUnavailable"));
|
|
||||||
}
|
|
||||||
pending.clear();
|
|
||||||
};
|
};
|
||||||
worker = candidate;
|
worker = candidate;
|
||||||
} catch {
|
return candidate;
|
||||||
|
} catch (error) {
|
||||||
|
candidate?.terminate();
|
||||||
worker = null;
|
worker = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return worker;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableWorker(): void {
|
function disableWorker(candidate: WorkerLike): void {
|
||||||
if (worker) {
|
if (worker !== candidate) return;
|
||||||
worker.terminate();
|
|
||||||
}
|
|
||||||
worker = null;
|
worker = null;
|
||||||
}
|
candidate.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
function runInWorker(
|
async function runDirect(
|
||||||
workerInstance: Worker,
|
|
||||||
tool: ToolEntry,
|
tool: ToolEntry,
|
||||||
ctx: ExecuteContext,
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = nextRequestId++;
|
const id = nextRequestId++;
|
||||||
pending.set(id, { resolve, reject });
|
pending.set(id, { resolve, reject });
|
||||||
workerInstance.postMessage({
|
try {
|
||||||
|
candidate.postMessage({
|
||||||
id,
|
id,
|
||||||
toolId: tool.id,
|
toolId: tool.id,
|
||||||
params: ctx.params,
|
params: ctx.params,
|
||||||
@@ -174,5 +130,40 @@ function runInWorker(
|
|||||||
: undefined,
|
: undefined,
|
||||||
text: ctx.text,
|
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" />
|
/// <reference lib="webworker" />
|
||||||
import { ToolError } from "$lib/core/errors";
|
import { handleWorkerRequest } from "./worker-handler";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { WorkerRequest, WorkerResponse } from "./protocol";
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||||
void handle(event.data);
|
void respond(event.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handle(request: WorkerRequest): Promise<void> {
|
async function respond(request: WorkerRequest): Promise<void> {
|
||||||
try {
|
const result = await handleWorkerRequest(request);
|
||||||
const tool = getTool(request.toolId);
|
(self as unknown as Worker).postMessage(result.response, result.transfer);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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");
|
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("неизвестный путь возвращает сам путь", () => {
|
it("неизвестный путь возвращает сам путь", () => {
|
||||||
expect(t("no.such.key")).toBe("no.such.key");
|
expect(t("no.such.key")).toBe("no.such.key");
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-53
@@ -724,9 +724,6 @@ export const ru: Dict = {
|
|||||||
toColor: "Цвет конца",
|
toColor: "Цвет конца",
|
||||||
direction: "Направление",
|
direction: "Направление",
|
||||||
},
|
},
|
||||||
options: {
|
|
||||||
direction: { horizontal: "По горизонтали", vertical: "По вертикали" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"color-wheel-png": {
|
"color-wheel-png": {
|
||||||
title: "Цветовой круг PNG",
|
title: "Цветовой круг PNG",
|
||||||
@@ -1051,13 +1048,6 @@ export const ru: Dict = {
|
|||||||
backgroundColor: "Цвет фона",
|
backgroundColor: "Цвет фона",
|
||||||
padding: "Отступ, px",
|
padding: "Отступ, px",
|
||||||
},
|
},
|
||||||
options: {
|
|
||||||
font: {
|
|
||||||
sans: "Без засечек",
|
|
||||||
serif: "С засечками",
|
|
||||||
mono: "Моноширинный",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"emoji-to-png": {
|
"emoji-to-png": {
|
||||||
title: "Эмодзи в PNG",
|
title: "Эмодзи в PNG",
|
||||||
@@ -1135,8 +1125,11 @@ export const ru: Dict = {
|
|||||||
"8": "8",
|
"8": "8",
|
||||||
"16": "16",
|
"16": "16",
|
||||||
"32": "32",
|
"32": "32",
|
||||||
|
"44": "44 (сильный)",
|
||||||
"64": "64",
|
"64": "64",
|
||||||
|
"96": "96 (сбалансированный)",
|
||||||
"128": "128",
|
"128": "128",
|
||||||
|
"192": "192 (лёгкий)",
|
||||||
"256": "256",
|
"256": "256",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1269,13 +1262,6 @@ export const ru: Dict = {
|
|||||||
font: "Шрифт",
|
font: "Шрифт",
|
||||||
bold: "Жирный",
|
bold: "Жирный",
|
||||||
},
|
},
|
||||||
options: {
|
|
||||||
font: {
|
|
||||||
sans: "Без засечек",
|
|
||||||
serif: "С засечками",
|
|
||||||
mono: "Моноширинный",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"add-text-png": {
|
"add-text-png": {
|
||||||
title: "Надпись на PNG",
|
title: "Надпись на PNG",
|
||||||
@@ -1293,24 +1279,6 @@ export const ru: Dict = {
|
|||||||
plateColor: "Цвет подложки",
|
plateColor: "Цвет подложки",
|
||||||
plateOpacity: "Прозрачность плашки, %",
|
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": {
|
"date-stamp-png": {
|
||||||
title: "Дата-штамп PNG",
|
title: "Дата-штамп PNG",
|
||||||
@@ -1328,24 +1296,6 @@ export const ru: Dict = {
|
|||||||
plateColor: "Цвет подложки",
|
plateColor: "Цвет подложки",
|
||||||
plateOpacity: "Прозрачность плашки, %",
|
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": {
|
"skew-png": {
|
||||||
title: "Наклонить PNG",
|
title: "Наклонить PNG",
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { TOOLS } from "../registry";
|
import { TOOLS } from "../registry";
|
||||||
import { setLocale } from "./locale.svelte";
|
import { setLocale } from "./locale.svelte";
|
||||||
import { searchTools, verdictText } from "./schema-tool-strings";
|
import {
|
||||||
|
searchTools,
|
||||||
|
toolDescription,
|
||||||
|
toolTitle,
|
||||||
|
verdictText,
|
||||||
|
} from "./schema-tool-strings";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setLocale("ru");
|
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)", () => {
|
describe("verdictText (вердикт с vars)", () => {
|
||||||
it("интерполирует {vars} в обоих локалях", () => {
|
it("интерполирует {vars} в обоих локалях", () => {
|
||||||
setLocale("ru");
|
setLocale("ru");
|
||||||
@@ -60,4 +77,33 @@ describe("verdictText (вердикт с vars)", () => {
|
|||||||
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
|
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
|
||||||
expect(verdictText("png-file-size", "нет-такого")).toBe("нет-такого");
|
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 };
|
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", () => {
|
describe("полнота словарей для нового registry", () => {
|
||||||
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
|
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
|
||||||
for (const tool of TOOLS) {
|
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("в словарях нет лишних инструментов", () => {
|
it("в словарях нет лишних инструментов", () => {
|
||||||
const ids = new Set(TOOLS.map((tool) => tool.id));
|
const ids = new Set(TOOLS.map((tool) => tool.id));
|
||||||
for (const dict of [ru.tools, en.tools]) {
|
for (const dict of [ru.tools, en.tools]) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
applySourceDefaults,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
field,
|
field,
|
||||||
|
resolveLayoutGroups,
|
||||||
sanitizeSchemaParams,
|
sanitizeSchemaParams,
|
||||||
toolSchema,
|
toolSchema,
|
||||||
|
type Dimension,
|
||||||
} from "./registry-schema";
|
} from "./registry-schema";
|
||||||
|
|
||||||
interface FrameParams {
|
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: дефолты из схемы", () => {
|
describe("registry-schema: дефолты из схемы", () => {
|
||||||
it("собирает дефолты всех полей", () => {
|
it("собирает дефолты всех полей", () => {
|
||||||
expect(defaultSchemaParams(frameSchema)).toEqual({
|
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)", () => {
|
describe("registry-schema: раскладка (schema.layout)", () => {
|
||||||
it("toolSchema сохраняет группы полей", () => {
|
it("toolSchema сохраняет группы полей", () => {
|
||||||
expect(frameSchema.layout).toEqual({
|
expect(frameSchema.layout).toEqual({
|
||||||
@@ -65,6 +131,82 @@ describe("registry-schema: раскладка (schema.layout)", () => {
|
|||||||
});
|
});
|
||||||
expect(plain.layout).toBeUndefined();
|
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: санитайз", () => {
|
describe("registry-schema: санитайз", () => {
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
// Новая типизированная схема параметров (для нового UI, старый UI не трогаем).
|
// Типизированная схема параметров для нового UI.
|
||||||
//
|
|
||||||
// Параллельный источник правды к старому `ParamDef[]`: старый pipeline
|
|
||||||
// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/
|
|
||||||
// валидацию поверх `ToolSchema<P>`.
|
|
||||||
//
|
//
|
||||||
// Связка типов на этапе компиляции (защита от рассинхрона):
|
// Связка типов на этапе компиляции (защита от рассинхрона):
|
||||||
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
|
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
|
||||||
@@ -73,6 +69,11 @@ export interface DimensionSpec extends FieldSpecBase {
|
|||||||
max: number;
|
max: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
defaultFromSource?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaContext {
|
||||||
|
source?: Dimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
|
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
|
||||||
@@ -274,6 +275,7 @@ export const field = {
|
|||||||
max: number;
|
max: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
defaultFromSource?: boolean;
|
||||||
}): Field<Dimension> => ({
|
}): Field<Dimension> => ({
|
||||||
spec: { kind: "dimension", ...s },
|
spec: { kind: "dimension", ...s },
|
||||||
}),
|
}),
|
||||||
@@ -339,21 +341,68 @@ export function toolSchema<P>(
|
|||||||
return { fields, ...(meta ?? {}) };
|
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 {
|
function clamp(value: number, min?: number, max?: number): number {
|
||||||
if (min !== undefined && value < min) return min;
|
if (min !== undefined && value < min) return min;
|
||||||
if (max !== undefined && value > max) return max;
|
if (max !== undefined && value > max) return max;
|
||||||
return value;
|
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. */
|
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
|
||||||
export function defaultSchemaParams<P>(
|
export function defaultSchemaParams<P>(
|
||||||
schema: ToolSchema<P>,
|
schema: ToolSchema<P>,
|
||||||
|
context?: SchemaContext,
|
||||||
): Record<keyof P, unknown> {
|
): Record<keyof P, unknown> {
|
||||||
const out = {} as Record<keyof P, unknown>;
|
const out = {} as Record<keyof P, unknown>;
|
||||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||||
const spec = schema.fields[key].spec;
|
const spec = schema.fields[key].spec;
|
||||||
if (spec.kind === "dimension") {
|
if (spec.kind === "dimension") {
|
||||||
out[key] = { width: spec.width, height: spec.height };
|
out[key] = resolveDimensionDefaults(spec, context);
|
||||||
} else if (spec.kind === "color-pair") {
|
} else if (spec.kind === "color-pair") {
|
||||||
out[key] = { from: spec.from, to: spec.to };
|
out[key] = { from: spec.from, to: spec.to };
|
||||||
} else if (spec.kind === "colors") {
|
} else if (spec.kind === "colors") {
|
||||||
@@ -386,10 +435,27 @@ export function defaultSchemaParams<P>(
|
|||||||
return out;
|
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`. */
|
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
|
||||||
export function sanitizeSchemaParams<P>(
|
export function sanitizeSchemaParams<P>(
|
||||||
schema: ToolSchema<P>,
|
schema: ToolSchema<P>,
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
|
context?: SchemaContext,
|
||||||
): Record<keyof P, unknown> {
|
): Record<keyof P, unknown> {
|
||||||
const out = {} as Record<keyof P, unknown>;
|
const out = {} as Record<keyof P, unknown>;
|
||||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||||
@@ -429,17 +495,22 @@ export function sanitizeSchemaParams<P>(
|
|||||||
"height" in raw
|
"height" in raw
|
||||||
? (raw as Record<string, unknown>)
|
? (raw as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const defaults = resolveDimensionDefaults(spec, context);
|
||||||
const w =
|
const w =
|
||||||
typeof r?.width === "number" && Number.isFinite(r.width)
|
typeof r?.width === "number" && Number.isFinite(r.width)
|
||||||
? r.width
|
? r.width
|
||||||
: spec.width;
|
: defaults.width;
|
||||||
const h =
|
const h =
|
||||||
typeof r?.height === "number" && Number.isFinite(r.height)
|
typeof r?.height === "number" && Number.isFinite(r.height)
|
||||||
? 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] = {
|
out[key] = {
|
||||||
width: clamp(w, spec.min, spec.max),
|
width: clamp(sourceSize.width, spec.min, spec.max),
|
||||||
height: clamp(h, spec.min, spec.max),
|
height: clamp(sourceSize.height, spec.min, spec.max),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const pngToBase64: ToolEntry<NoParams> = {
|
|||||||
schema: emptySchema,
|
schema: emptySchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
result: "text",
|
result: "text",
|
||||||
|
domOnly: true,
|
||||||
run: imgTool((img) => toBase64(img)),
|
run: imgTool((img) => toBase64(img)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ const pngToDataUri: ToolEntry<NoParams> = {
|
|||||||
schema: emptySchema,
|
schema: emptySchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
result: "text",
|
result: "text",
|
||||||
|
domOnly: true,
|
||||||
run: imgTool((img) => toDataUrl(img)),
|
run: imgTool((img) => toDataUrl(img)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,6 +165,7 @@ const base64ToPng: ToolEntry<NoParams> = {
|
|||||||
category: "convert",
|
category: "convert",
|
||||||
schema: emptySchema,
|
schema: emptySchema,
|
||||||
input: "text",
|
input: "text",
|
||||||
|
domOnly: true,
|
||||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +176,7 @@ const dataUriToPng: ToolEntry<NoParams> = {
|
|||||||
category: "convert",
|
category: "convert",
|
||||||
schema: emptySchema,
|
schema: emptySchema,
|
||||||
input: "text",
|
input: "text",
|
||||||
|
domOnly: true,
|
||||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
|
|||||||
category: "filters",
|
category: "filters",
|
||||||
schema: jpegArtifactsSchema,
|
schema: jpegArtifactsSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
|
domOnly: true,
|
||||||
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
|
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ const placeholder: ToolEntry<PlaceholderParams> = {
|
|||||||
category: "generate",
|
category: "generate",
|
||||||
schema: placeholderSchema,
|
schema: placeholderSchema,
|
||||||
input: "none",
|
input: "none",
|
||||||
|
domOnly: true,
|
||||||
run: genTool((p) => {
|
run: genTool((p) => {
|
||||||
const w = Math.trunc(p.size.width);
|
const w = Math.trunc(p.size.width);
|
||||||
const h = Math.trunc(p.size.height);
|
const h = Math.trunc(p.size.height);
|
||||||
|
|||||||
@@ -194,8 +194,9 @@ export const resizeSchema = toolSchema<ResizeParams>(
|
|||||||
label: "fields.canvasSize",
|
label: "fields.canvasSize",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 20000,
|
max: 20000,
|
||||||
width: 0,
|
width: 1,
|
||||||
height: 0,
|
height: 1,
|
||||||
|
defaultFromSource: true,
|
||||||
}),
|
}),
|
||||||
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
|
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
|
||||||
},
|
},
|
||||||
@@ -263,10 +264,11 @@ export const cropSchema = toolSchema<CropParams>(
|
|||||||
}),
|
}),
|
||||||
size: field.dimension({
|
size: field.dimension({
|
||||||
label: "fields.cropAreaSize",
|
label: "fields.cropAreaSize",
|
||||||
min: 0,
|
min: 1,
|
||||||
max: 2000,
|
max: 2000,
|
||||||
width: 0,
|
width: 1,
|
||||||
height: 0,
|
height: 1,
|
||||||
|
defaultFromSource: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { TOOLS } from ".";
|
import { TOOLS } from ".";
|
||||||
import { PREVIEW_GROUPS } from "../catalog";
|
import { PREVIEW_GROUPS } from "../catalog";
|
||||||
import type { PixelImage } from "../core/types";
|
import type { PixelImage } from "../core/types";
|
||||||
|
import type { OutputMime } from "../core/io";
|
||||||
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
||||||
import type { FileResult, ToolResult } from "./types";
|
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-дефолты корректны", () => {
|
it("create-empty: dimension-дефолты корректны", () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
||||||
const d = defaultSchemaParams(tool.schema);
|
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