Compare commits

..
1 Commits
Author SHA1 Message Date
Ku6epXBOCTuK 299586fa87 feat: add redirect html 2026-10-13 08:05:16 +05:00
301 changed files with 64 additions and 37346 deletions
-95
View File
@@ -1,95 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install root dependencies
run: pnpm install --frozen-lockfile
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run quality gate
run: pnpm verify
build:
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
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: Configure Pages
uses: actions/configure-pages@v5
- name: Install dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Build
working-directory: web
run: pnpm build
env:
BASE_PATH: /easy-png-tools
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: web/build
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
-80
View File
@@ -1,80 +0,0 @@
name: Quality
on:
push:
branches:
- main
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
-9
View File
@@ -1,9 +0,0 @@
node_modules
# Отчёты разовых аудитов и артефакты прогонов
web/audit/cdp-audit*.txt
css-lint.txt
web/coverage
# Локальные планы с приватной информацией (выбранное имя домена) — не коммитить
docs/plan-domain.local.md
-7
View File
@@ -1,7 +0,0 @@
# Точечные исключения, без allowlist-`/*`: корневой `/*` глушил формат-он-сейв
# (весь `web/**` резолвился как ignored, дочерние конфиги не успевали примениться).
# Дочерние каталоги управляются своими конфигами/игнорами (web/, refs/).
node_modules
.git
refs-html
pnpm-lock.yaml
-3
View File
@@ -1,3 +0,0 @@
{
"proseWrap": "always"
}
-103
View File
@@ -1,103 +0,0 @@
# AGENTS.md
Краткий обязательный контракт. Правило: **у каждой темы ровно один источник
истины**; здесь только то, что агент обязан знать до чтения остального, и то,
что линтер не проверяет. Подробности — по ссылкам ниже.
## Стек и команды
- **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes).
- **pnpm** обязателен, npm не использовать. Команды — из корня (`pnpm build`,
`pnpm dev`, `pnpm verify`) или из `web/` с префиксом `pnpm --dir web`.
| Что | Команда |
| ----------------------- | ------------------------------------------------- |
| Полный быстрый gate | `pnpm verify` |
| Типы | `pnpm --dir web check` |
| Юнит-тесты | `pnpm --dir web test` |
| Тесты lint-правил | `pnpm --dir web test:rules` |
| Линтинг | `pnpm --dir web lint:all` |
| Только токены | `pnpm --dir web lint:tokens` |
| E2E | `pnpm --dir web test:e2e` |
| Форматирование | `pnpm format` (код + docs) |
| Проверка форматирования | `pnpm check:docs` + `pnpm --dir web format:check` |
## Что читать под задачу
Читать только нужную строку, а не все документы подряд.
| Задача | Документ |
| --------------------------------------------- | ---------------------------------- |
| Слои, поток данных, новый инструмент/поле | `docs/architecture.md` |
| Какие проверки запускать, пороги, CI | `docs/quality-gates.md` |
| Правка самих гейтов: «почему так, а не иначе» | `docs/decisions.md` |
| Порядок работы, нагрузка на машину | `docs/agent-workflow.md` |
| Locator-ы, fixtures, browser matrix | `docs/testing-strategy.md` |
| Детали lint-правил и токенов | `web/eslint-plugins/README.md` |
| Задачи и идеи | `docs/backlog.md` |
| Актуальный список инструментов | `docs/tools-map.md` |
| Правка полей и раскладки по волнам | `docs/plan-fields-audit-fixes.md` |
| Ручная приёмка UI | `docs/checklist-manual-testing.md` |
| Индекс остальных документов | `docs/README.md` |
## Инварианты, которые линтер не проверяет
- **Сначала искать.** Новая константа, тип или утилита не заводится, пока не
найдено существующее определение — по ключу в соседних модулях и по всему
`web/src/`.
- **Комментарий объясняет «почему»**, а не пересказывает код. `TODO`/`FIXME` —
только для зарегистрированной задачи: ссылка на `docs/backlog.md` и критерий
закрытия.
- **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n,
UI и рефакторинг.
- **Не коммитить.** Коммит и пуш делает разработчик после ревью.
- **Не запускать тяжёлое без спроса.** Playwright — только по явной команде
пользователя, один прогон, без циклов повторов и фонового запуска.
- **Не писать изменяемые числа** (количество инструментов, тестов, локалей,
проценты покрытия): вместо числа — ссылка на источник или команда. Число
допустимо, только если его проверяет конфиг или тест. Обоснование и границы
автоматизации — `docs/decisions.md`, раздел 3.
- **Результат CI смотреть по отдельным job**: у `e2e` стоит `continue-on-error`,
поэтому общий conclusion может быть зелёным при красном e2e
(`docs/decisions.md`, раздел 10).
## Границы изменений
- Production-UI — `web/src/routes/**` и `web/src/lib/components/**`.
- `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts` — общие модули: их
правка проверяется unit-тестами и smoke-сценариями production-потребителей.
- Направление зависимостей, точки расширения и рецепт правки (инструмент, вид
поля, маршрут, токен) — `docs/architecture.md`, разделы 1–2, 7, 8.
## Что проверяется линтером
`pnpm --dir web lint:all` — ESLint с `--max-warnings=0` (warn-only правила тоже
блокируют gate), Stylelint и token audit. Синтаксические ограничения здесь не
дублируются: полный список правил и префиксы токенов —
`web/eslint-plugins/README.md`; новое правило сначала появляется в warn-only
режиме с fixtures и тестами (`test:rules`).
- **E2E линтером не покрыт:** порядок locator-ов, допустимые `data-testid`,
`test.fixme` — `docs/testing-strategy.md`.
## Дизайн
Источник правды по визуальному языку — код: `web/src/app.css` (дизайн-токены и
глобальные стили), `web/src/lib/components/**` и витрина `/kit`. Новый дизайн
живёт в `app.css` и применяется ко всем production-маршрутам; повторяющийся
визуальный элемент — отдельный компонент, даже «просто div с двумя стилями»;
дизайн-токены — только из CSS-переменных, новый токен появляется вместе с
потребителем (`pnpm --dir web lint:tokens` сообщает о неиспользуемых). Приёмка —
`docs/checklist-manual-testing.md`.
## Документация
- Новый документ создаётся, только если темы нет в существующем; иначе
обновляется источник истины. У документа есть статус (`draft`, `active`,
`completed`, `archived`).
- Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и
ссылка на канонический документ.
- **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для
проекта факты переносятся в тематический документ. Завершённые планы и
исследования — в `docs/archive/`, удаляются через 30 дней; процедура в
`docs/archive/README.md`.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Ku6epXBOCTuK
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4 -96
View File
@@ -1,99 +1,7 @@
# easy-png-tools
# Project renamed
Browser-based image utility toolkit — no server, no data upload, everything runs
locally in your browser.
"Easy png tools" now named "Easy image tools" and moved to a new domain.
Inspired by online services like onlinepngtools.com, but fully offline-capable
and open source.
[New repository link](https://github.com/Ku6epXBOCTuK/easy-image-tools)
## Features
- **A full set of image tools** across categories: conversion,
transparency/alpha, color, channel decomposition, geometry, filters,
pixel-property masks, generation, text/watermarks, analysis & verdicts — the
current list lives in [`docs/tools-map.md`](docs/tools-map.md)
- **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
- **Dark/Light themes** — persisted to localStorage
- **Pure TypeScript core** — `lib/core/` operates on
`ImageData`/`Uint8ClampedArray` with no DOM dependency
- **Fully static** — no backend, no analytics, no tracking
## Tech Stack
- **SvelteKit** (Svelte 5, runes mode) with static adapter — pure static export,
no server
- **Vite** + **TypeScript** (strict)
- **Vitest** for unit tests, **Playwright** for e2e
- **ESLint** (incl. local plugins) + Stylelint + Prettier
- **pnpm** (required, ^11.20.0)
- **Lucide** icons, **IBM Plex** fonts (self-hosted)
## Getting Started
### Prerequisites
- [pnpm](https://pnpm.io/) v11.20+
### Install & Run
```bash
pnpm install
pnpm dev
```
### Build
```bash
pnpm build
```
Static output is written to `web/build/`.
### Commands
Run from the repository root; `pnpm --dir web …` targets the app package.
```bash
pnpm install
pnpm dev # dev server
pnpm verify # full fast quality gate — the pre-review gate
pnpm build # static production export → web/build/
```
Полный список команд и матрица «какие проверки запускать при каком изменении» —
в `AGENTS.md` и `docs/quality-gates.md`.
## Architecture
Everything is built around the **two-layer registry** (`web/src/lib/registry/`):
a tool is a self-contained implementation `{id, schema, input, result, run}`
(`registry/tools/`), and a page is `{slug, title, description, category, steps}`
(`registry/pages/`) that names the address and the tools to run — one tool can
back several pages. Pages, forms, and pipelines are generated from it, and all
execution goes through a single `executor.execute` entry point.
Core image processing lives in `web/src/lib/core/` and operates directly on
`ImageData`, with no browser API dependencies.
Layering, data flow, and the recipe for adding a tool or a field kind:
[`docs/architecture.md`](docs/architecture.md).
## Roadmap
See [`docs/roadmap.md`](docs/roadmap.md) for phases and
[`docs/plan-platform.md`](docs/plan-platform.md) for the API/PWA direction. The
current focus is SEO/GEO ([`docs/plan-seo.md`](docs/plan-seo.md)) plus the
product tasks in [`docs/backlog.md`](docs/backlog.md).
## For AI agents
Read [`AGENTS.md`](AGENTS.md) first — it is the mandatory short contract.
## License
[MIT](LICENSE)
[New website link](https://easy-image-tools.com)
-70
View File
@@ -1,70 +0,0 @@
# Документация проекта
> Статус: **active**. Индекс документов. Обязательный краткий контракт — в
> `AGENTS.md`, подробные правила — в самих документах.
## Порядок чтения
1. `AGENTS.md` — команды, инварианты и карта «что читать под задачу».
2. Этот файл — выбрать актуальный документ по теме.
3. Соответствующий план или checklist темы.
4. Исходники, конфигурация и тесты рядом с изменяемым кодом.
## Активные документы
| Документ | Когда читать | Статус |
| ---------------------------------- | -------------------------------------------------- | ------ |
| `AGENTS.md` | Всегда: контракт агента | active |
| `docs/architecture.md` | Слои, поток данных, новый инструмент/поле/маршрут | active |
| `docs/quality-gates.md` | Какие проверки запускать, пороги, coverage, CI | active |
| `docs/decisions.md` | Правка самих гейтов; чтение CI после пуша | active |
| `docs/agent-workflow.md` | Порядок работы, нагрузка на машину, протокол ревью | active |
| `docs/testing-strategy.md` | Правка e2e: locator-ы, fixtures, browser matrix | active |
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
| `docs/roadmap.md` | Направления развития, фазы 1–11 | active |
| `docs/tools-map.md` | Актуальный список инструментов против реестра | active |
| `docs/tools-audit.md` | Сырые UX/UI-находки по инструментам (вход аудита) | active |
| `docs/plan-fields-audit-fixes.md` | Разбивка аудита на волны правок | draft |
| `docs/plan-seo.md` | SEO/GEO-план | active |
| `docs/plan-platform.md` | API/PWA/шрифты-план | active |
| `docs/plan-domain.local.md` | Чек-лист переименования бренда и покупки домена | active |
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
| `web/eslint-plugins/README.md` | Правка lint-правил, токенов и их конфигов | active |
| `docs/archive/README.md` | Архивирование и удаление старых документов | active |
`docs/plan-domain.local.md` **исключён из git** через `.gitignore`: выбранное
имя домена не должно попасть в историю до покупки. В клоне файла нет — это
ожидаемо, и ссылки на него в `plan-seo.md` и `backlog.md` не разрешаются. Строка
в таблице оставлена, чтобы задачу R1/R2 можно было найти по индексу; сам
чек-лист лежит только на машине разработчика.
## Правила
- У каждой темы ровно один владелец: остальные документы ссылаются на него, а не
пересказывают. Новый документ создаётся, только если темы нет в существующем.
- У документа есть статус (`draft`, `active`, `completed`, `archived`) и
понятная область применения. Ссылки на конкретный файл или команду проверяются
при изменении источника истины.
- Подробные материалы живут в `docs/`, а не в корне. Не создавать отдельные
`docs/commands.md` или `docs/known-issues.md`: команды принадлежат
`quality-gates`, известные проблемы — backlog и checklist. `README.md` в корне
остаётся кратким обзором продукта, а не вторым руководством агента.
- `docs/backlog.md` содержит только открытые задачи: выполненные удаляются, а
критичные для проекта факты из них переносятся в тематический документ.
- `docs/archive/` — временная историческая справка, не источник критериев
приёмки; срок хранения 30 дней, файлы восстановимы из git history.
Единственный документ в архиве со статусом `active` —
`docs/archive/README.md`: он описывает процедуру архивирования, а не архивный
материал, поэтому не подлежит 30-дневному удалению.
- Ссылаясь на инструмент, сверяться с реестром: внутреннее имя (`id`) — в
`web/src/lib/registry/tools/`, адрес и имя страницы (`slug`) — в
`web/src/lib/registry/pages/`; исторические названия в старых планах не
действуют (`docs/architecture.md`, раздел 3).
- Поиск по `docs/` задевает `docs/archive/` (объём больше, чем у активных
документов вместе). Для правок production-кода архив не нужен: grep по
`docs/*.md` без `archive/`.
- Проверка ссылок в документации не автоматизирована; ожидаемо неразрешимыми
считаются ссылки на `docs/plan-domain.local.md` (файл вне git). Всё остальное
неразрешимое — ошибка.
- Протокол «остановиться и показать изменения» — `docs/agent-workflow.md`,
раздел 6.
-87
View File
@@ -1,87 +0,0 @@
# Workflow для агентов
> Статус: **active**. Порядок исследования, изменения и проверки. Обязательные
> инварианты — в `AGENTS.md`, этот документ не заменяет source of truth
> подсистемы.
## 1. Подготовка к задаче
1. Прочитать `AGENTS.md` и выбрать документ по карте «что читать под задачу».
2. Найти релевантный активный план, backlog или checklist.
3. Проверить `git status`: не перезаписывать чужие незакоммиченные изменения.
4. Определить границу изменения: production UI, shared-модуль или инфраструктура
— `AGENTS.md`, раздел «Границы изменений». Изменение shared-кода требует
проверки production-потребителей.
5. Не добавлять исключения линтера ради отдельных модулей без записи в baseline.
## 2. Порядок изменения
- Сначала исправляются тесты или контракт, который фиксирует неверное поведение,
затем production-код — если это не отдельная продуктовая задача.
- Не начинать следующий этап плана в том же шаге: изменение атомарное, ревью
идёт между шагами (раздел 6).
## 3. Проверки
Матрица «изменение → минимальный набор проверок», состав `pnpm verify`, политика
warnings и чтение CI — в `docs/quality-gates.md`. Здесь только то, что относится
к работе агента.
`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` (обоснование — `docs/decisions.md`, раздел 7).
- При необходимости повторного прогона сначала спрашивается пользователь.
## 4. Работа с тестами
Политика locator-ов, запрещённые зависимости, fixtures, browser matrix и
`test.fixme` — в `docs/testing-strategy.md`. Здесь только рабочая рутина:
- Поведенческий тест проверяет пользовательский контракт, а не текущую
реализацию.
- Спецификация вызывает общие операции из `web/e2e/helpers/page.ts`, а не
повторяет raw-селекторы.
- Не фиксировать абсолютное число инструментов, если проверка не связана с этим
инвариантом.
- Новые fixtures должны быть воспроизводимыми и не требовать ручного создания
больших бинарных файлов.
## 5. Документация
Порядок работы с документами и карта источников истины — `docs/README.md`.
Архивирование — `docs/archive/README.md`.
## 6. Протокол ревью между шагами
После каждого шага плана агент:
1. останавливается и не начинает следующий этап;
2. перечисляет изменённые файлы и принятые решения;
3. указывает выполненные проверки и их фактический результат;
4. отдельно перечисляет warnings, fixme, долги и вопросы;
5. ждёт подтверждения на продолжение.
Коммит и пуш делает разработчик. Следующий шаг начинается только после ревью
текущего изменения; после пуша результат CI читается по процедуре из
`docs/decisions.md`, раздел 10.
-229
View File
@@ -1,229 +0,0 @@
# Архитектура
> Статус: **active**. Описание текущего кода: границы модулей, поток данных от
> реестра до скачивания, точки расширения. Канонический источник истины — сам
> код; этот документ объясняет, **куда смотреть и что с чем связано**, и не
> дублирует сигнатуры.
## 1. Слои
```txt
web/src/lib/
registry/ # источник истины: инструменты и страницы
registry/tools/ # Tool: реализации (id), без адресов и текстов
registry/pages/ # Page: адрес, тексты, категория, список шагов
registry-schema.ts # декларативная схема параметров + санитизация дефолтов
catalog.ts # группировка страниц и счётчики для /list-tools
categories.ts # id/названия категорий
tool-icons.ts # slug страницы → иконка
core/ # чистый TS: операции над ImageData, без DOM
executor/ # единственная точка исполнения инструментов
i18n/ # словари ru/en, t(), списки для i18n-правил линтера
components/ # UI (fields/, layout/, ui/, schema-компоненты)
zip.ts # сборка мультифайлового результата (1 → many)
theme.svelte.ts # light/dark + localStorage
web/src/routes/
+page.svelte # главная
list-tools/ # каталог
tools/[slug]/ # страница инструмента (адрес = slug страницы)
kit/ # витрина компонентов (production, но исключена из i18n-гейта)
```
Правило зависимостей: `components/**` и `routes/**` знают про `registry/**`,
`executor/**` и `core/**`; `core/**` не знает ни о ком и не зависит от DOM;
`registry/**` описывает данные и знает только `core/**` и `registry-schema.ts`.
Нарушение этого порядка — повод не заводить импорт, а инвертировать зависимость.
### Чего в коде нет
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
скачала результат. Отсутствуют:
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
полей двух инструментов — отдельная работа, описанная как фаза 11
`docs/roadmap.md`. Под неё зарезервированы `components/StepCard.svelte`
(сейчас импортируется только витриной `/kit`) и ключи `chain.*`,
`errors.badPipelineShape`, `errors.noSteps`.
- **Разделения страниц, которые делят один инструмент** — сводит 14
convert-страниц на один инструмент; задача в `docs/backlog.md`.
Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по
пайплайну — `docs/roadmap.md`.
## 2. Поток данных
```txt
registry/{tools,pages}/*.ts
│
├─► catalog.ts ──► routes/list-tools (каталог страниц, поиск, фильтры)
│
└─► routes/tools/[slug]/+page.svelte
│ getPageBySlug(slug) → Page, getTool(steps[0].id) → Tool
▼
SchemaToolView.svelte оркестратор страницы
│ schema = tool.schema, тексты и имя файла — от page
├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол)
├─► SchemaSourceTile/SchemaTextSource.svelte вход
└─► SchemaPreview + schema-preview-model.ts вид превью
│ buildSchemaPreviewModel(inputMode, resultKind, …) → source/result/hasResult
▼
executor.execute(ctx) единственная точка исполнения
│ сериализация параметров → worker → worker-handler → tool.run(ctx)
▼
core/* пиксели
│
▼
SchemaResultTile / SchemaTextResult + SchemaActions (download/copy)
│ кодирование по Tool.output (mime/ext/quality)
└─► zip.ts, если result === "files"
```
Ключевые решения:
- **Реестр — единственный источник истины.** Страница и форма строятся из
записей; новый инструмент = новая запись `Tool` + функция `run` и новая
страница `Page` на него.
- **Исполнение идёт только через `executor.execute`.** Компоненты не зовут
`tool.run` напрямую — иначе нельзя перенести тяжёлое в worker/wasm без правки
вызывающего кода.
- **Форма параметров не знает про инструмент.** Она рендерится из
`registry-schema.ts`, поэтому добавление вида поля не трогает страницы.
- **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая
функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод.
## 3. Инструмент и страница
Реестр двухслойный: реализация и то, что видит пользователь, лежат в разных
файлах. `Tool` (`web/src/lib/registry/types.ts`, записи в
`registry/tools/<group>.ts`):
| Поле | Смысл |
| ---------- | ------------------------------------------------------------------------------ |
| `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker |
| `schema` | `toolSchema({ fields, layout?, meta? })` |
| `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе |
| `result` | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
| `domOnly` | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
| `output` | mime/ext/quality для скачивания (не у всех) |
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
`Page` (записи в `registry/pages/<group>.ts`):
| Поле | Смысл |
| ------------- | ---------------------------------------------------------------------- |
| `slug` | уникальный адрес `/tools/<slug>`, имя скачиваемого файла и ключ иконки |
| `title` | EN-строка страницы, переводится через `pages.<slug>.title` |
| `description` | EN-строка страницы, переводится через `pages.<slug>.description` |
| `category` | id из `categories.ts`; по нему каталог группирует страницы |
| `steps` | упорядоченный список шагов `{ id, params? }` — ссылки на инструменты |
Кто по чему адресуется: `slug` — маршрут, имя файла, `TOOL_ICONS`, поиск по
каталогу; `id` — ключ `tools.<id>.*` (подписи полей, `options`, `results`),
`toolId` worker-протокола и запись пайплайна. Один инструмент может обслуживать
несколько страниц, одна страница — состоять из нескольких инструментов.
Инструмент работает с любым поддержанным форматом, поэтому png-интент живёт
только в `slug`: `id: "crop"` → страница `slug: "crop-png"` → `crop-png.png`.
Правило имени: снять суффикс `-png` и в середине имени (`png-to-base64` →
`to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`); следствие —
переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя
файла, ключ `pages.*`, иконку и запись в `docs/tools-map.md`.
Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых
случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS`
и `PAGES` собираются в `registry/index.ts` из восьми групп; `getTool(id)` и
`getPageBySlug(slug)` — способы достать запись, `registry.test.ts` проверяет
уникальность обоих имён, существование инструмента под каждым шагом и то, что
шаг ровно один.
## 4. Схема параметров
`registry-schema.ts` — единственное место, где описаны виды полей.
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
`font-style`, `plate`, `gradient`.
- **Фабрика `field.*`** создаёт `Field<T>`; UI-контрол подбирается в
`SchemaFields.svelte` по `spec.kind` через карту `FIELDS`.
- **`schema.layout`** группирует поля (`resolveLayoutGroups`); раскладка
разделов — единственное место, где живёт порядок и группировка.
- **Значения по умолчанию** даёт `defaultSchemaParams`; `applySourceDefaults`
подставляет размеры текущего source; `sanitizeSchemaParams` приводит мусор к
допустимому виду. Правка поведения полей идёт здесь, а не в компоненте.
Новый вид поля требует: спека + фабрика `field.*` → запись в `fieldSpecs` →
контрол в `components/fields/schema/` → ключ в `FIELDS` → дефолт и санитизация →
i18n-подписи → тест в `registry-schema.test.ts`.
## 5. Исполнение
`executor/executor.ts` — фабрика `createExecutor(factories?)` плюс готовый
экспорт `execute`. Контракт асинхронный (`Promise`), параметры сериализуются,
исполнение уходит в `executor.worker.ts`; `worker-handler.ts` разбирает
сообщение и зовёт `run`. Если worker недоступен — прямой вызов.
Отсюда следует: подключение wasm позже означает замену реализации **внутри**
исполнителя, а не правку компонентов.
## 6. i18n
Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с
`LOCALES`/`BASE_LOCALE`). Пользовательский текст живёт только в словарях и
читается через `t()`. Секции разделены по слоям: `pages.<slug>.title` и
`pages.<slug>.description` — тексты страницы (в базовой локали секция пустая, EN
берётся из реестра), `tools.<id>.options/params/results` — подписи полей и
тексты вердиктов инструмента, `fields.*`/`groups.*` — общие подписи схемы.
Инварианты паритета ключей, плейсхолдеров и запрет хардкода проверяются
линтером, детали — `web/eslint-plugins/README.md`.
## 7. Точки расширения
| Задача | Где править |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) |
| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен |
| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются |
| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `tool-icons.ts` + `docs/tools-map.md` + хардкод slug в тестах и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи |
| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу |
| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов |
| Мультифайловый результат | `result: "files"` + `zip.ts` |
## 8. Рецепт: добавить инструмент
1. **Найти соседей.** Поиск по ключу в `registry/tools/<group>.ts` и по всему
`web/src/` — не заводить дублирующую константу, тип или утилиту.
2. **Выбрать группу** (`geometry`/`alpha`/`color`/`convert`/`filters`/
`generate`/`text`/`analyze`) и положить инструмент в её экспорт; при новой
категории — сначала `categories.ts`.
3. **Задать `id`** по правилу раздела 3: без png-интента (`crop`, `to-jpg`,
`from-base64`, `is-grayscale`).
4. **Описать схему** через `field.*`, задать `layout`, если поля нужно
сгруппировать. Качество кодирования (`quality`) — не поле страницы, а
настройка скачивания (`docs/plan-seo.md` §1, S1f).
5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в
`core/`, без DOM.
6. **Добавить страницу** в `registry/pages/<group>.ts`: `slug` с адресным
png-интентом (`crop-png`), тексты EN, `category`, `steps: [{ id }]`.
7. **Проверить обязательные контракты**: инструмент попадает в `TOOLS`, страница
в `PAGES`, `/tools/<slug>` prerender-ится, `input`/`result` согласованы с
тем, что делает `run`.
8. **Добавить i18n-ключи во все локали** сразу: `pages.<slug>.title` и
`pages.<slug>.description`, при наличии опций и вердиктов — `tools.<id>`.
9. **Тесты:** unit — в `registry/registry.test.ts` или рядом с `core`-функцией;
e2e-smoke — существующий spec по типу `input`/`result` (правила —
`docs/testing-strategy.md`).
10. **Обновить `docs/tools-map.md`:** новая страница обязательна в разделе
«Реализовано», формат строки — в шапке раздела. Карта перечисляет **`slug`
страниц** из `registry/pages/`, то есть адреса. Проверяет
`web/src/lib/registry/tools-map-doc.test.ts`: slug без страницы и страница
без записи в карте роняют тест. Обоснование и границы автоматизации —
`docs/decisions.md`, раздел 4.
Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для
инструмента, меняющего пользовательский поток, — e2e (см. раздел 3
`docs/quality-gates.md`).
-59
View File
@@ -1,59 +0,0 @@
# Архив документов
> Статус: **active**. Здесь хранятся завершённые, отменённые и явно устаревшие
> документы как временная историческая справка. Этот README — единственный
> документ архива со статусом `active`: он описывает процедуру, а не архивный
> материал, поэтому не подлежит 30-дневному удалению.
## Назначение
Архив помогает быстро посмотреть, какое решение принималось раньше и почему. Он
не является источником текущих требований, критериев приёмки или команды. Для
актуальных правил используются `AGENTS.md`, `docs/README.md` и активные планы.
Если решение снова понадобится, его можно восстановить из git history или
создать новый активный документ: **архивные файлы удаляются, а не хранятся
вечно.** Удалённые планы восстановимы через
`git log --diff-filter=D -- name-only -- docs/archive/` и
`git show <commit>^:<path>`.
## Обязательные метаданные
При переносе документа в архив добавить в начало:
```text
> Статус: archived
> Архивирован: YYYY-MM-DD
> Удалить после: YYYY-MM-DD
> Причина: краткое объяснение, почему документ больше не активен
> Использование: только историческая справка
```
Дата `Удалить после` — дата архивирования плюс 30 дней.
## Порядок архивирования
1. Убедиться, что документ действительно завершён, отменён или заменён более
актуальным источником.
2. Убрать из него ссылки на несуществующие пути и артефакты прошлых волн.
3. Добавить обязательные метаданные.
4. Переместить файл в `docs/archive/`.
5. Найти оставшиеся входящие ссылки:
`git grep -n "<filename>" -- 'docs/*.md' AGENTS.md README.md web/eslint-plugins/README.md`.
Шаг 4 ломает их, и агент без этого шага напишет в новом документе ссылку на
удалённый файл. Ожидаемо неразрешимыми остаются ссылки на
`docs/plan-domain.local.md` — он вне git, это не ошибка (`docs/README.md`,
раздел «Правила»).
6. Не менять архивный документ под новые требования.
## Удаление
Через 30 дней после архивирования:
- проверить, что документ не нужен для текущей работы;
- найти и удалить оставшиеся ссылки на него из активных документов;
- удалить файл из `docs/archive/`.
До истечения срока архивный файл не превращается обратно в live-документ: если
он снова актуален, создаётся новый документ или возвращается версия из git
истории с новым статусом и датами.
-312
View File
@@ -1,312 +0,0 @@
# Анализ: набор PNG-утилит для браузера
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> ранний исследовательский разбор, не являющийся текущим планом Использование:
> только историческая справка
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере.
Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких
загрузок на сервер, всё считается локально. Стек: **SvelteKit +
@sveltejs/adapter-static** (статический экспорт, никакого сервера). Все данные
обрабатываются на клиенте, ничего не уходит в сеть.
Источник идеи: типовой перечень утилит, который встречается в онлайн-наборах
PNG-инструментов (~296 операций).
---
## 1. Ключевые технологии браузера
| Задача | Средство |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Декодирование изображений | `<img>` / `createImageBitmap()` / `ImageDecoder` — браузер нативно декодирует PNG, JPEG, WebP, GIF, BMP, ICO, AVIF |
| Кодирование PNG/JPEG/WebP | `canvas.toBlob()` / `toDataURL()` — нативные кодировщики |
| Пиксельная обработка | `ImageData` + `Uint8ClampedArray` (RGBA), read-only canvas |
| Чтение/анализ PNG-структуры | Своя мини-библиотека чанков (IHDR/PLTE/tRNS) или `pngjs` |
| Сжатие/оптимизация PNG на байтовом уровне | `UPNG.js` (JS) либо `pngquant`/`oxipng` (WASM) |
| GIF-анимации (fade/reveal и т.п.) | `gifenc` / `gif.js` (JS), APNG — `UPNG` |
| Кодирование GIF/BMP на байтовом уровне | `gifenc`, простой ручной BMP-энкодер |
| Тяжёлая математика без блокировки UI | Web Workers, SharedArrayBuffer, WASM (zlib, квантование, ML) |
| Экономия памяти при больших изображениях | `ImageBitmap`, работа тайлами, downscale до декорирования |
**Вывод для архитектуры:** ~90% утилит — это одна и та же связка «декодировать →
прогнать через пиксельную функцию → закодировать». Разумно сделать общий ядро
(`core/pipeline.ts` + набор операций над `ImageData`) и тонькие страницы-обёртки
в SvelteKit. Каждая утилита = отдельный маршрут со своим UI (drag&drop слева,
превью, параметры, скачивание справа).
---
## 2. Уровень EASY — canvas + ImageData, простые попиксельные операции и композиция
Реализация: загрузка в canvas → `getImageData()` → цикл по пикселям /
перекомпоновка / отрисовка на выходной canvas → `toBlob('image/png')`. Без
сложных алгоритмов, каждая утилита — часы работы.
### 2.1 Конвертация форматов и текстовые представления
- **convert-png-to-jpg / convert-jpg-to-png / convert-webp-to-png /
convert-png-to-webp / convert-svg-to-png** — рисование на canvas + `toBlob()`
нужного типа
- **convert-bmp-to-png / convert-ico-to-png / convert-gif-to-png** (первый кадр)
— нативное декодирование браузером
- **convert-png-to-bmp** — BMP-формат простой, ~30 строк энкодера
- **convert-png-to-base64 / convert-base64-to-png / data-uri (в обе стороны)** —
`FileReader.readAsDataURL` + строка
- **convert-png-to-bytes / convert-bytes-to-png / convert-png-to-hex /
convert-hex-to-png / convert-png-to-rgb-values / convert-rgb-values-to-png** —
чтение/запись `ImageData` в текст
- **convert-png-to-gif** (один кадр) — `gifenc`, тривиально
### 2.2 Прозрачность и альфа-канал
- **create-transparent-png, remove-color-from-png(простая версия),
transparent-logo-maker, remove-logo/icon-background (по цвету)** — замена
цвета на прозрачность по порогу близости
- **remove-alpha-channel-from-png, fill-alpha-channel-in-png,
replace-alpha-channel-in-png, extract-alpha-channel-from-png,
invert-png-alpha-channel** — манипуляции только с A-компонентой
- **generate-png-alpha-mask, show-transparent-png-areas,
fill-transparent-png-pixels, convert-semi-transparent-png-to-opaque** — пороги
и заливки по альфе
- **harden-png-edges** — бинаризация альфы по порогу
### 2.3 Цвет: замена, тон, каналы, цветовые пространства
- **change-png-color, change-png-color-tone, change-png-opacity,
swap-png-colors, change-png-brightness, change-png-contrast,
convert-png-to-grayscale, add-sepia-tone-to-png, change-png-hue,
invert-png-colors, reverse-png-colors, convert-png-to-two-colors,
convert-png-to-black-and-white, create-monochrome-png,
generate-single-color-png** — попиксельные арифметики/преобразования
- **extract-png-color-channels, swap-rgba-color-channels,
split-png-into-rgb-components** — перестановка/выделение RGBA-каналов
- **convert-png-to-hsl/hsv/hsi/cmyk/ycbcr/lab-colors** — известные формулы
конвертации (Lab — чуть больше математики, но детерминировано)
- **generate-\*-color-palette
(complementary/monochromatic/analogous/triadic/tetradic),
generate-similar-color-shades, color-wheel-generator, mix-multiple-colors,
calculate-average-color, simulate-paint-colors, blend-two-colors,
step-between-two-colors, convert-color-names-to-png** — генераторы
палитр/смешение без работы с изображением
- **posterize-png, create-color-spectrum-png, create-multi-color-gradient-png,
generate-png-gradient-palette** — простые градиенты/квантование уровней
### 2.4 Геометрия, композиция, холст
- **resize-png, zoom-png, crop-png, cut-png, rotate-png, skew-png, shift-png,
change-png-canvas-size, change-png-aspect-ratio, fit-png-in-rectangle,
convert-landscape/portrait-png-to-portrait/landscape-png** — операции через
`CanvasRenderingContext2D` (`drawImage`, `rotate`, `setTransform`)
- **flip-png-horizontally/vertically, mirror-png, reverse-png** —
отражение/поворот на 180°
- **center-png, center-logo, center-icon** — центровка по bounding box
альфа-канала
- **duplicate-png, clone-png, multiply-png, create-png-tile,
create-symmetric-png, split-png-into-parts** — тайлинг/копирование
- **create-round-png, create-square-png, create-star-shaped-png,
create-wavy-shaped-png, round-png-corners** — маски-«штампы» на альфе
- **add-png-border, add-padding-to-png, add-space-around-png,
remove-padding-from-png, remove-space-around-png, remove-png-padding** —
расширение/усадка холста
- **add-png-background, add-signature/logo/icon/stamp-background** — подложка
под прозрачность
### 2.5 Текст, рамки, простые эффекты
- **add-text-to-png, convert-text-to-png, create-emoji-png, add-png-watermark,
wrap-png-in-polaroid, create-logo-from-text, create-icon-from-text/emoji,
create-custom-stamp, create-\*-stamp
(round/rect/green/red/number/date-time/monogram/from-text/emoji),
create-placeholder-png, create-empty-png, create-custom-png,
generate-colorful-png, generate-random-png, generate-white-noise-png,
generate-1x1-png, draw-png-grid** — рисование примитивов/случайных данных
- **add-noise-to-png, create-glitch-png, pixelate-png, censor-png,
erase-part-of-png, randomize-png-pixels** — шум/порча байтов/блоки/кисть
- **add-shadow-to-png** — смещение + лёгкое размытие + композиция
- **add-artifacts-to-png** — простой вариант: JPEG-раундтрип через canvas
### 2.6 Анализ и проверки
- **analyze-png, find-png-file-size, find-png-dimensions,
check-if-png-is-landscape-or-portrait, find-png-color-count,
check-if-png-is-grayscale, check-if-png-is-transparent,
verify-if-image-is-png, pick-png-color, pick-logo/icon-color, png-viewer,
test-png, preview-png-on-colorful-background,
test-png-on-multiple-backgrounds** — чтение заголовков/ImageData/гистограмм,
«пипетка» по клику
- **sort-colors** — сортировка списка палитры (не изображения)
### 2.7 Семейства signature/logo/icon/stamp — простые вариации
Почти все
«-maker/-color/-resize/-crop/-rotate/-bg/-space/-padding/-grayscale/-single-color/-white/-black/-semi-transparent/-shadow»
для signature/logo/icon/stamp — это переиспользование операций из разделов выше
с одним цветом/боксом. Отдельно перечислены только те, что требуют алгоритмов
(см. MEDIUM/HARD).
> Примерный объём: **≈180–190 утилит уровня EASY.**
---
## 3. Уровень MEDIUM — нужен настоящий алгоритм (свёртки, морфология, квантование, анимация)
Реализация: честные алгоритмы на `ImageData`, часто в Web Worker, при
необходимости — обвязка вокруг готовых JS-библиотек.
### 3.1 Фильтры и свёртки
- **blur-png, sharpen-png** — box/gaussian blur, ядро свёртки (упрощённо можно
`ctx.filter`, но качественно — своя свёртка)
- **refine-png-edges, feather-png-edges, clean-png-edges** — работа с
полупрозрачными краями: размытие альфы, де-контуринг, suppression
полупрозрачности
- **add-glow-effect-to-png** — расширение альфы + размытие + цветное наложение
### 3.2 Морфология и контуры (операции над альфа-маской)
- **add-stroke-to-png, add-outline-to-png, remove-stroke-from-png,
remove-outline-from-png, find-png-outline** — dilation/erosion по альфе,
извлечение контура
- **make-signature-thicker / make-signature-thinner, make-icon-thicker /
make-icon-thinner** — dilation/erosion
- **convert-outlined-icon-to-filled-icon** — flood-fill замкнутых областей;
**convert-filled-icon-to-outlined-icon** — эрозия
- **remove-png-border, remove-border-around-icon, remove-stamp-border** — детект
однотонной/декоративной рамки и вырезание
### 3.3 Квантование и цветовая кластеризация
- **quantize-png, decrease-png-color-count, set-custom-png-color-palette,
add-dithering-to-png** — median cut / octree / Wu + Floyd–Steinberg; готовые
JS-библиотеки (image-q и т.п.)
- **change-png-quality, create-low-quality-png** — симуляция потери качества
через квантование + JPEG-раундтрип
- **convert-png-to-color-blocks** — мультипликативный блок-эффект/квантование по
блокам
- **extract-png-color-palette, find-logo-color-scheme, extract-icon-colors** —
частотная гистограмма + выбор топ-N / кластеризация
- **create-unique/popular/multi-color/outlier-color-mask** — статистика
распределения цветов и выделение по маске
### 3.4 Выделение объектов по цвету / фону
- **remove-png-chroma-key** — порог по цвету + деспилл (убрать зелёную
окантовку)
- **remove-color-from-png (качественная версия), extract-color-from-png,
separate-png-colors, separate-logo-colors, separate-icon-colors** —
сегментация по близости цвета
- **convert-png-to-silhouette, convert-png-to-stencil** — порог + заливка
силуэта
- **extract-stamp-from-image (по цвету печати)** — хромакей/частотный фильтр по
цвету чернил
- **remove-png-background (вариант без ИИ)** — проверенный цветовой подход,
знакомый по популярным онлайн-наборам PNG-утилит. Работает хорошо на
однотонном/градиентном фоне и в ряде случаев удобнее ИИ. Опции и алгоритм:
- **Background Color** — выбор удаляемого цвета: пипеткой по изображению или
hex/RGB
- **Percentage (похожесть цвета)** — slider; 0% = убрать только точный
цвет, >0% = убрать соседние оттенки в окрестности цвета (в цветовом
пространстве)
- **Delete Outer Areas (галочка)** — режим «только внешние пиксели»: удаляются
только области, связанные с краями изображения (flood-fill/region-growing от
границ). Выключена = удалять по всему холсту. Именно эта опция позволяет
убрать фон, не задевая «пятна» того же цвета внутри объекта
- **Smooth Edge Line + Radius** — постобработка: полупрозрачная полоса
толщиной N px по границе удаления (сгладить переход)
- **Preview Deleted Background** — двухцветный предпросмотр маски (что
удаляется/что остаётся)
- Реализация: выбор цвета → порог по цветовому расстоянию → (опц.) flood-fill
от краёв → сглаживание краёв → маска поверх превью. Всё на `ImageData`, без
ML.
### 3.5 Геометрические искажения и пиксель-сортировка
- **add-whirl-to-png** — свирл/вихрь: обратное отображение координат
- **sort-png-pixels** — pixel sorting (сортировка строк/столбцов по
яркости/цвету)
- **upscale-png / downscale-png** — качественный ресемплинг (Lanczos, mipmap)
поверх базового canvas-resize
- **remove-signature-background, transparent-png-signature-maker,
clean-up-signature-edges, improve-low-quality-signature, clean-logo-edges,
clean-icon-edges, fix-low-quality-stamp,
convert-rubber-stamp-to-digital-stamp** — комбинация «цвет→прозрачность +
де-шум + морфология краёв»
- **convert-image/png/jpg/icon/logo/signature-to-stamp** — стилизация в
двухцветную «печать» (порог + зернистость + края)
### 3.6 Анимации и GIF-энкодинг
- **convert-png-to-gif, slowly-reveal-png, create-disappearing-png, fade-in-png,
fade-out-png, create-scrolling-png, create-png-sequence-from-gif** —
декомпозиция кадров GIF / генерация последовательности кадров +
`gifenc`/`UPNG` (APNG)
> Примерный объём: **≈71 утилита уровня MEDIUM.**
---
## 4. Уровень HARD — WASM, ML, длительная обработка, тонкая подгонка алгоритма
Реализация: WASM-модули, ML-модели в браузере, длительная обработка с
прогресс-барами.
- **compress-png / reduce-png-file-size / optimize-png** — сжатие на уровне
формата: нужен качественный PNG-энкодер с контролем zlib-фильтров/битовой
глубины и оптимизаторы. Решение: **pngquant / oxipng / zopfli (WASM)** +
подгонка под «качество→размер» (это главный кандидат на HARD — именно здесь
тюнинг бесконечен)
- **remove-png-background (вариант с ИИ)** — опциональное улучшение к
MEDIUM-версии: ML-сегментация (Rembg/DeepLab в WASM или ONNX) для произвольных
фото, где цветовой матчинг бессилен. Начинать можно без него — не-ИИ версия
уже даёт рабочий инструмент
- **extract-signature-from-image** — детект подписи на скане/фото с произвольным
фоном: де-шум, контраст, ML-детекция
- **convert-handwritten-signature-to-digital-signature** — очистка и «оцифровка»
почерка (ML, денойзинг, выравнивание)
- **extract-barcode-from-image** — декодирование штрих-кодов/QR: готовая
библиотека **zxing-wasm**, сопряжение с камерой/фото
> Итого **6 утилит уровня HARD** (все — опциональные улучшения/надстройки). Всё
> остальное (≈290) — EASY/MEDIUM.
---
## 5. Итоговая сводка
**EASY — ~185 утилит.** canvas + ImageData. Примеры: crop, resize, rotate,
конвертации, альфа-канал, палитры, текст, рамки, анализ.
**MEDIUM — ~71 утилита.** Алгоритмы, Web Workers, gifenc. Примеры: blur,
sharpen, морфология, квантование, дизеринг, pixel sorting, анимации GIF,
remove-background (без ИИ).
**HARD — 6 утилит.** WASM (pngquant/oxipng/zxing), ML (ONNX/Rembg). Примеры:
compress/optimize, remove-background (ИИ), extract-signature, barcode.
---
## 6. Рекомендации по реализации
1. **Единое ядро обработки** — `src/lib/core/`: загрузка файла → `ImageData`,
библиотека операций (`color.ts`, `alpha.ts`, `geometry.ts`, `filters.ts`,
`morphology.ts`, `quantize.ts`, `channels.ts`), экспорт через `toBlob`.
Каждая утилита — тонкая SvelteKit-страница + общие компоненты (DropZone,
Preview, Download).
2. **Порядок внедрения:** MVP = ~40 топовых EASY (crop, resize, rotate, convert,
alpha, grayscale, brightness, палитры, watermark, add-text, add-background,
pick-color, analyze). Затем остальные EASY → MEDIUM пакетами по темам
(фильтры, морфология, квантование, анимации). `remove-png-background` (без
ИИ) — отличная «витринная» MEDIUM-фича ранних этапов: не требует ML и
переиспользует `remove-color` + flood-fill + сглаживание краёв. HARD — в
последнюю очередь.
3. **Производительность:** вся пиксельная работа и тяжёлые алгоритмы — в Web
Worker; большие изображения декодировать сразу в downscale для превью;
прогресс-бары для MEDIUM/HARD.
4. **SEO/структура:** SvelteKit + static adapter — каждая утилита отдельный
маршрут (`/crop-png`, `/rotate-png`), что даёт удобные адреса и повторяет
распространённую структуру наборов PNG-инструментов.
5. **Семейства logo/icon/stamp/signature** — переиспользуют общие операции; в
коде это конфиг-опции той же страницы (например `/resize-png` и
`/resize-icon` = один компонент с разными подписями/дефолтами). Не плодить
дублирующий код.
-647
View File
@@ -1,647 +0,0 @@
# План: линтеры, тесты и качество кодовой базы
> Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
> Причина: все этапы Q0–Q8 выполнены или закрыты решениями; рабочие части
> перенесены в `docs/quality-gates.md`, `docs/testing-strategy.md`,
> `docs/agent-workflow.md`, `AGENTS.md`, `web/eslint-plugins/README.md` и
> `docs/backlog.md`. Использование: только историческая справка
>
> Выполнены Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3; открытых шагов не осталось.
>
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
> инфраструктурных скриптов. Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3 выполнены.
> Решения, важные за пределами плана, живут в `docs/quality-gates.md`,
> `docs/testing-strategy.md` и `AGENTS.md`.
>
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
> проверок, стабилизировать тестовые контракты и только после этого расширять
> автоматические правила.
## 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; после Q7 вычищены
4 неиспользуемых design token, 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. Условия перехода и текущая политика
описаны в `docs/quality-gates.md`, раздел «Политика e2e в CI».
**Готово, когда:** одна команда воспроизводит все быстрые проверки, а 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`; unused-token сообщение
оставалось неблокирующим и закрыто в Q7.
- 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. Coverage thresholds
**Статус:** выполнен для чистой логики. Vitest считает покрытие только по
`src/lib`, с явным списком исключений, и `pnpm verify` падает при падении ниже
порога.
- `web/vitest.config.ts`: provider `v8`, `reporter: text-summary`, пороги
statements 90, branches 82, functions 72, lines 91.
- Пороги поставлены на 1.5–2.5 п.п. ниже измеренного baseline (91.77 / 83.3 /
74.31 / 92.53), поэтому gate ловит регрессию, а не текущий долг.
- Scope — логика, которую node-Vitest вообще может исполнить. Исключены
браузерные модули `core/domText.ts` и `core/io.ts` (canvas,
`createImageBitmap`), data-модули `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts`,
rune-файлы `*.svelte.ts`, entry worker-а и type-only `components/define.ts`.
Причина каждого исключения зафиксирована в конфиге и в
`docs/testing-strategy.md`.
- `registry/*` (47–81% statements, 25–47% functions) в scope остаётся: это
обычная непокрытая логика, а не ограничение окружения. Покрывается e2e-smoke
Q6e, но unit-порог её не скрывает.
- `verify` теперь выполняет `test:coverage` вместо `test`: набор тестов тот же,
двойного прогона нет.
- Порог functions самый слабый (74.31% против 91.77%): добавление инструмента с
непокрытым `run` быстрее всего опускает именно его. Это ожидаемо, порог держит
запас.
- DOM-тесты `SchemaToolView`/controls сознательно не начаты: node-Vitest не
имеет DOM, а e2e уже покрывает поведение. Задача живёт в `docs/backlog.md`.
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
file-result-инструментам.
#### Q6f.5. Visual snapshots
**Статус:** решено не делать. Обоснование, чем закрыта потребность сейчас и при
каком условии стоит вернуться — в `docs/testing-strategy.md`, раздел 8.
### Q7. Укрепление lint-инфраструктуры
**Оценка:** M. **Зависимости:** Q1, Q2, Q3.
#### Q7a. Очистка неиспользуемых design tokens
**Статус:** выполнен. Token audit чист: `All app.css tokens are used somewhere`.
- Удалены `--color-text-accent-muted` (light и dark, иначе ломается parity),
`--space-text-s: 0`, `--size-content-max: 880px` и `--duration-m: 0.3s`.
- Проверено, что мёртвые токены — это пыль, а не признак хардкода: дублирующих
значений (`max-width`, `0.3s`, muted-цвет) в `src` нет. Потребители
`space-text-m/xl/2xl`, `size-content-max-wide` и `size-workspace-min`
используются, поэтому шкалы не тронуты.
- Шкалы остались неполными намеренно (`duration-s/l` без `m`, `space-text` без
`s`): мёртвый элемент шкалы хуже, чем «дырявая» шкала, а вернуть его можно
вместе с потребителем.
- Режим unused-аудита решён в Q7d: он остаётся предупреждением.
#### Q7b. Тесты token audit и PostCSS HCT-плагина
**Статус:** выполнен. У обоих не было ни одного теста, хотя HCT-плагин
(`web/scripts/postcss-hct.mjs`, 430 строк) пересчитывает всю палитру.
- `web/scripts/__tests__/postcss-hct.test.mjs` — 17 тестов: literal и
производный `hct()`, seed из `:root` и из hex, цепочка hct-токенов, refs
`h/c/t`, `calc/min/max/clamp`, обёртка hue, клампы tone/chroma, несколько
вызовов в одном объявлении, а также warn-and-keep для неизвестного seed,
циклической ссылки и битого тела `hct()`.
- `web/scripts/__tests__/token-audit.test.mjs` — 15 тестов на parity
(light-only, dark-only, derived, не-цвет), authorship (hct, color-mix,
seed-токены, хардкод с локацией, игнор не-цветов и обычных свойств), unused и
предикаты `isColorValue`/`isDerived`.
- Ожидания не зашивают вывод плагина, а сравнивают его с эквивалентной
литеральной записью: `hct(from var(--seed) h c calc(t + 10))` должен совпасть
с `hct(120 40 60)`, если seed — `hct(120 40 50)`. Иначе тест просто
зафиксировал бы текущее поведение.
- Единственный seam: `checkUnused(root, used = collectUsedTokens())` —
зависимость от чтения `src` стала инъецируемой, production-вызов не изменился.
- `vitest.config.ts` подхватывает `scripts/**/*.test.mjs`, поэтому скрипты
покрываются обычным прогоном и `verify`; `test:rules` не трогали.
- Проверка мутацией выявила дыру: первая версия тестов не ловила кламп tone в
ветке `from` (смена `clamp(t, 0, 100)` на `0, 90` проходила зелёной). Добавлен
отдельный тест на производный кламп; после этого мутация падает.
#### Q7c. Базовый JS recommended для инфраструктуры линтинга
**Статус:** выполнен. `eslint-plugins/**` и `scripts/**` больше не вне линта.
- `web/eslint.config.js`:
`toolingFiles = ["**/eslint-plugins/**/*.js", "**/scripts/**/*.mjs"]` получают
`js.configs.recommended` плюс `languageOptions` с `globals.node` (иначе
`no-undef` ругается на `console`, `process` и `URL` в Node-скриптах).
- Полные TS/svelte-наборы туда не подключаются: это `.mjs` и конфиги, а не
продуктовый код.
- Масштаб оказался небольшим: 28 проблем, из них 26 — `no-undef` на
Node-глобалах и только 2 настоящие: мёртвые импорты `existsSync` и `resolve` в
`scripts/serve-static.mjs`. Линтер сразу нашёл пыль в инфраструктуре, для
которой его раньше не было.
- `eslint-plugins/**` дал ноль проблем, а новые тесты `scripts/__tests__` тоже
попали под правило.
- Проверено пробой: после включения неиспользуемая переменная и вызов
неопределённого глобала в `scripts/` дают ошибки, то есть правило применилось,
а не молча пропустило файлы.
#### Q7d. Решения по оставшимся вопросам
**Статус:** закрыт тремя решениями.
1. **Обычный CSS под те же токен-правила, что Svelte styles — узко.** В `src`
всего один CSS-файл, и он же словарь токенов, поэтому переносить туда
`no-hardcoded-in-svelte` и подобные нечего: stylelint уже проверяет
`src/**/*.css` (префиксы, запрет `!important`, цвет только у brand-токенов),
а `check-tokens.mjs` дублирует проверку цвета. Реальный пробел был один —
`var()` может ссылаться на несуществующий токен, — его закрыл новый модуль
`web/scripts/token-audit/refs.mjs` (см. Q7e). Широкую унификацию механизмов
решено не делать: это лишний слой поверх дублирующихся проверок.
2. **Конфиги под рекомендации — только JS.** `*.config.js`
(`eslint`/`postcss`/`stylelint`) добавлены в `toolingFiles`; по измерению они
дают 0 проблем, поэтому это бесплатно. TS-конфиги (`vite`, `vitest`,
`playwright`) не подключены: им потребовался бы `tseslint`-парсер и запись
`vite.config.ts` в `allowDefaultProject`, а выигрыш близок к нулю. Попутно
удалена мёртвая запись `svelte.config.js` в `allowDefaultProject` — такого
файла нет.
3. **Unused-аудит остаётся предупреждением.** Правило «новый токен — вместе с
потребителем» закреплено в `AGENTS.md`. Причина: `verify` входит в
deploy-workflow, поэтому стилевой долг блокировал бы деплой сайта; кроме
того, чтение токена из JS (`getPropertyValue`) аудит не видит и дал бы ложное
срабатывание. Baseline чистый, пыль вычищена в Q7a.
#### Q7e. Проверка резолва `var()` в `app.css`
**Статус:** выполнен. Аудит падает, если `var(--x)` в `app.css` указывает на
токен, которого нет в этом же файле.
- `web/scripts/token-audit/refs.mjs` + вызов в `check-tokens.mjs`; режим —
ошибка, а не предупреждение, потому что это дефект (объявление молча
отбрасывается), а не стилистический долг.
- `var(--x, fallback)` не считается ошибкой: fallback легален без определения
токена.
- 7 тестов в `scripts/__tests__/token-audit.test.mjs`: резолв в обеих темах,
опечатка с локацией, fallback, дедуп повторов, ссылка внутри определения
токена, `var()` в обычном свойстве.
- Проверено на живом файле: временная опечатка `var(--color-bordr)` дала
`Unresolved var() references` и код выхода 1; `app.css` восстановлен байт в
байт.
### Q8. Новые автоматические правила
**Оценка:** L. **Зависимости:** Q4, Q7. **Разбивка:** три атомарных шага, каждый
заканчивается либо работающим warn-правилом с нулевым baseline, либо измерением
с записью вывода. Механически важно: `lint-all` запускает ESLint с
`--max-warnings=0`, поэтому warn-правило обязано приземлиться без срабатываний
на текущем коде.
#### Q8.1. Пользовательские тексты вне i18n
**Статус:** выполнен. Пилот `i18n/no-hardcoded-user-text` (warn) с нулевым
baseline; 2 реальных нарушения исправлены.
- Измерение до правила: 42 строковых атрибута пользовательского текста, все — в
`/kit`; вне кита текстовые узлы дали 2 находки. Итог после исключений: 2
настоящих бага и 2 бренда, которые план требовал исключить.
- `web/eslint-plugins/i18n/no-hardcoded-user-text.js`: узлы `SvelteText` плюс
строковые литералы пользовательских атрибутов; `<style>`/`<script>`
пропускаются.
- Исключения из плана реализованы списками, а не эвристиками: форматы/единицы/
имена файлов/версии/размеры, ALL CAPS идентификаторы (registry source
strings), `/kit` через scope, бренд через опцию `allowWords` (в конфиге, а не
в коде правила).
- Исправлены 2 бага: `ColorPairControl.svelte` показывал английские `From`/`To`.
Ключи `ui.from`/`ui.to` уже существовали в обоих словарях — компонент их
просто не использовал, поэтому новые ключи не понадобились.
- 30 тестов в `__tests__/no-hardcoded-user-text.test.ts`; мутацией проверено,
что правило не декоративное: с пустым `allowWords` бренд начинает репортиться.
- Известные слепые зоны, зафиксированные как границы пилота: текст, собранный в
скрипте и переданный выражением, и текст вне Svelte-шаблонов (например, в
атрибутах, приходящих из registry). Расширение поверхности — отдельная работа,
а не молчаливое расширение правила.
#### Q8.2. Дублирование HTML/Svelte-разметки
**Статус:** измерено, решение — правило не делать.
Одноразовый аудит (скрипт после измерения удалён) по 65 `.svelte` в
`components/**` и `routes/**`: при пороге 5 нормализованных строк — **одна**
пара, при пороге 3 строки — 17 пар, и все они шум (`</label>`, `{/each}`,
`{#if children}`). Единственная осмысленная находка — побайтово идентичные
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte`; она заведена в
`docs/backlog.md` (идея 16).
Почему автоматика отменена:
- построчное сравнение Svelte-разметки принципиально ненадёжно: одинаковый
контейнер с разным содержимым даёт ложное срабатывание, а намеренно общий
класс — ложное отсутствие сигнала;
- чтобы не ловить шум, порог пришлось бы поднять до 12+ строк, и тогда правило
срабатывает ровно один раз — на паре страниц, которые по замыслу должны
различаться; дальше нужна либо allowlist-запись, либо принудительный
рефакторинг;
- контроль остаётся на ревью: расхождение видно в диффе, а поведение ловит e2e
(`navigation.spec.ts` открывает все маршруты, `catalog.spec.ts` проверяет
каталог), а извлечение общей разметки уже закреплено конвенцией в `AGENTS.md`.
#### Q8.3. Согласованность комментариев
**Статус:** решение — автоматический lint не делать, это process policy.
Описания инструментов различаются осознанно, и ловить это текстовым правилом
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
`TODO`/`FIXME` из кода перенесены в `docs/backlog.md` (7 записей), в коде не
осталось ни одного незарегистрированного. Правило-детектор не добавлялось.
## 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-правила и исправление продуктовых багов в одну работу.
Продуктовые направления, которые план не начинает (платформа PWA/API/WASM,
plural-формы, batch/архивы, region-инструменты), живут в `docs/backlog.md` и
`docs/roadmap.md`; границы этого плана — в разделе 1.
-335
View File
@@ -1,335 +0,0 @@
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в
> backlog. Использование: только историческая справка
# План: i18n в preview (новый UI)
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2
> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и
> ограничения — в `docs/testing-strategy.md` и `docs/backlog.md`.
## 1. Что получается
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
`v1`) использует модуль `lib/i18n`: переключатель RU/EN в шапке работает по
локали, строки и ошибки локализованы. Остаточные задачи — довести паритет
словарей до zero-warning и спроектировать plural-формы.
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
копия `lib/i18n` → `lib/v1/i18n`, у нового UI — своя независимая ветка
`lib/i18n`, которую дальше меняем без оглядки на v1 (Фаза 0). Переключённая
локаль (localStorage) остаётся общей для обеих версий — язык переживает переход
между страницами.
## 2. Текущее состояние (исследование)
### Уже есть
- `lib/i18n/locale.svelte.ts` — состояние локали (runes), персист в
localStorage, обновление `document.documentElement.lang`.
- `lib/i18n/t.ts` — перевод по точечному ключу с фолбэком на базовую локаль и
интерполяцией `{var}`.
- `lib/i18n/dict.ts` — тип `Dict`, `BASE_LOCALE = "en"` (база — английский, ru —
перевод), `LOCALES = ["ru", "en"]`.
- `lib/i18n/ru.ts` (~1351 строка) и `en.ts` (188 строк) — полные переводы
инструментов v1 по id (title/description/params/options/results).
- `lib/i18n/tool-strings.ts` — хелперы для v1
(`toolTitle/toolDescription/ paramLabel/optionLabel/searchDoc`).
- `lib/i18n/matching.ts` — кросс-языковой поиск (ё→е, диакритика, скоринг).
- Тесты: `i18n.test.ts`, `tools-i18n.test.ts`, `matching.test.ts`,
`smoke-i18n.test.ts`, `search-coverage.test.ts`.
- `+layout.svelte` уже вызывает `initLocale()` на клиенте.
### Пробелы в новом UI
- `LangToggle.svelte` — заглушка с локальным `let lang = $state("RU")`, не
связана с `locale.svelte.ts`.
- Новый UI почти целиком на хардкод-английском:
- ~150+ строк по ~30 компонентам и маршрутам (заголовки, кнопки, аria,
плейсхолдеры, подписи секций);
- 122 инструмента с английскими `title`/`description` в `lib/registry/*.ts`;
- ~60+ подписей опций select;
- ~30 заголовков layout-групп (`title: "Canvas"`, `"Background"`, …);
- вердикты анализаторов — raw English строки (`"Yes — valid PNG signature."`),
при том что v1 возвращает ключи словаря (`"verifyYes"`) и рендерит их через
`tools[id].results`;
- `labelOf(id)` в `SchemaFields.svelte` генерирует подписи полей из field ID
(keepAspect → "Keep Aspect");
- категории в `catalog.ts` (`GROUP_LABELS`) — English uppercase, хотя в `dict`
уже есть ключи `categories.X`.
- Из всех новых компонентов `t()` вызывает только `SchemaToolView.svelte`, и
только для ошибок (`ToolError.key`).
### Совпадение регистров
- Новый registry и старый v1 registry используют **одинаковые id** инструментов
(resize-png, flip-png, …) — переводы title/description из v1 `ru.ts` служат
исходником для новой ветки `lib/i18n/ru.ts` (копирование данных при наполнении
Фазы 2, не общий модуль).
- **Расхождение**: param ids в ru.ts (`width`/`height`/`keepAspect`) не равны
field ids новоно schema (`size` dimension, `anchor`). Нужны новые переводы
полей.
- `ru.ts` tools[id].results для анализаторов уже содержит ключи (`verifyYes`,
`grayscaleNo`, `orientationPortrait` и т.д.).
### Архитектурные ограничения
- Изоляция old↔new (`isolation/no-mixed-imports`): сейчас `lib/i18n/` — shared.
По решению задачи (Фаза 0) i18n **форкается**: v1 получает полную копию
`lib/v1/i18n`, а `lib/i18n` переходит в ветку «new» (правило изоляции запретит
старому коду импортить новый i18n). До форка в общем модуле два скрытых
v1-импорта: `dict.ts` тянет `CategoryId` из `../v1/categories`,
`tool-strings.ts` — `$lib/v1/registry`; после форка оба живут только в копии.
Новому UI нужен параллельный `schema-tool-strings.ts` поверх `$lib/registry`.
- Tone detection в `SchemaTextResult.svelte`: `/^(yes|true)/i` по value —
сломается при русских вердиктах («Да», «Нет»).
- `tools-i18n.test.ts` покрывает только v1 TOOLS; нужен аналог для нового
registry.
- `known-issues.spec.ts`:
`test.fixme("error message is localized, not a raw i18n key")`.
## 3. Как делаем
Принципы:
- Мелкие атомарные коммиты (правило AGENTS.md); самому не коммитить — коммитит
разработчик после ревью.
- Изменения «снизу вверх»: сначала общий слой i18n, потом словари, потом UI.
- Каждый этап заканчивается зелёными `pnpm --dir web lint` +
`pnpm --dir web check` + тесты.
- Решения по §5 зафиксированы и учитываются с первых правок словарей.
## 4. Фазы
### Фаза 0: Форк i18n для v1 + подготовка общего слоя
Выполняется строго до остальных фаз, без изменения поведения UI. Цель — два
независимых i18n-слоя: v1 получает замороженную копию, новый UI — свою ветку,
которую дальше меняем свободно. На выходе фазы — зелёные `lint`, `check`,
`test`, `test:rules`.
- **0.1** Форк: скопировать `web/src/lib/i18n/` → `web/src/lib/v1/i18n/` (полный
снимок: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `tool-strings.ts` + все 5 тестов). v1 работает дальше только с
копией — поздние правки нового i18n её не затрагивают.
- **0.2** Привести копию к v1-канону (относительные пути от нового места):
- копия `dict.ts`: импорт `CategoryId` `../v1/categories` → `../categories` (=
`lib/v1/categories`);
- копии тестов (`tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts`): `../v1/registry` → `../registry` (=
`lib/v1/registry`);
- импорт `$lib/v1/registry` в копии `tool-strings.ts` остаётся валидным;
- перепроверить остальные относительные и `$lib`-пути в копии.
- **0.3** Переключить импорты старого UI: все `$lib/i18n/*` в `routes/v1/**` и
`lib/v1/components/**` (39 импортов в 23 файлах) → `$lib/v1/i18n/*`.
- **0.4** Очистить новый `lib/i18n/` от v1-специфики, иначе isolation поймает
new→old:
- удалить `tool-strings.ts` (завязан на v1-типы `ParamDef`/`ToolEntry`; для
нового UI вместо него `schema-tool-strings.ts`, см. 0.8);
- удалить v1-центричные тесты `tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts` (они импортируют `../v1/registry`; живут только в
копии);
- остаются: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `i18n.test.ts`, `matching.test.ts`.
- **0.5** Обновить правило изоляции и конфиги:
- `isolation/no-mixed-imports.js`: добавить `lib/i18n/**` в `DEFAULT_NEW`
(копия по-прежнему old через `lib/v1/**`);
- `eslint-plugins/__tests__/no-mixed-imports.test.ts`: кейсы «old → i18n
чисто» (строки 83–89) и «lib/i18n — shared» (строки 99–105) обновить под
новую классификацию: old→новый i18n = нарушение; добавить фикстуру
`__fixtures__/src/lib/v1/i18n/t.ts` для кейса old-копия;
- `eslint.config.js`: в `allowDefaultProject` добавить фикстуру
`eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts`; поправить комментарий
изоляции «Общее (core/, i18n/, theme)» → «Общее (core/, theme)»;
- `eslint-plugins/README.md`: убрать i18n из списка shared (§ Isolation).
- **0.6** Новый `lib/i18n/dict.ts`: `CategoryId` переключить с
`../v1/categories` на `../categories` (новый структурно-идентичный тип) —
скрытых v1-зависимостей в новом i18n не остаётся.
- **0.7** Проверка форка: `pnpm --dir web lint`, `check`, `test` (v1-копия
тестов подхватывается `vitest include: src/**/*.test.ts`), `test:rules`;
смоук: v1-страницы и оба переключателя RU/EN работают как раньше, языки обеих
версий согласованы.
- **0.8** Добавить `lib/i18n/schema-tool-strings.ts` поверх `$lib/registry`
(новый тип `ToolEntry`):
- `toolTitle(tool)`, `toolDescription(tool)` с фолбэком на registry English;
- `fieldLabel(field, id)` — перевод по ключу из `fields`-секции словаря (см.
решение C), фолбэк на `labelOf(id)`;
- `groupLabel(title)` — перевод по ключу из `groups`-секции словаря (см.
решение A);
- `verdictText(toolId, key)` — рендер вердикта по ключу;
- `verdictTone(key)` — `success`|`danger`|`info` по имени ключа
(`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке.
- **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`):
- секция `preview` **не заводится**: строки нового UI живут в собственных
категориях — расширяем существующие (`catalog`, `header`, `dropZone`,
`sourceCard`, `resultCard`, `paramsCard`, `textResult`, `ui`) и заводим две
маленькие новые: `actions` (Generate / Open image / Download result…) и
`textSource` (placeholder, Try sample…);
- новая глобальная секция `fields: Record<string, string>` — переводы подписей
полей по ключу из схемы (решение C);
- новая глобальная секция `groups: Record<string, string>` — переводы
заголовков групп полей (решение A).
### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем)
- **1.1** Заполнить `en.ts` и `ru.ts` секциями `actions`, `textSource`,
`fields`, `groups` и ключами недостающих строк в существующих секциях.
- **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и
ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить
title/description в ru.ts новой ветки (по категориям — несколько коммитов).
- **1.3** Добавить в `ru.ts` ключи `fields`/`groups` для используемых в схемах
ключей полей и заголовков групп (по ходу проставления ключей в schema, Фаза
5).
- **1.4** Добавить `tools[id].options[fieldId][value]` для select-полей, чьи
опции нужно локализовать.
- **1.5** Тест полноты (задел на Фазу 6): для каждого ключа label в schema есть
перевод в обоих словарях.
### Фаза 2: LangToggle → реальный locale
- **2.1** `LangToggle.svelte`: убрать локальный `lang`, подключить
`getLocale()`, `setLocale()`, `LOCALES`. Модель — language switch из
`routes/v1/+layout.svelte`.
- **2.2** Проверить e2e `navigation.spec.ts` «language toggle marks the active
button» — должен проходить без изменений.
### Фаза 3: Routes + layout
- **3.1** `+layout.svelte`: breadcrumbs через `t()`, динамический `lang`.
- **3.2** `+page.svelte`, `list-tools/+page.svelte`: footer-строка →
`t("header.footerNote")`.
- **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`.
### Фаза 4: Компоненты — основной блок (коммиты кучками по смыслу)
Все текстовые подписи в компонентах — информативные, сохраняем и переводим на
`t`/хелперы. Каждый коммит: замена строк + lint/check.
- `SchemaToolView.svelte`: title/description →
`toolTitle()`/`toolDescription()`, перевести `TOOL SETTINGS`, «Configure
output» и «This tool has no schema yet.».
- `SchemaSourceTile.svelte`: перевести figcaption `SOURCE`, «choose an
image»/«no source…» и alt.
- `SchemaResultTile.svelte`: перевести figcaption `RESULT`, «no result yet» и
alt; суффикс «parts» — в `PreviewTile`.
- `SchemaPreview.svelte`: перевести панельный лейбл `PREVIEW PANEL` и
meta-капшены `SOURCE / RESULT / FORMAT`; значения «parts»/«text» → `t()`.
- `SchemaActions.svelte`: Generating…, Generate, Open image, Download result.
- `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)` →
`fieldLabel(field)`.
- `SchemaTextSource.svelte`: placeholder, Try sample, Render text.
- `SchemaTextResult.svelte`: перевести лейбл `RESULT` у вердикта, Copy /
Download .txt; tone detection → `verdictTone()`.
- `CatalogToolbar.svelte`: фильтры → `t("categories.all")` +
`t("categories.X")`, поисковый placeholder.
- `CatalogHeader.svelte`: перевести heading/lead и счётчик «TOOLS AVAILABLE».
- `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS».
- `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`.
- Schema field controls (по одному или группой): Width/Height, From/To, Backing
plate, Opacity, Font/Size/Color/Bold, Sans/Serif/Mono, Angle, «+ Add color»,
«Remove color».
- `TopNav.svelte`, `ThemeToggle.svelte`, `CodeBlock.svelte`, `StepCard.svelte`,
`Footer.svelte`.
### Фаза 5: Вердикты + ключи в schema
- **5.1** Анализаторы в `registry/analyze.ts`: возвращают ключи (`"verifyYes"`
вместо raw English).
- **5.2** `SchemaPreview.svelte`: рендер вердикта через
`verdictText(tool.id, key)`.
- **5.3** Layout groups в `registry/*.ts` (решение A): `title` → ключ
(`title: "groups.canvas"`); в `SchemaFields.svelte` — `groupLabel(title)`. В
`Dict` — глобальная секция `groups`.
- **5.4** Лейблы полей в `registry/*.ts` (решение C): на поле в схеме
проставляется `label`-ключ (`field.number({ label: "fields.thickness", … })`),
вывод в UI — `fieldLabel(field)` = `t(label)`. В `Dict` — глобальная секция
`fields`.
- **5.5** Категории в `catalog.ts`: `GROUP_LABELS` → `t("categories.X")` (ключи
уже есть в dict).
### Фаза 6: Тесты
- **6.1** `tools-i18n.test.ts` — полнота словарей для текущего registry:
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
- **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются.
- **6.3** В новой ветке `lib/i18n` появится собственный `smoke-i18n.test.ts`
(аналог v1: переключение локали, `html lang`, контент без смеси языков) —
v1-копия теста не трогается.
### Фаза 7: Кросс-языковой поиск
Переключить поиск в новом каталоге с `toLowerCase()` на
`scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см.
решение D). Последний шаг, аккуратный отдельный коммит.
### Фаза 8: Линтер словарей (i18n key linter)
Замена ручной проверки ключей на автоматическую. Инфраструктура уже есть:
`eslint-plugins/` умеет читать соседние файлы (`isolation/no-mixed-imports`) и
гоняться фикстурами (`test:rules`).
- **8.1** Новое правило `i18n/dict-consistency` в `web/eslint-plugins/`.
Применяется к файлам-словарям (конвенция: экспортируют `Dict`, лежат в
`lib/i18n/`); новые локали подхватываются автоматически — правка правила не
нужна.
- **8.2** Проверки — **только `warn`, никогда `error`** (пропущенный перевод не
валит сборку):
- **Паритет дерева ключей**: каждый ключ (dot-path `a.b.c`) присутствует во
всех остальных локалях, не только против BASE. Нет —
`missing key "x.y" in en`.
- **Пустые значения**: `""` и строка из одних пробелов —
`empty value "x.y" in en`.
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
всех локалях — поймать потерянную при переводе переменную.
- **8.3** Политика источников: `tools.*.title` и `tools.*.description` в
`BASE_LOCALE` намеренно берутся из registry English fallback; остальные локали
обязаны иметь физические переводы. Старый `tools.*.params` не используется
новым UI и пока исключён policy-pattern, а не списком id. Target — zero-warn
без молчаливых подавлений.
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
### Фаза 9: Множественные числа (plural-формы)
- **9.1** Тип значения словаря расширяем: `string | PluralForms`, где
`PluralForms = { other: string } & Partial<{ one, few, many }>` по
CLDR-правилам локали (ru: обязательны `one/few/many`; `other` — всегда, как
фолбэк). Новая функция `tPlural(path, n)` в `lib/i18n/`: выбирает категорию
через `Intl.PluralRules(locale).select(n)` и подставляет её с интерполяцией
`{n}`.
- **9.2** Первые потребители: «часть/части/частей» (split-into-parts-png),
счётчики инструментов (`catalog.toolsCount`), «N параметров», «N файлов».
- **9.3** Паритет в линтере (Фаза 8): если ключ — plural-объект в одной локали,
он обязан быть plural-объектом во всех остальных (тип-паритет; string в одной
из локалей — warn). Набор форм между локалями не сравнивается дословно — он
проверяется по правилам конкретной локали (`other` обязателен; en: one/other,
ru: one/few/many).
- **9.4** Открытый вопрос на этапе реализации: неоднозначность «объект-секция»
(`Record<string, string>`) vs plural-объект в дереве словаря. Варианты:
отдельная секция `plurals` в словаре, конвенция имён ключей, вывод типа из
контекста. Фаза начинается с проработки этого вопроса, не с кода.
## 5. Решения
- **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме
(`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря
(`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` =
`t(title)` с фолбэком на сырой ключ.
- **C. Лейблы полей**: в схеме задаём i18n-ключ
(`field.number({ label: "fields.thickness", … })`), в UI выводится перевод —
`fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря,
фолбэк на текущий `labelOf(id)` для несопоставленных.
- **D. Объём**: переводим всё сразу — и каркас, и контент реестра, и вердикты, и
кросс-языковой поиск (Фаза 7). Крупные изменения кода разбиваем на небольшие
атомарные шаги/коммиты.
## 6. Проверки
- `pnpm --dir web lint`
- `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`
- `pnpm --dir web test`
- `pnpm --dir web test:rules` (если менялся eslint/isolation-конфиг)
- Смоук: переключатель RU/EN на новом UI; страницы каталога/инструмента, ошибка,
вердикт — без смеси языков; `html lang` следует за локалью.
-380
View File
@@ -1,380 +0,0 @@
# План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
> Причина: переезд на новый дизайн завершён (C17/C18/Q3), C20 и C21 выполнены,
> пункты полировки разобраны и разложены по `docs/backlog.md`. Источник правды
> по визуальному языку — `web/src/app.css`, `web/src/lib/components/**`, витрина
> `/kit` и приёмка в `docs/checklist-manual-testing.md`. Использование: только
> историческая справка
> Статус: C17 **выполнен**, C18 **выполнен**, Q3 (удаление старого UI)
> **выполнен**, C20 **выполнен**, C21 **выполнен**. Открытых шагов не осталось.
> Покупка домена НЕ была блокером C17: домен нужен для S1 (`plan-seo.md`) —
> canonical, sitemap, снятие `noindex`.
>
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
>
> **Историческая стратегия перехода:** сначала новый UI собирался отдельно,
> затем стал production, после чего legacy-ветка была удалена Q3. Текущие
> критерии относятся только к production UI; старые решения и детали перехода
> остаются в архивных планах.
## 0. Почему меняем стратегию
- Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить
независимо; теперь production использует только новые маршруты и `app.css`.
- Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout
для удалённого UI-v1 больше не нужны.
## 1. Источники и их оценка
### `refs-html/` — витрина разметки
Покрытие достаточное:
| Файл | Что показывает | Оценка |
| ------------------------- | ----------------------------------------- | ---------------- |
| `index.html` | Главная: pipeline-воркспейс + превью | ✅ |
| `demo.html` | Зеркало `index.html` (идентичны) | ⚠️ дубликат |
| `gradient.html` | Инструмент «Градиент»: настройки + превью | ✅ (тёмная тема) |
| `background-remover.html` | Инструмент «Удаление фона»: source/result | ✅ (тёмная тема) |
| `list-tools.html` | Каталог инструментов | ✅ |
- **Статика без JS** — интерактив (тумблеры/слайдеры/перетаскивание) брать из
кода `refs/`, не из HTML. HTML — только визуальная правда по разметке/классам.
- По 4 файлам собран инвентарь классов — это и есть спецификация kit'а:
`app-shell`, `page-grid`, `eyebrow`, `brand-mark`, `topbar`, `footer`,
`panel-heading`, `setting-group`, `settings-footer`, `settings-panel`,
`segmented`, `status`, `step-card` (`step-index`/`step-heading`/`step-type`/
`step-body`/`drag`), `color-field` (`swatch`/`native-color`),
`preview-toolbar`/`checker`
(`tile-canvas`/`remover-canvas`/`comparison-grid`), `result-meta`/`meta-row`,
`tool-card`/`image-card` (`tool-icon`/`tool-index`/ `tool-copy`),
`code-block`, `icon-btn`, `download-btn`, `reset-btn`, `lede`, `auto-note`,
`label`, `toggle`/`toggle-group`, `direction-grid`, `gradient-bar`.
- **Дыры в refs-html:** нет состояний empty/error/loading, нет глобальных
модалок/тултипов, i18n не представлен (все лейблы — английские брендовые).
Решения — см. §9.
## 2. Токены (фундамент нового дизайна)
Живут в `src/lib/styles/design2.css` как обычный `:root { … }` (плюс
`[data-theme='dark'] { … }`). Файл грузится **только** layout'ом новой ветки
(§5), поэтому глобально не конфликтует со старым `app.css`. Маппинг старых
токенов `web/src/app.css` → новые:
| Старый | Новый | Light | Dark |
| -------------- | -------------- | --------- | ------------------------ |
| `--bg` | `--background` | `#eef1f4` | `#11171d` |
| `--surface` | `--panel` | `#f8fafb` | `#182129` |
| `--text` | `--foreground` | `#17212b` | `#e8eef2` |
| `--text-muted` | `--muted` | `#6d7883` | `#91a0ac` |
| `--border` | `--line` | `#cbd3da` | `#33414c` |
| `--accent` | `--blue` | `#1769d2` | `#54a2ff` |
| — | `--cyan` | `#00a8c7` | `#00a8c7` |
| — | `--amber` | `#bd7411` | `#bd7411` |
| — | `--success` | `#25a96a` | `#25a96a` (статус-точки) |
| `--danger` | без изменений | `#e5484d` | `#ff7479` |
Прочее:
- Радиусы: один `--radius: 4px` (мягкость старой темы уходит).
- Шрифты: `IBM Plex Sans` + `IBM Plex Mono`, self-host через `@fontsource`
(offline-first, CDN нельзя). Mono — рабочий шрифт для лейблов/метаданных/
цифр; Sans — заголовки и текст. Задаются на `.app-shell` (корень новой ветки),
чтобы не зависеть от старых глобальных правил `body`.
- Фон-сетка (32px blueprint-grid) и шахматка канвасов — утилиты `.page-grid` и
`.checker-canvas` внутри `.ds2`, не токены.
- Тёмная тема — на `[data-theme='dark']` (инлайн-скрипт уже есть в `app.html`);
класс `.dark-mode` из рефа не переносим, используем наш механизм. На
переходный период **никаких алиасов старых токенов** (старые страницы сами
живут на своём `app.css`).
## 3. Базовые примитивы дизайн-системы (все — компоненты)
Правило фазы 1: **любой повторяющийся паттерн — компонент**, даже «просто div с
двумя стилями». Никакой копипасты классов между страницами. Каждый примитив ниже
— отдельный `.svelte` в `src/lib/components/kit/`.
- `MonoLabel` — микро-лейбл: mono 10px, uppercase, letter-spacing .1em, muted;
слот-модификатор accent (синие eyebrow/version).
- `StatusDot` / `StatusLine` — точка `--success` + подпись («AUTO PIPELINE»,
«LIVE PREVIEW»).
- `Panel` / `PanelHeading` / `SettingGroup` / `SettingsFooter` — панель с
1px-бордером, составные части с разделителями.
- `CheckerCanvas` — рабочая поверхность превью: тёмная подложка + шахматка.
- `MetaRow` / `MetaList` — пары «CAPTION значение».
- `Segmented` — сегмент-переключатель (язык, тип градиента, пресеты).
- `Badge` — тег типа шага (BACKGROUND/TRANSFORM/STYLE), чип «✓ AUTO».
- `StepCard` — нумерованная карточка этапа: индекс `01`, drag-handle, Badge
типа, заголовок, кнопка удаления; слот для параметров.
- `ToolCard` — карточка каталога (icon, index, title, copy, arrow).
- `ImageCard` / `PreviewTile` — label сверху + canvas-превью.
- `AppShell` — корневой контейнер `.app-shell` + `.page-grid` (обёртка новой
ветки маршрутов); задаёт шрифты и фон нового дизайна.
- `BlueprintGrid` — фоновая сетка (используется в `AppShell`).
- `IconButton`, `Icon` (обёртка `lucide-svelte`, 16–17px, muted).
## 4. Минимальный UI-kit (`src/lib/components/kit/`)
Контролы, поверх примитивов §3. API проектируем сразу под новый дизайн (обратной
совместимости со старым `ui/` не нужно — старые страницы его продолжают
использовать).
| Компонент | Назначение |
| ------------------------- | ----------------------------------------------------------------- |
| `Button.svelte` | `primary` (solid blue) / `secondary` (outline) / `ghost` + `icon` |
| `Field.svelte` | обёртка: `MonoLabel` + слот значения/контрола |
| `SliderField.svelte` | range + `<output>`, опц. строка подсказок |
| `ColorField.svelte` | swatch + hex-input + нативный picker |
| `TextField.svelte` | текстовый ввод в стиле дизайна |
| `SelectField.svelte` | селект в стиле дизайна |
| `CheckboxField.svelte` | чекбокс → switch-toggle 32×18 |
| `Toggle.svelte` | переключатель (для toggle-group) |
| `SegmentedControl.svelte` | на базе `Segmented` — RU/EN, Linear/Radial, пресеты |
| `Badge.svelte` | на базе `Badge` |
| `MetaList.svelte` | на базе `MetaRow` |
| `EmptyState.svelte` | пустое состояние под новый стиль |
| `CodeBlock.svelte` | блок кода (gradient-art / direction-grid) |
| `DownloadButton.svelte` | кнопка скачивания (можно переиспользовать идею старой) |
Иконки: `lucide-svelte` (те же пути, MIT), 16–17px, stroke, muted.
## 5. Изоляция старого и нового (критично)
Без префиксов и обёрток — только за счёт того, какие layout грузят какой CSS.
- **Корневой layout `src/routes/+layout.svelte`** перестаёт импортировать
`../app.css`. В нём остаётся только общая логика (init темы/locale) и
нейтральный `<div>{children}</div>` без глобальных стилей дизайна.
- **Старая ветка `src/routes/(old)/+layout.svelte`** импортирует `app.css` и
содержит текущую шапку/подвал (разметку из нынешнего корневого layout). Все
старые страницы переносятся внутрь `(old)/` (группа в скобках не меняет URL):
`(old)/+page.svelte`, `(old)/demo/...`, `(old)/list-tools/...`,
`(old)/tools/[id]/...`.
- **Новая ветка `src/routes/preview/+layout.svelte`** импортирует `design2.css`
и рендерит `<AppShell>`. Сюда же — новые страницы (§6).
- Благодаря code-splitting CSS в SvelteKit: на старых маршрутах грузится только
`app.css`, на `preview/*` — только `design2.css`. Скоуп стилей
Svelte-компонентов добавляет второй уровень изоляции. Никаких `.ds2`.
- Переключатель для ручной проверки: временный флаг `previewDesign` в
`localStorage` + кнопка в старой шапке («Preview v2»), ведущая на `/preview`.
На фазе 4 флаг убираем.
> Почему не `(new)`-группа с теми же путями вместо `preview/*`: две группы не
> могут одновременно определять один и тот же URL (`(old)/+page.svelte` и
> `(new)/+page.svelte` оба → `/` = конфликт маршрутов). Поэтому новая ветка
> живёт под префиксом `preview/`, а на фазе 4 файлы переносятся на реальные пути
> и группа `(old)` удаляется.
>
> Это совпадает с рекомендованным в документации SvelteKit паттерном: «put your
> entire app inside one or more groups except the routes that should not inherit
> the common layouts» — старое целиком в `(old)`, новое снаружи. Синтаксис
> `@`-breakout (`+layout@.svelte`, `+page@.svelte`) здесь не требуется:
> «выломаться» можно лишь до корня, а нам нужно, чтобы именно у корня НЕ было
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
> пригодится для сброса родительского layout.
## 6. Маршрутизация: историческая схема перехода
Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были
перенесены на production-маршруты. Эта схема нужна только для восстановления
контекста C17; текущая структура описана в разделе 7, отдельных preview- или
legacy-layouts в production больше нет.
## 7. Фазы выполнения
- **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и
интерактивные состояния приняты; детали перехода остаются в архивном
`plan-design-fix.md`.
- **C17/C18 — production-переход.** Новые страницы перенесены на реальные
корневые маршруты; `app.css` стал единственным production-стилем.
- **Q3/C19 — cleanup.** Удалены UI-v1, legacy CSS, v1-тесты, fixtures и
isolation-конфигурация; production build и verify проверяются отдельно.
- **C20 — следующий этап.** Финальный проход по брейкпоинтам и визуальной
приёмке production UI.
- **C21 — отдельная уборка.** Удалить `refs/` и `refs-html/`, если они больше не
нужны.
Критерии каждого этапа — зелёные `test`/`check`/`build`, отсутствие остаточных
legacy-путей в production-коде и ревью в браузере для UI-изменений.
## 8. Смоук (после фазы 3, контроль на фазе 4)
1. Светлая и тёмная тема: сетка фона, панели, шахматка канвасов читаются,
контраст mono-лейблов достаточный.
2. Инструмент с параметрами (градиент): slider/color/toggle в стиле
setting-groups, автозапуск не регрессировал.
3. Удаление фона: сравнение source/result, маска, пипетка работают на новой
шахматке.
4. Chain из 3+ инструментов: step-cards рендерятся, удаление/сброс работают.
5. Узкий экран 800px: настройки над превью, sticky выключается как в рефе.
6. RU/EN: новые лейблы локализованы; mono-лейблы рефа — либо ключи i18n, либо
осознанно английские брендовые элементы (см. §9).
## 9. Открытые вопросы
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
разложил его в `components/` (подпапки fields/layout/ui). На C21 — только
навести порядок внутри `components/`, переименование не требуется.
- **Единый production CSS:** `app.css` подключается в корневом layout; отдельных
legacy-layouts и переключателей версий больше нет.
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
ключи, но допустить часть английских брендовых («приборная панель»).
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
мелочь, решить при сборке topbar.
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка;
их можно удалить в C21, они не являются частью рантайма.
## 10. План исполнения (мелкие коммиты)
Ветка `feat/redesign`. Каждый шаг — отдельный коммит, цель < ~500 строк. Перед
стартом: `test`/`build` зелёные на базе (якорь регрессии).
### Шаг 0. Изоляция layout (без дизайна)
- [x] **[C1]** Сделать корневой `routes/+layout.svelte` нейтральным: убрать
`import '../app.css'`, оставить только init темы/locale и
`<div>{children}</div>`.
- [x] **[C2]** Создать `routes/(old)/+layout.svelte` (импорт `app.css` + старая
шапка/подвал) и перенести старые страницы (`+page`, `demo`, `list-tools`,
`tools/[id]`) в `(old)/`. URL не меняются. Проверка: старый сайт
идентичен. _(файлы — move, объём строк минимален)_
### Шаг 1. Фундамент нового дизайна
- [x] **[C3]** Создать `src/lib/styles/design2.css` (токены `:root` +
`[data-theme='dark']` по §2) и подключить `@fontsource/ibm-plex-sans` +
`@fontsource/ibm-plex-mono` (self-host, offline-first). подключить шрифты
в `design2.css` (self-host, offline-first).
- [x] **[C4]** _(слит в C3: шрифты нельзя подключить до создания `design2.css`)_
`[data-theme='dark']` (таблица §2). Без утилитарных классов.
- [x] **[C5]** Примитивы batch 1: `AppShell`, `BlueprintGrid`, `CheckerCanvas`,
`MonoLabel`, `StatusDot`/`StatusLine`.
- [x] **[C6]** Примитивы batch 2: `Panel`, `PanelHeading`, `SettingGroup`,
`SettingsFooter`, `MetaRow`/`MetaList`, `Segmented`, `Badge`, `StepCard`,
`ToolCard`, `ImageCard`/`PreviewTile`.
- [x] **[C7]** Контролы: `Icon`, `IconButton`, `Button`, `Field`, `SliderField`,
`ColorField`, `TextField`, `SelectField`, `CheckboxField`, `Toggle`,
`SegmentedControl` (= `Segmented` из C6), `EmptyState`, `CodeBlock`,
`DownloadButton`.
### Шаг 2. Витрина kit (проверка компонентов)
- [x] **[C8]** `preview/+layout.svelte` (импорт `design2.css` + `<AppShell>` +
переключатель темы) и временная `preview/kit` страница со всеми
состояниями (light/dark, hover/disabled, варианты Button/Segmented/Badge и
пр.)
### Шаг 3. Новые страницы
- [x] **[C9]** `/preview` — воркспейс (eyebrow, h1, lede, поиск, «открыть
последний»). Поиск фильтрует `TOOLS` из `$lib/registry`; карточки
результата пока ведут на `#` (реальные `/preview/tools/[id]` подключим на
C12, иначе prerender падает на 404).
- [x] **[C10]** `/preview/list-tools` — каталог карточек (группировка по
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
подключим на C12).
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью),
статичный приёмочный стенд.
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel +
preview-panel (gradient live, остальные — плейсхолдер); `NumberField`,
`entries()` для prerender, ToolCard-ссылки подключены.
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source
инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`);
охватывает background-remover и прочие.
- [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги
(параметры inline) + «Add step» (chainable), живой прогон пайплайна на
исходнике, превью каждого шага + итог + Download.
### Шаг 4. Переключатель и проверка
- [x] **[C15]** В старой шапке ссылка «Preview v2» → `/preview`, ставит
`localStorage['easy-png-tools:preview-v2']='1'`.
- [x] **[C16]** База под дизайн: рабочий переключатель тем (persist в
localStorage, применяется на `<html>`; проверено — фон
`#eef1f4`↔`#11171d`), `TopBar` приведён к виду `demo.html` (бренд + синий
хлебный крош `/* страница */`, безрамочные иконки, язык — active =
`--foreground`/`--background`), страницы `/preview/*` на всю ширину (убран
`max-width`). Точечное выравнивание к рефу отложено: реф-дизайн
неконсистентен. (доказательство отсутствия регресса). Фиксы по результатам
— отдельными мелкими коммитами.
### Шаг 5. Сделать новый дизайн основным
> C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты
> перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов
> после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и
> код. Остались C20 и C21.
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
shell. Убрать флаг/кнопку. **Снять `noindex, nofollow`** со страницы
инструмента (`preview/tools/[id]/+page.svelte`) — страницы становятся
боевыми лендингами ровно в момент переезда (SEO: `plan-seo.md`, S1a).
**Временное решение:** если домен ещё не куплен, `noindex` можно оставить
до S1 — тогда C17 делается без снятия `noindex`, а снятие происходит
вместе с подключением canonical в S1. **И обновить `newCode` в
`web/eslint.config.js`:** заменить `src/routes/preview/**` на
`src/routes/**` (после переезда все маршруты — новый дизайн), компонентный
glob `src/lib/components/kit/**` пока оставить. _(Итог: `newCode` обновлён
(`components/**`, `routes/**`), `noindex` оставлен до S1 — домен ещё не
куплен. Детали: `docs/archive/plan-c17.md`.)_
- [x] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
_(Билд собирается без ошибок, линт/свелт-чека нет — выполнен.)_
### Шаг 6. Проверка нового дизайна
- [x] **[C20]** Финальный проход: grep по старым токенам и классам пуст (0
совпадений по `--bg`/`--surface`/`--text`/`--blue`/`--panel` в `web/src`),
`app.css` — единственный production-стиль, `components/` разложен как в
C17 (`fields`/`layout`/`ui`), production-маршруты и пользовательские
потоки покрыты e2e. Визуальная приёмка живёт в
`docs/checklist-manual-testing.md`.
### Шаг 7. Cleanup
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
из конфигурации и активной документации.
- [x] **[C21]** `refs/` и `refs-html/` удалены — каталогов в репозитории нет.
- [x] Навести порядок в `components/`: подпапки `fields`/`layout`/`ui`.
> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
> После удаления проверяются только production-маршруты и общие модули.
>
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
> чтобы регрессия локализовалась одним шагом назад.
>
> **Атомарность и точки «поломки» между коммитами.**
>
> - C1+C2 уже сделаны вместе (связаны: нейтральный root без `(old)`-layout
> ломает старый сайт). Аналогично связанных пар больше нет.
> - C3 и C4 слиты: шрифты подключаются внутри `design2.css`, поэтому файл должен
> существовать в том же коммите.
> - Коммиты **C3→C16 не трогают старый сайт** `(old)/` — они только добавляют
> новые файлы/маршруты, сломать старый сайт на них нельзя.
> - **C17 — атомарный переключатель**: перенос `preview/*` на реальные пути +
> удаление `(old)/` + смена root-layout — в ОДНОМ коммите. Снятие `noindex`
> также в C17, **если домен уже куплен**; иначе — отложить на S1. Не дробить
> сам C17.
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны
> существовать до страниц, которые их импортируют (C8–C14).
## Бэклог полировки — закрыт по итогам реализации
Пять пунктов проверены по коду: четыре сняты как выполненные, один перенесён в
`docs/backlog.md`.
- `Toggle` прямоугольный — выполнено (`border-radius: 0`).
- `Segmented`: последняя кнопка не растянута — выполнено (`flex: 1` у пунктов).
- Отступ в `DownloadButton` между текстом и size-чипом — снято как неактуальное:
size-чипа больше нет, компонент тонкая обёртка над `Button`.
- Паддинги в полях — выполнено: `TextField` и `SelectField` имеют
`padding: var(--space-s) var(--space-m)`.
- Радиусы бордеров против референсов — перенесено в `docs/backlog.md`.
-81
View File
@@ -1,81 +0,0 @@
# Хранить данные для сортировки по популярности инструментов
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> ранний исследовательский вариант, не являющийся текущим планом Использование:
> только историческая справка
Идея выложить на Vercel и использовать Vercel KV (на базе Upstash Redis) — это
отличный, рабочий и очень быстрый в реализации вариант. Для 150 инструментов
объем данных будет крошечным, но здесь есть пара критически важных нюансов по
лимитам, о которых нужно знать на старте, чтобы бесплатный тариф не отключился в
первый же месяц. [1]
## ⚠️ Подводный камень: Лимиты Vercel KV на Free-тарифе
На бесплатном тарифе (Hobby) для Vercel KV действуют следующие ограничения: [2]
- 30 000 запросов в месяц (суммарно чтение + запись).
- Ограничение около 3 000 запросов в день. [2, 3]
Если делать запрос к базе на каждый просмотр страницы («посещение»), лимит в 30
тысяч улетит мгновенно при первой же паре сотен посетителей в день.
## 💡 Как сделать это правильно и бесплатно
Чтобы не выйти за рамки лимитов, архитектуру нужно построить экономно:
## 1. Чтение: Сортировка на этапе сборки (ISR / SSG)
Не нужно запрашивать KV из Edge-функции при каждом открытии сайта пользователем.
- Используйте Incremental Static Regeneration (ISR) (если вы делаете на Next.js)
или раз в сутки запускайте Cron-сид (Vercel Cron).
- Сайт раз в несколько часов запрашивает весь список из KV (всего 1 запрос на
получение общей структуры), сортирует инструменты и кеширует эту страницу как
статическую.
- Результат: 100 000 пользователей увидят отсортированный список, а вы потратите
всего пару запросов к KV в день.
## 2. Запись: Защита от накрутки и экономия («Добавление в избранное»)
«Избранное» — идеальная метрика для KV, так как её нажимают редко. А вот
«Посещения» лучше отправлять не на каждый клик, а пачками (батчингом) или
использовать для просмотров бесплатную Vercel Web Analytics (там лимит 25 000
событий в месяц обновляется отдельно).
---
## 💻 Оптимальная структура данных в Redis (KV)
Вместо 150 отдельных ключей, храните всё в одной хэш-таблице (Hash) или
сортированном множестве (Sorted Set). Это сэкономит количество запросов. Вариант
структуры через Hash:
```js
// Запись / инкремент (в API роуте Vercel)
import { kv } from "@vercel/kv";
//Когда пользователь зашел на инструмент 'crop-png'
await kv.hincrby("tools:visits", "crop-png", 1);
// Когда добавил в избранное
await kv.hincrby("tools:favorites", "crop-png", 1);
```
Как забрать всё одним запросом для сортировки:
```js
// Получаем сразу все просмотры (вернет объект вида { 'crop-png': '120', 'resize-png': '85' })
const allVisits = await kv.hgetall("tools:visits");
const allFavorites = await kv.hgetall("tools:favorites");
// Дальше соединяем в массив на клиенте/сервере и сортируем:
// Популярность = Просмотры + (Избранное * 5) <- так лайки весят больше
```
## ⚡ Альтернатива: Cloudflare Workers + KV
Если вы боитесь упереться в 30 000 запросов на Vercel, рассмотрите связку
Cloudflare Workers + Workers KV. Там на бесплатном тарифе дают: [4]
- 100 000 запросов на запись в сутки.
- 10 000 000 (10 млн) запросов на чтение в сутки.
- Для пет-проекта этого хватит навсегда, даже если вы будете делать запросы
напрямую с фронтенда без кеширования.
-276
View File
@@ -1,276 +0,0 @@
# Backlog
> Статус: **active**. Продуктовые, UX- и инфраструктурные задачи. Здесь только
> **открытые** задачи: выполненные удаляются, а критичные для проекта факты из
> них переносятся в тематический документ (архитектура, quality gates, lint,
> e2e) по мере закрытия.
>
> «Что сознательно не вводим и почему» живёт в `docs/decisions.md`, архитектура
> — в `docs/architecture.md`. Raw-находки UX-аудита — `docs/tools-audit.md`, их
> разбивка на волны — `docs/plan-fields-audit-fixes.md`.
>
> Ids инструментов сверять с `web/src/lib/registry/`; полный список —
> `docs/tools-map.md`.
## lint и прочие правила
- [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `typescript-eslint` —
`web/eslint.config.js`.
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого действует pattern-based policy-исключение
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
## Мелочи всякие
Порядок правок полей и раскладки — `docs/plan-fields-audit-fixes.md`, сами
находки («что плохо», с датой аудита) — `docs/tools-audit.md`. Здесь — как это
исправить; находка в аудите не отменяет задачу здесь.
- [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют
количество инструментов, точное совпадение текстов, поиск по
`img[alt="Result image"]`, svelte классам и тому подобные
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
какой размер и 2 сетки)
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] можно ли в тестах получать i18n ключ вместо текста?
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
единую структуру, позволит упростить некоторые вещи - например
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть
в кнопке Download
- [ ] Кнопка download должна сразу писать какой формат сейчас, быть с селектором
формата и подсвечивать предупреждение если формат не совместим (альфа
канал)
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей)
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] проверить настройки range slider на crop-png - слишком большие значения
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
значения
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
изменено
- [ ] обновить страницу kit или сделать storybook
- [ ] Свести convert-страницы на один инструмент `convert` — реализация уже
умеет «скопировать пиксели, декодировать вход, отдать в заданном формате»,
а страницы различаются только форматом вывода. **Зависит от S1f**
(`docs/plan-seo.md` §1): пока `quality` живёт в схеме страницы, у
`convert-png-to-bmp` и `convert-png-to-jpg` разные наборы полей, и один
инструмент на них не сойдётся. Качество — настройка кнопки Download
(«Download as JPEG»), а не поле страницы; после его переезда пресеты схемы
не нужны, страницы различаются только `output` и `params` шага. Модель
страницы к этому готова: `Page.steps` — список инструментов, один
инструмент может обслуживать много страниц (`docs/architecture.md`, раздел
3).
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
удаление, добавление инструментов. переход один инструмент - pipeline без
перезагрузки
- [ ] `CatalogToolbar`: фильтры используют сырой `<button>`, а не `ui/Button` —
единый контрол для всей библиотеки
(`web/src/lib/components/CatalogToolbar.svelte`).
- [ ] Вынести `ToneVariant`/варианты в общий модуль: `const`-объект с тонами
продублирован в `ui/Badge.svelte` и кнопках
(`web/src/lib/components/ui/Badge.svelte`).
- [ ] `PreviewTile`: canvas должен сам выбирать соотношение сторон в диапазоне
16:9…9:16 вместо фиксированного
(`web/src/lib/components/layout/PreviewTile.svelte`).
- [ ] `ThemeToggle`: свои анимации иконок и своя кнопка вместо `IconButton`
(`web/src/lib/components/ui/ThemeToggle.svelte`).
- [ ] Радиусы бордеров не совпадают с референсом: в `refs` радиусы были 0, а мы
взяли ненулевой `--radius-m` для всех бордеров. Проверить токен против
референсов; возможно он должен быть 0, а скругления оставить только там,
где они реально есть в дизайне.
- [ ] `Footer`: версию брать из `package.json`, а не писать `v0.1.0` вручную
(`web/src/lib/components/layout/Footer.svelte`).
## Переезд
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
`tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog
## SEO/GEO
Общий план: `docs/plan-seo.md` (S1–S4). Старт возможен — пока только domain-free
часть S1. Полное содержание этапов не дублируется здесь, только идентификаторы.
- [ ] переименование бренда + домен: чек-лист в локальном файле
`plan-domain.local.md` (в git не входит, в клоне отсутствует; подробности
и порядок — `docs/plan-seo.md` §6, R1/R2)
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
- [ ] download с селектором формата PNG/JPG/WebP/BMP для всех инструментов (райз
«Download» → S1f)
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
вордстату с уникальным контентом; редиректы только для опечаток и
переименований уже опубликованных страниц; клонов с canonical не делать
(`docs/plan-seo.md` §5)
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
- [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e)
- [ ] контент страницы инструмента: seo-блок в словарях, H1/how-to/FAQ/related
(S2)
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
- [ ] GSC + приватная аналитика (Plausible/Umami/CF), дистрибуция (S4)
## Платформа (API/PWA)
Общий план: `docs/plan-platform.md`; WASM-ядро, эталоны и CLI —
`docs/roadmap.md` (фазы 2–8). Здесь только треки, которых нет в roadmap.
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
(после wasm-фаз 5–7)
- [ ] Шрифты: лицензирование (F1–F4) и DOM-free рендер (F5–F6) — до фазы 8
(`docs/plan-platform.md` §4)
## Качество production UI
Политика проверок — `docs/quality-gates.md`, e2e — `docs/testing-strategy.md`.
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
## Идеи
Идеи 1–5 и 15 относятся к **пайплайну (цепочкам шагов)**, которого в коде пока
нет: это фаза 11 `docs/roadmap.md`. Идеи 6–14 — к существующим инструментам и
UI. Оценки (S/M/L) — оценка труда, не приоритет.
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
зафиксирован при открытии страницы; хочется заменить его на другой без
разборки цепочки: шаги и их параметры сохраняются, вход перечитывается.
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
file).
2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
помнить в workspace-pipeline.
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
Формат экспортного JSON расширить опциональным полем имени; обратной
совместимости со старыми выгрузками не делаем (см. задачу про `id`/`slug`
выше — формат ещё не устоялся).
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
chain по каждому файлу, сборка результата обратно в zip для скачивания.
Зависит от идеи 3 (выбор цепочки) и от экспортного формата pipeline JSON;
прогресс и ошибки — пофайлово.
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
карточках каталога, результатах поиска и в шапке страницы инструмента; список
slug в localStorage. Избранное показывается отдельной секцией сверху каталога
и поднимается в выдаче поиска (бонус к popularity при скоринге).
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
— оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и
задача **S1f**; почему селектор не заменяет существующие convert-страницы —
`docs/plan-seo.md` §5. Механическая часть уже есть: `Tool.output`
(`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в
`SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось
сверх S1f:
- экспорт с лимитом размера → закрывает отложенную страницу
`reduce-to-size-png` (целевой KB, бинарный поиск по k из квантования).
Проработать вместе с достройкой download-флоу.
7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна
overlay-механика: второй источник изображения (getOverlay/store), параметры
scale/opacity/position/margin/aspect. Решается вместе с вопросом, как второй
источник картинки вписывается в schema-driven preview (`SchemaToolView`).
8. **`png-info` (детальная информация о PNG)** — оценка M/L. Отдельный райз:
exif-теги, редактирование, структурированный вывод; нужен свой отдельный
случай в UI, не «ещё один text/verdict». Возможно нужна вообще отдельная
страница, несовместимая с другими инструментами: 1) она работает не с
изображением, а с метаданными из файла, 2) она не может быть частью цепочки,
т.к. результат не является изображением, 3) если надо изменить теги, никакого
форматирования и изменения в изображение вносить не надо, только в
метаинформацию.
9. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
reverse-area) — ждут UI выделения области на превью. Вне очереди.
10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
— механизм «результат = набор файлов» (1 → many) уже есть
(`docs/architecture.md`, раздел 7), первый потребитель —
`split-into-parts-png`. Остальные подключаются по мере нужды.
11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
решение о формате.
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital).
13. **Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог `palette.test.ts`;
targeted-тест проходит. Light-тема всё ещё ниже AA 4.5 для обычного текста,
а `--color-danger` в light-теме на границе: после завершения редизайна
вернуть порог 4.5 и перепроверить оба токена.
14. **Отображение маски для инструментов (`runMask`)** — придумать и реализовать
процесс показа маски результата. **Контекст:** удалённый UI-v1 умел
показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
переходе на единый `run(ctx)` этого поля не осталось — executor и
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
дать каждому `run` возможность вернуть (или инструмент — объявить) `runMask`
— ч/б изображение маски изменений либо иной подход (отдельное поле `mask` +
функция маски, второй результат, overlay). Оценка — на усмотрение при
проработке (S/M). Пока живём без неё. **Идея 2:** каждый `run` возвращает
два изображения — для отображения (показывает маску или сам результат) и для
передачи в следующий инструмент (всегда результат). Оценка — на усмотрение
при проработке (S/M). Пока живём без неё. Первые кандидаты на маску:
`remove-color-from-png`, `remove-background-png`.
15. **Набор файлов в пайплайне: 1 → many не должен быть терминальным** — оценка
L. **Сейчас** `execute` принимает один `source: PixelImage | undefined` и
возвращает один `ToolResult`, а `SchemaToolView` раскладывает вывод в
`result` / `fileResult` / `textResult` по `resultKind` — поэтому шаг с
`result: "files"` упирается в конец цепочки. **Направление:** следующий
инструмент применяется **ко всем картинкам** предыдущего результата
(fan-out), а many → 1 (вход = архив или несколько файлов) — обратная сторона
того же контракта. Терминальными остаются только результаты, которые не
картинка (text, verdict). Что обдумать:
- где проходит граница: один вызов `run(ctx)` на файл (имя части склеивается
в `FileResult.files`) или батч целиком на стороне исполнителя;
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
прогресс и отмена;
- частичные ошибки: один битый файл не должен ронять весь шаг — нужен
per-file отчёт, а не один `ToolError` на шаг;
- имена частей на каждом шаге и Download для набора (сейчас zip по
`tool.id`, `SchemaToolView.download()`);
- обратная сторона: распаковка zip в браузере, порядок файлов, поведение
many → 1 → many в одной цепочке. Зависит от идеи 10 (остальные потребители
1 → many) и идеи 4 (batch по архиву), `docs/roadmap.md` фаза 8
«batch-страница».
16. **Главная должна отличаться от каталога** — оценка S. Сейчас
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
Замысел: главная — крупнее и с более простым поиском, без категорий; каталог
остаётся группировкой с фильтрами. Детектор дублирования разметки
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
ловится на ревью.
-105
View File
@@ -1,105 +0,0 @@
# Чек-лист ручного тестирования production UI
> Вспомогательный документ к e2e-тестам (`web/e2e/`, `pnpm --dir web test:e2e`)
> и стратегии `docs/testing-strategy.md`. Автоматизированная часть закрывает
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
>
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
> → `node scripts/serve-static.mjs`).
>
> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания»,
> сообщения об ошибках человекочитаемые**.
## A. Краевые PNG-файлы
> Фикстуры для этого раздела генерируются скриптом: `pnpm generate:fixtures` →
> `tests/fixtures/manual-edge-cases/`.
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
remove-background, feather-edges, clean-edges дают корректные края.
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется
(авто-покрытие: `web/e2e/png-fixtures.spec.ts`).
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
`pipeline.spec.ts` и `png-fixtures.spec.ts`).
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`.
### Файлы (`tests/fixtures/manual-edge-cases/`)
| Файл | Описание |
| ------------------------ | ------------------------------------------------------------ |
| `1x1.png` | Минимальное изображение 1×1, сплошной красный |
| `1x1-transparent.png` | 1×1, полностью прозрачный (alpha=0) |
| `2x2-extreme.png` | 2×2: чёрный, белый, полупрозрачный красный, прозрачный синий |
| `1024x1024-checker.png` | 1024×1024, шахматка ≈1 мегапиксель |
| `1920x1080-gradient.png` | 1920×1080, градиент ≈2 мегапикселя |
| `alpha-checkerboard.png` | 64×64, чёрно-белая шахматка с чередующейся альфой |
| `alpha-gradient.png` | 128×64, альфа-градиент 0→255 по ширине |
| `sparse-alpha.png` | 100×100, 10% пикселей с alpha=0 |
| `all-black-opaque.png` | 64×64, сплошной чёрный |
| `all-white-opaque.png` | 64×64, сплошной белый |
| `all-transparent.png` | 64×64, полностью прозрачное |
| `palette-4colors.png` | 32×32, палитровый 4 цвета (RGBW) |
| `palette-256.png` | 64×64, палитровый полная палитра 256 цветов |
| `16bit-subtle.png` | 32×32, 16-bit RGBA, тонкий градиент |
| `noise-pattern.png` | 100×100, шумовая текстура |
| `1x4000-strip.png` | 1×4000, длинная вертикальная полоса |
| `4000x1-strip.png` | 4000×1, широкая горизонтальная полоса |
| `corrupt-bad-crc.png` | 4×4, повреждённый CRC в IDAT |
| `truncated.png` | Обрезанный файл (только PNG-заголовок + часть IHDR) |
| `fake-png.txt` | Текстовый файл «.png» — не-PNG |
## B. Края параметров
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate — не
«залипает» running.
- [ ] Пустые/NaN поля (напр. width/height) — санитизация (silent repair), нет
красного alert-текста в виде `errors.*`.
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
именные цвета) — silent repair или понятная ошибка.
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет.
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
## C. Буфер обмена и drag-n-drop
- [ ] В реальном браузере кнопка copy на текстовом результате (png-to-base64 и
т.п.) кладёт текст в буфер (в Playwright не тестируем из-за permissions).
- [ ] Drag-n-drop файла на dropzone страницы инструмента (не только файл-диалог)
— UX-ощущения и корректность.
## D. Производительность и отзывчивость
- [ ] Долгие авто-рераны на больших картинках (jpeg-artifacts, dithering,
quantize на 4К) — UI не фризит, канселяция/дебаунс срабатывает.
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
залипаний, панели не прыгают.
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
(утечек blob-URL/bitmap быть не должно).
## E. Визуальный слой (доделываем по ходу)
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
desktop) на всех 4 маршрутах.
- [ ] Тема light/dark: контраст вердиктов (Yes/No), статус «LIVE PREVIEW»,
мета-инфо бликов не даёт.
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
## F. Субъективная корректность
- [ ] dithering / two-colors / quantize — результат «по ощущениям» соответствует
описанию.
- [ ] watermark-tile / add-text / date-stamp на кириллице — рендер текста
корректный (не «кракозябры»).
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
## Как долго
- Полный прогон: все разделы A–F, включая мегапиксели и 4К, в двух браузерах.
- Быстрый смоук: A (кроме мегапикселей), B (выборочно), C, F выборочно.
> Сценарии, закрытые e2e-тестами, в этот чек-лист не попадают: их покрытие видно
> по спецификациям в `web/e2e/`, политика e2e — `docs/testing-strategy.md`.
-172
View File
@@ -1,172 +0,0 @@
# Решения по гейтам и тестам
> Статус: **active**. Журнал решений «что сознательно НЕ вводим» и «как читаем
> результат CI». Только про гейты, линтеры, coverage, e2e и CI — **для обычных
> правок production-кода не нужен**. Читать, когда проектируется или меняется
> сам гейт, либо когда спрашивают «почему так, а не иначе». Оперативные команды
> и пороги — в `docs/quality-gates.md`, правила нагрузки на машину — в
> `docs/agent-workflow.md`, раздел 3.
Здесь лежат решения, чтобы не пересматривать их заново. Для каждого: причина и
то, что решение пересмотрело бы. В `docs/backlog.md` такие пункты не
дублируются: backlog живёт задачами, а не историей решений.
Числа ниже — разовые замеры, а не текущие значения: помечены датой. Актуальные
цифры даёт прогон (`docs/quality-gates.md`), по правилу `AGENTS.md` в документах
нет величин, которые меняются вместе с кодом.
## 1. Детектор дублирования разметки — не вводим
Ни свой AST-правило, ни готовая зависимость (jscpd, `eslint-plugin-sonarjs`).
- Замер по корпусу `.svelte` в `components/**` и `routes/**` (сентябрь 2026):
при пороге 5 нормализованных строк — одна осмысленная пара, при пороге 3 — 17
пар, и все они шум (`</label>`, `{/each}`, `{#if children}`). Семейство
schema-контролов делит максимум 4 строки — законное сходство by design.
- Причина: построчное сравнение Svelte-разметки принципиально ненадёжно —
одинаковый контейнер с разным содержимым даёт ложное срабатывание, а намеренно
общий класс — ложную тишину. Чтобы не ловить шум, порог пришлось бы поднять до
12+ строк, и тогда правило срабатывает ровно один раз.
- Контроль остаётся на ревью (расхождение видно в диффе) и на поведенческих
e2e-проверках; извлечение общей разметки закреплено конвенцией в `AGENTS.md`.
- Пересмотреть, если появится реально «уехавшая» копипаста в компонентах или
библиотека разрастётся на уровень композиций страниц.
## 2. Линтер согласованности комментариев — не вводим
Описания инструментов различаются осознанно, и ловить это текстовым правилом
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
`TODO`/`FIXME` перенесены в backlog, незарегистрированных пометок в коде нет;
новые запрещены правилом в `AGENTS.md`.
## 3. Линтер «изменяемых чисел» в документации — не вводим
Правило «не фиксировать числа, которые меняются вместе с кодом» живёт в
`AGENTS.md` и держится на ревью. Автоматизация отклонена по замеру (сентябрь
2026): regex «число + существительное» на корпусе Markdown дал 15 совпадений, из
которых 14 — ложные («30 дней» политики архива, «9 опций» описания поля, «4
проекта» факт из `playwright.config.ts», цитата самого правила).
- Правило всё равно неполно: ловит «122 инструмента», но не «инструментов —
122», не «более 100», не «≈120»; на каждое обходное движение нужна правка
правила, а allowlist легитимных чисел становится вторым источником долга.
Худший исход — не «шумно», а «вроде гейт, а толку нет»: срабатывания на пустом
месте забейзелят, и через месяц правило перестанет ловить настоящие нарушения.
Видимое блокирующее правило, которое на деле ничего не проверяет, хуже
отсутствия правила.
- Соблюдение правила дешевле обхода: «список — в `docs/tools-map.md`» стоит
столько же символов, сколько цифра, но не требует обновления.
- Пересмотреть, если появится источник истины, из которого число можно
**вычислить** и сравнить автоматически, без разбора прозы. Такой случай есть и
решён отдельно — следующий пункт.
## 4. Сверка `docs/tools-map.md` с реестром — автоматизирована
Правило про числа не закрывает более опасный класс расхождений: документ не
устарел числом, а **перечисляет то, чего нет в коде**. В разделе «Реализовано»
карты лежали несуществующие страницы (`png-info`, `color-wheel-generator`,
`jpg-to-png`, `watermark-image-png`), которые агент принял бы за готовые
инструменты. Счёт при этом был верный — число такой класс не ловит.
Сверка множеств отвечает на вопрос однозначно и без allowlist, поэтому
автоматизирована: `web/src/lib/registry/tools-map-doc.test.ts` парсит раздел
«Реализовано» и сравнивает slug с `PAGES` в обе стороны (slug без страницы и
страница без записи), плюс ловит повтор slug между буллетами. Формат строки
карты — всё до первого тире, slug через `/` или `,` — закреплён в самом
документе и в тесте. Сверяются именно страницы, а не инструменты: карта
описывает то, что видит пользователь, а один инструмент может обслуживать
несколько страниц (см. `docs/architecture.md`, раздел 3).
Чтобы сломанный парсер не выглядел как успех, у теста есть отдельная проверка
«разбор не пустой»: если формат карты снова поменяется, тест упадёт с сообщением
про парсер, а не молча перестанет что-либо проверять. Входит в обычный
`pnpm --dir web test`; новых гейтов и конфигов не добавляет.
## 5. Visual snapshots — не вводим
`toHaveScreenshot` сравнивает страницу с PNG-baseline из прошлого коммита, а не
с замыслом дизайна. При активно меняющемся визуальном языке это превращает
каждый дизайн-PR в регенерацию и ревью картинок, а baseline платформенные
(снимок с Windows не совпадёт на `ubuntu-latest`). Потребность закрывают
e2e-поведение, `lint:tokens` с token audit, i18n-гейт и ручной чеклист
`docs/checklist-manual-testing.md`.
Вернуться стоит, когда визуальный язык стабилизируется; тогда первым идёт `/kit`
в двух темах, а baseline рождаются на Linux.
## 6. Fail-режим для unused-token аудита — не вводим
Остаётся предупреждением: `verify` входит в deploy-workflow, поэтому стилевой
долг блокировал бы деплой сайта. Кроме того, аудит видит только ссылки
`var(--x)`, и чтение токена из JS дало бы ложное срабатывание. Правило «новый
токен — вместе с потребителем» живёт в `AGENTS.md`.
## 7. Per-project `expect.timeout` — не задаём
Firefox и WebKit отдают результат так же быстро, как Chromium: расхождения
объясняются памятью, а не скоростью движка. Таймаут под конкретную машину —
магическое число, которое придётся подстраивать на каждом раннере, поэтому
Playwright-конфиг остаётся с дефолтами, а проблема решается лимитом воркеров.
Практические правила — `docs/agent-workflow.md`, раздел 3.
## 8. Дублирование `verify` между workflow — сознательно
`Deploy to GitHub Pages` и `Quality` оба зовут `verify`: deploy проверяет
быстрый gate и собирает сайт, `Quality` прогоняет тот же `verify` и browser
matrix. По замеру (сентябрь 2026) `verify` — единицы минут, matrix — около трёх,
поэтому дублирование дешевле условных job-и, а matrix дёшево держать на каждом
PR. Актуальные длительности — в логе прогона. Ручной
`gh workflow run quality.yml` нужен только для перепрогона старого коммита.
## 9. Политика e2e в CI
Текущее состояние: `e2e` — non-blocking baseline, запускается на каждом пуше в
`main` и на каждом PR целиком (все проекты matrix). Ночного разделения нет: при
стоимости job около трёх минут оно не окупается, поэтому новые browser-critical
спецификации просто добавляются в `browserContractTests`.
`e2e` станет blocking, когда выполнятся три условия:
1. Условия matrix пересмотрены: решено, какие спецификации обязаны идти в
`browserContractTests` и `mobileTests`, нужен ли `png-fixtures` вне Chromium
и что делать с engine-специфичными расхождениями декодеров.
2. Накопились стабильные прогоны на раннере без падений по таймингу.
3. Вместе с `continue-on-error` снимается и сам trick с общим conclusion: иначе
зелёный прогон при красном `e2e` останется возможен.
Пока условия не выполнены, `e2e` остаётся non-blocking, а его статус читается по
отдельному job — см. следующий раздел.
## 10. Чтение результатов CI после пуша
Первый реальный прогон browser matrix происходит на runner, а не на локальной
машине: локально Firefox и WebKit проходят на 1 воркере, но состав, скорость и
память Linux-раннера отличаются. Поэтому после пуша разработчика:
1. дождаться завершения workflow и посмотреть, какие job вообще запустились;
2. смотреть статусы **отдельных job**, а не общий conclusion прогона: у `e2e`
стоит `continue-on-error: true`, поэтому прогон с упавшим e2e целиком
помечается как `success` — проверено на run `36225579363` (`E2E: failure` при
`conclusion: success`);
3. если `e2e` запускался и зелёный — сообщить фактические числа passed, failed,
skipped;
4. если `e2e` не запускался, сказать это прямо: matrix на раннере не проверена,
и её можно прогнать через `gh workflow run quality.yml`;
5. красный `e2e` только из-за ресурсов раннера фиксируется как ограничение
окружения: сначала проверяются один проект и один spec
(`docs/agent-workflow.md`, раздел 3), а не увеличиваются таймауты.
## 11. Тексты страниц: в базовой локали секция `pages` пустая
Заголовки и описания страниц переводятся (`pages.<slug>.*`), а EN-строка лежит в
реестре страниц — дублировать её в `en.ts` нельзя, иначе при правке реестра
английский текст разъедется со страницей, а `dict-consistency` этого не увидит.
Поэтому `en.ts` объявляет `pages: {}` — пустую секцию, а линтер правила
`i18n/dict-consistency` не считает это нарушением: в базовой локали отсутствие
`pages.*.title/description` разрешено опцией `baseLocaleFallback`, а полнота
перевода для остальных локалей проверяется строго и юнит-тестом
(`tools-i18n.test.ts`).
Альтернатива — `allowPaths: ["pages"]` — была бы слабее: она разрешила бы пустую
секцию `pages` в любой локали, то есть пропущенный перевод страницы прошёл бы
линтер молча. Пустая секция в EN и есть то, что делает правило точным.
-408
View File
@@ -1,408 +0,0 @@
# План: исправления по аудиту UX/UI
> Статус: **draft** — на ревью.
>
> Находки — `docs/tools-audit.md` (что плохо, с датой прохода). Этот документ
> задаёт **порядок правок** полей и раскладки волнами; сами задачи — в
> `docs/backlog.md`. Проверки и гейты — `docs/quality-gates.md`; точки касания
> при добавлении вида поля — `docs/architecture.md`, раздел 4.
>
> **Идентификаторы шагов — `FA-*` (fields-audit).** Буквы `C*`/`Q*` в других
> документах относятся к архивным волнам редизайна и здесь не используются.
>
> **Как читать волны.** Шаг с пометкой «реализовано» — закрытый, его правку
> можно не повторять; шаг без пометки — работа. Пометка стоит в первом абзаце
> шага, а не в заголовке, чтобы не потерять её при чтении оглавления. Задачи,
> которые в бэклоге, здесь не дублируются: этот документ задаёт **порядок**,
> backlog — **учёт** (`docs/backlog.md`).
>
> **Инструменты в этом документе названы по адресам страниц (slug).** В коде у
> страницы `slug` (`registry/pages/`), у вызываемого инструмента — `id` без
> png-интента (`crop-png` → `id: "crop"`, `png-to-hsl` → `to-hsl`), модель —
> `docs/architecture.md`, раздел 3. Названия из `docs/tools-audit.md` вида
> `color-wheel-generator`, `webp-to-png` в реестре отсутствуют (реальная
> страница — `color-wheel-png`): при переносе пункта аудита в шаг сверять с
> `PAGES`.
## 0. Ключевое решение: select → buttons
Существующий `Segmented.svelte` — компонент с группированными кнопками (общая
рамка, разделители между сегментами). Для параметров инструментов он не
подходит: при 3+ опциях сегменты сжимаются, текст не читается.
**Решение:** новый компонент `ButtonsControl` — раздельные outline-кнопки с
отступами. Каждая опция — отдельная `<button>` со своей рамкой, активная
подсвечивается заливкой (`--color-main`). Не зависит от `Segmented`.
### Компонент `ButtonsControl`
```svelte
<!-- web/src/lib/components/fields/schema/ButtonsControl.svelte -->
<div class="buttons" role="group">
{#each options as opt}
<button
class="btn"
class:selected={value === opt.value}
aria-pressed={value === opt.value}
onclick={() => select(opt.value)}
>{opt.label}</button>
{/each}
</div>
```
```css
.buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-m);
}
.btn {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
background: transparent;
cursor: pointer;
}
.btn.selected {
background: var(--color-main);
color: var(--color-background);
border-color: var(--color-main);
}
```
### Схема: `field.buttons()`
Новый тип в `registry-schema.ts`:
```ts
export interface ButtonsSpec<V extends string = string> extends FieldSpecBase {
kind: "buttons";
default: V;
options: { value: V; label: string }[];
}
```
Фабрика:
```ts
buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
spec: { kind: "buttons", ...s },
}),
```
Регистрация: добавить `"buttons": {} as ButtonsSpec` в `fieldSpecs`, добавить
`ButtonsControl` в `FIELDS` в `SchemaFields.svelte`, добавить дефолт/санитайз в
`sanitizeSchemaParams` и `defaultSchemaParams`.
---
## 1. Волна A — инфраструктура (1 коммит)
### FA-A1. Новый тип `buttons` в схеме
Файлы:
- `web/src/lib/registry-schema.ts` — `ButtonsSpec`, фабрика `field.buttons()`,
кейс в `sanitizeSchemaParams`, кейс в `defaultSchemaParams`
- `web/src/lib/components/fields/schema/ButtonsControl.svelte` — новый компонент
- `web/src/lib/components/SchemaFields.svelte` — импорт + запись в `FIELDS`
### FA-A2. Seed → input + randomize
Для `field.number()` с `kind: "seed"` (или новый подтип `field.seed()`) —
рендерить поле ввода + кнопку «Random» вместо range slider.
Решение: новый вид `field.seed()` (наследует от number, но рендерится иначе).
Или проще: опциональное поле `variant?: "seed"` в `NumberSpec` — тогда
`RangeControl` покажет input + кнопку.
Решение: **`variant: "seed"` в `NumberSpec`** — меньше новых типов.
Файлы:
- `web/src/lib/registry-schema.ts` — `NumberSpec.variant?: "seed"`
- `web/src/lib/components/fields/schema/RangeControl.svelte` — условный рендер
- `web/src/lib/registry/filters.ts` — `randomizePixels`, `addNoise`: поле seed →
`{ ...field.number(...), spec: { ...spec, variant: "seed" } }` (или
пересоздать через `field.seed()`)
---
## 2. Волна B — select → buttons (по файлам)
Каждый шаг — один коммит, < 500 строк. Меняем `field.select()` →
`field.buttons()` в перечисленных ниже схемах.
### FA-B1. geometry.ts
| Инструмент | Поле | Опции |
| ----------------------- | -------- | ----------------------------------------- |
| rotate-png | angle | 90° / 180° / 270° |
| flip-png | axis | horizontal / vertical |
| swap-orientation-png | target | portrait / landscape |
| symmetric-copy-png | axis | vertical / horizontal |
| symmetric-copy-png | keepSide | left / right / top / bottom |
| change-aspect-ratio-png | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 |
| change-aspect-ratio-png | mode | crop / pad |
**Исключение:** `change-canvas-size-png` (anchor, 9 опций) — оставить
`field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже
отдельный компонент `PositionControl`).
### FA-B2. color.ts
| Инструмент | Поле | Опции |
| ------------------------ | --------- | ------------------------------------ |
| dithering-png | pattern | floyd-steinberg / bayer |
| extract-channel-png | channel | red / green / blue |
| swap-channels-png | pair | r-g / r-b / g-b |
| decrease-color-count-png | maxColors | 2 / 4 / 8 / 16 / 32 / 64 / 128 / 256 |
**Исключение:** channel spaces (hsl/hsv/... component) — 3-6 опций, но это
динамические инструменты, оставить `select`.
### FA-B3. analyze.ts
| Инструмент | Поле | Опции |
| ------------------------- | ---- | ------------------ |
| show-transparent-png | mode | binary / highlight |
| show-grayscale-pixels-png | mode | (тот же) |
| show-color-pixels-png | mode | (тот же) |
| light-pixel-mask-png | mode | (тот же) |
| dark-pixel-mask-png | mode | (тот же) |
Все используют общий `maskBaseFields` — правка в одном месте.
### FA-B4. filters.ts
| Инструмент | Поле | Опции |
| ------------- | ---- | ------------ |
| add-noise-png | mode | mono / color |
### FA-B5. generate.ts
| Инструмент | Поле | Опции |
| ------------------ | ------------ | --------------------- |
| color-spectrum-png | direction | horizontal / vertical |
| step-colors-png | layout | grid / strip |
| complementary-png | layout | grid / strip |
| triadic-png | layout | grid / strip |
| tetradic-png | layout | grid / strip |
| analogous-png | layout | grid / strip |
| monochromatic-png | layout | grid / strip |
| shades-png | layout | grid / strip |
| sort-colors-png | layout | grid / strip |
| mix-colors-png | (нет select) | — |
---
## 3. Волна C — обязательные фиксы (баги + дефолты)
### FA-C1. Source-aware defaults для dimension-полей — реализовано
Source-aware defaults реализованы: общий resolver в `registry-schema`
(`applySourceDefaults`). Для `resize-png` и `crop-png` дефолт после загрузки
равен размерам текущего source; до загрузки используется положительный fallback
`1×1`. Один-sided `0` у resize сохраняет режим auto. Проверено тестом
`registry.test.ts` («resize и crop получают размеры текущего source»).
### FA-C2. symmetric-copy-png: "keep side" не работает
Баг в `run()`: `keepSide` не учитывает `axis`. При `axis: "vertical"` (удвоение
ширины) работают только `left`/`right`, `top`/`bottom` не имеют смысла. Нужно:
либо фильтровать опции `keepSide` в зависимости от `axis` (reactive schema),
либо нормализовать в `run()`.
**Решение:** reactive — при смене `axis` сбрасывать `keepSide` на допустимое
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
{top, bottom} → заменить на `left`.
### FA-C3. Source-aware bounds (отдельный следующий шаг) — не начат
Source-aware **defaults** реализованы через `SchemaContext` и
`applySourceDefaults`. Динамические min/max для crop x/y, shift и resize не
реализованы и остаются отдельной задачей: bounds не следует смешивать с default
resolver.
**Что нужно спроектировать и завести:**
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
→ `PixelImage` (width/height); для bounds понадобится передать этот context в
`SchemaFields` и контролы.
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
источника вместо жёстких чисел. Кандидат:
```ts
x: field.slider({
label: "fields.x",
bound: "sourceWidth", // min/max = ±sourceWidth
default: 0,
}),
```
`BoundSpec = "sourceWidth" | "sourceHeight" | "minSide" | "maxSide"`,
применимо к `NumberSpec`, `SliderSpec`, `OffsetSpec`, `DimensionSpec`.
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
размера источника; при смене картинки диапазон пересчитывается, текущее
значение пере-клампится.
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
(offsetX/offsetY), resize (max → maxSide).
Это отдельный рефакторинг; source-aware defaults не заменяют bounds.
### FA-C4. verify-is-png: неверное название — решение принято, шаг не выполнен
Проверяет текст, не PNG-файл. Варианты:
- `title` → "Verify PNG data", `description` → уточнить; `id` оставить — он же
адрес страницы, переименование ломает ссылку
- либо полное переименование `id` → `verify-is-png-data` с редиректом со старого
URL (нужен вордстат, иначе страница выпадает из индекса)
**Решение:** поменять title и description, id не трогать. Переименование `id`
затрагивает адрес — с ним связана задача разведения `id`/`slug`
(`docs/backlog.md`), поэтому в одиночку не делается.
---
## 4. Волна D — обязательные фиксы (новые параметры)
### FA-D1. remove-alpha-channel-png: выбор цвета фона
Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`.
Файл: `alpha.ts`, схема `removeAlphaChannelSchema`.
### FA-D2. extract-alpha-mask-png: галочка «инвертировать»
Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В
`run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`.
Файл: `alpha.ts`.
### FA-D3. change-canvas-size-png: position9 вместо select
`anchor` сейчас `field.select()` с 9 опциями → `field.position9()`.
Файл: `geometry.ts`. Уже есть `PositionControl`.
### FA-D4. Generators: добавить height
Инструменты без `size`/`height`:
- blend-two-png — добавить
`height: field.slider({ min: 1, max: 1024, default: 512 })`
- step-colors-png — аналогично
- complementary, triadic, tetradic, analogous, monochromatic, shades,
sort-colors — все используют `paletteBaseSchema` (общий объект), добавить
`height` туда
- mix-colors-png — добавить height
Файл: `generate.ts`.
### FA-D5. add-border-png: прозрачность цвета
Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть
(прозрачность не поддерживается нативным color picker). Отметить в аудите как
blocked.
---
## 5. Волна E — полировка (некритично)
### FA-E1. Плейсхолдер "нет параметров"
Инструменты с пустой схемой (`EmptyParams`) выглядят странно. Добавить
`EmptyState` или подсказку "No parameters — click Run".
Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст.
### FA-E2. quantize-png: пресеты
`field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под
слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера.
### FA-E3. trim-empty-space-png: 0-254 → проценты
Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог
шага FA-A1 — `variant: "alpha-threshold"` в NumberSpec).
### FA-E4. add-text-png: plate offset
Plate снизу имеет большой отступ — добавить галочку "compact plate" или
уменьшить дефолтный padding.
### FA-E5. emoji-to-png: выбор эмодзи
Поле `text` → добавить группу часто используемых эмодзи как кнопки- пресеты над
полем ввода.
---
## 6. Верификация
Проверки и их минимальный набор — `docs/quality-gates.md`, раздел «Быстрые
подмножества»; правила e2e — `docs/testing-strategy.md`. Для волн с изменением
пользовательского потока (новый контрол, новая раскладка) обязателен ручной
смоук в браузере: часть находок — про воспринимаемость, которую не ловит ни один
гейт. `pnpm verify` — перед ревью волны.
---
## 7. Не покрыто / требует решения
Эти пункты из аудита не попали в волны A–E. Требуют либо дополнительного
исследования, либо нормативного решения, либо отложены.
### 7.1 Требуют normative решения
- **Pixel ↔ Percent** (circle-mask, square-mask, star-mask, wavy-mask — %→px,
round-corners — px→%): нужен компонент переключателя единиц в слайдере.
Требует правки `RangeControl` + всех схем — в волны A–E не входит, нужен
отдельный этап.
- **Smoothing checkbox** для масок (circle, square, star, wavy, round-corners,
invert-alpha): нужен `field.checkbox("smoothing")` + правка core (shapes.ts,
alpha). Отложен — требует визуального тестирования.
- **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует
normative решения по UI.
- **Remove-background**: выбор точки удаления — либо параметр, либо отдельный
инструмент.
- **Precision/quality**: select в resize (bilinear/bicubic/nearest) — нужен ли
выбор алгоритма?
- **Placeholders**: "нет параметров" — какой текст/компонент показывать.
### 7.2 Операционные (тест-кейсы, описания)
- find-contour-png: расширять на толщину линии (требует правки core)
- make-thicker-png: расширять картинку на толщину линии
- make-thinner-png: уменьшать от краев, fix выступа
- feather-edges-png: расширять картинку на толщину линии
- clean-edges-png: найти тест-кейс, возможно увеличить максимум
- despeckle-alpha-png: найти более заметный тест-кейс
- close-holes-png: проверить поведение на больших полупрозрачных областях
- auto-contrast-png: найти тестовый кейс
- sharpen-png: добавить тест-кейс
- center-by-alpha-png: найти тест-кейс
- png-is-grayscale: улучшить видимость результата
### 7.3 Дизайн-вопросы
- add-padding-png vs add-border-png: разница неочевидна, нужен review
- change-aspect-ratio-png: не видно что меняется — рамка результата
- watermark-tile-png: заполняет не полностью
- sepia-png: нужна ли настройка силы эффекта?
- posterize-png: настройка для каждого канала отдельно?
- pixelate-png: разные алгоритмы?
- swap-channels-png: обдумать flow через более простые инструменты
### 7.4 Аудит полей (компоненты)
- Range slider: поле ввода + кнопки +/- + кнопка сброса
- Color picker: пипетка с картинки, прозрачность
- Width/height с keep-aspect-ratio: единый компонент
- Position offset: отдельные слайдеры x/y (уже сделано в `OffsetControl`)
-140
View File
@@ -1,140 +0,0 @@
# План: платформа — общее ядро, API и PWA
> **Статус: active.** Этот план добавляет к `docs/roadmap.md` то, чего там нет:
> **API** (фаза 9, возможная монетизация) и **PWA** (фаза 10, оффлайн +
> установка), плюс шрифты (раздел 4 — частично относится к фазе 8, поэтому
> решения-phase 8 здесь дублируются). WASM-ядро, эталоны и CLI — фазы 2–8
> `docs/roadmap.md`, они здесь не пересказываются. Оба трека стартуют после
> wasm-фаз; PWA не ждёт API и может идти раньше.
## 0. Решения
- **Одно ядро — три потребителя.** Rust-крейт `core/` (roadmap фаза 4) даёт:
wasm-пакет для сайта (фаза 5), нативный CLI (фаза 6), серверный слой API (фаза
9). Единый контракт — JSON-пайплайн `{tool, params}` (заложен в roadmap §0,
реализуется в фазе 11); эталонные файлы и harness (фазы 2, 3, 7) гарантируют
одинаковый результат во всех рантаймах.
- **API — нативный Rust на сервере, не wasm.** В браузере wasm нужен ради
доставки и песочницы; на сервере крейт линкуется напрямую — быстрее и проще.
Исключение — edge-воркеры (см. спайк ниже): там исполняется тот же
wasm-артефакт фазы 5.
- **PWA не трогает SEO-схему.** Сайт остаётся полностью prerendered-статикой;
service worker добавляет оффлайн-слой поверх и не отвечает за HTML ботовам.
- **Монетизация не ломает позиционирование.** Браузерные инструменты — бесплатны
и без трекинга; API — отдельный продукт для автоматизации/бизнеса. Файлы в API
обрабатываются в памяти: без хранения и логирования содержимого — продолжение
«privacy»-истории.
## 1. Фаза 9 — API (после фаз 5–7)
- [ ] **[A1]** Спайк сервера: (а) axum/actix + нативный `core/` на VPS против
(б) wasm-артефакт фазы 5 на edge (CF Workers). Сравнить: стоимость, лимиты
памяти/времени на больших картинках, сложность деплоя и rate-limiting.
Решение записать в §0 этого файла.
- [ ] **[A2]** Контракт: `POST /v1/process` — multipart (image) + JSON-пайплайн
→ изображение/архив; лимиты размера, коды ошибок; OpenAPI-спека.
- [ ] **[A3]** Ключи и лимиты: Bearer-ключи, free tier (N запросов/мес), rate
limiting по ключу; самостоятельная выдача/ротация ключей в кабинете —
позже.
- [ ] **[A4]** Биллинг: Stripe/LemonSqueezy (merchant of record), 2–3 тира по
запросам/размеру файла. Только после стабильного free tier.
- [ ] **[A5]** Документация как лендинг: `/api` — индексируемая страница
(туториал, примеры curl/JS), OpenAPI, ссылка из `llms.txt`. Dev-аудитория
— источник бэклинков (план-seo S4c).
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
CLI и API (harness фазы 7 расширяется третьим рантаймом).
## 2. Фаза 10 — PWA (после S1)
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
maskable, theme-color из дизайн-токенов, `display: standalone`.
- [ ] **[P2]** Service worker (SvelteKit `src/service-worker.ts`): precache
app-shell; immutable-ассеты (build-hash) — cache-first; навигация по
инструментам — stale-while-revalidate; offline-fallback страница. Спайком
оценить вес полного precache: страницы маленькие, тяжёлое тащить по
требованию.
- [ ] **[P3]** Оффлайн-UX: индикатор «offline — работаете локально», своя кнопка
установки (на `beforeinstallprompt`), стратегия обновления версии (prompt
vs skipWaiting — решить на спайке).
- [ ] **[P4]** SEO-безопасность: SW не перехватывает запросы ботов и не меняет
HTML-ответы; canonical/prerendered-страницы не затрагиваются. Проверка:
Lighthouse PWA + SEO зелёные после внедрения.
- Гейт: каталог и топ-инструменты полностью работают в airplane-mode; повторный
визит без сети открывается мгновенно.
## 3. Порядок и связи
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8; пайплайн как продуктовая
фича — фаза 11 того же документа.
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
GEO-текстов (`llms.txt`, описания категорий).
## 4. Шрифты: лицензирование и DOM-free рендер текста
Текст — один из canvas-зависимых классов, которые roadmap (фаза 8) держит в TS
(там их четыре: текст, градиенты, штампы, `ctx.filter`); для текста выбран курс
на реимплементацию, остальные классы не переносятся. Для CLI/API текст всё равно
придётся рендерить в Rust, поэтому решение: **перевести text-инструменты в
ядро** (в браузере canvas-рендер остаётся TS-фолбэком для A/B). Два вопроса —
легальность шрифтов в дистрибутиве и рендер без DOM.
**Когда начинать:** блока F1–F4 нет — они не привязаны к фазе roadmap, но F5/F6
без них бессмысленны (нечего рендерить). Реализуется всё это в фазе 8, поэтому
лицензирование стоит закрыть до неё, а не после.
### Лицензирование (решить до выбора шрифтов, backlog «Font Style»)
- [ ] **[F1]** В набор — только лицензии, допускающие **коммерческое
использование и встраивание в продукт** (сайт + PWA + CLI + API): база —
SIL OFL 1.1 (можно редистрибутировать и использовать коммерчески; нельзя
продавать сами шрифты; требование — сохранять лицензию и имена при
редистрибуции) и Apache 2.0 (Roboto, Open Sans, часть Noto).
- [ ] **[F2]** Запрещённые категории: «free for personal use», shareware, шрифты
без явной лицензии, MS core fonts (лицензия привязана к поставке с
Windows-ПО). Отдельно проверять: бесплатность «для веба на одном домене» ≠
право класть TTF в дистрибутив CLI/API — у OFL/Apache таких ограничений
нет, у прочих «free»-лицензий бывают.
- [ ] **[F3]** Паспорт шрифта в `core/fonts/<name>/` (или эквивалент): файл(ы),
LICENSE, источник, версия, хеш; **новый гейт** «у каждого файла шрифта
есть паспорт» — его ещё нет, проектируется здесь и добавляется в
`pnpm verify` по правилам `docs/quality-gates.md` (решение о fail-режиме —
`docs/decisions.md`). Браузер грузит шрифты из бандла, не из ОС и не с
`fonts.googleapis.com`-CDN (сторонний запрос = утечка IP пользователя,
противоречит «no tracking»), — одинаковый результат у всех пользователей и
во всех рантаймах.
- [ ] **[F4]** Кириллица: приоритет семействам с полным покрытием — Liberation
Sans, PT Sans/PT Serif, Noto Sans, Inter, Roboto (OFL/Apache), DejaVu Sans
(пермиссивная лицензия Bitstream Vera: редистрибуция и коммерческое
использование разрешены) — закрывает и backlog-вопрос про «только с
кириллицей».
- **Стартовый набор** (все OFL/Apache, с кириллицей; состав не догма —
расширяется по запросам, каждый проходит паспорт F3):
- базовый sans: **Inter** (дефолт add-text), **PT Sans** / **Golos Text**
(профессиональная русская типографика), **Noto Sans** (фолбэк покрытия);
- serif: **PT Serif**, **Lora**, **Playfair Display** (display);
- mono: **JetBrains Mono** (дефолт), **Roboto Mono**;
- display/акцент: **Oswald** (узкий), **Rubik** (округлый), **Comfortaa**,
**Caveat** (рукописный), **Amatic SC** (маркерный);
- кириллицу проверить по файлу при добавлении: Bebas Neue, Pacifico
(добавлялась поздними версиями). Источник файлов — только Google Fonts /
Fontsource (версионированные пакеты под паспорт F3); случайные «free
font»-сайты — нет. Вес: woff2-сабсеты latin+cyr лениво в браузере, TTF в CLI
(~2–4 МБ суммарно), в wasm не зашивать — грузить из статики по требованию.
### Рендер без DOM (спайк — фаза 5–6, реализация — фаза 8)
- [ ] **[F5]** Спайк стека: растеризация глифов (`ab_glyph`/`rusttype`) +
шейпинг (`swash`/`harfrust`; для латиницы/кириллицы сложный шейпинг не
критичен, сложные письменности — осознанно не в первой версии) → отрисовка
в наш `Image`. Эталон — canvas-рендер TS (perceptual-класс по roadmap §3):
совпадать обязаны позиция/выравнивание/переносы, пиксели будут отличаться
хинтингом/антиалиасингом.
- [ ] **[F6]** Единый текстовый API для TS-core и Rust-core: параметры текста —
из JSON-схемы инструмента, шрифт — **id из реестра шрифтов** (не системное
имя), метрики базовой линии считать в ядре, а не через canvas
`measureText` (иначе TS и Rust разъедутся).
- Гейт: один и тот же текстовый шаг пайплайна даёт в браузере (wasm), CLI и API
визуально идентичный результат; в дистрибутиве нет ни одного файла шрифта без
паспорта лицензии.
-416
View File
@@ -1,416 +0,0 @@
# План: SEO + GEO — индексация и конкуренция в image tools
> **Статус: active.** Адреса страниц инструментов — `/tools/<slug>` (`crop-png`
> и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug`
> ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3).
>
> **`noindex, nofollow` выставлен на каждой странице инструмента**
> (`web/src/routes/tools/[slug]/+page.svelte`) — осознанно, до появления
> уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и
> **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают
> эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо
> него. Условие снятия и порядок — §4, S1.
>
> **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный
> интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в
> ответы генеративных систем (ChatGPT, Perplexity, AI Overviews) — GEO.
>
> **Позиционирование:** не «PNG-инструменты», а **image tools с PNG-ядром** —
> любой инструмент принимает PNG/JPG/WebP/GIF/BMP и отдаёт результат в
> PNG/JPG/WebP/BMP через селектор в кнопке Download. Дифференциатор —
> прозрачность/альфа/фон и no-upload-приватность. Референс-конкурент по ядру —
> onlinepngtools.com (§2). Долгосрочно ядро переезжает на wasm и обрастает API +
> PWA (`docs/plan-platform.md`); для SEO это аргументы дистрибуции и GEO-факты,
> схему сайта они не меняют.
>
> **Слаги в этом документе:** `jpg-to-png`, `crop-jpeg` и подобное — интенты и
> URL конкурента, а не id наших инструментов. Наши id — в `docs/tools-map.md`; в
> тексте ниже реальные id инструментов приводятся в бэктиках.
## 0. Решения
- **Бренд: переименовать до индексации** (новое имя и домен сознательно не
записаны в этом репозитории — чек-лист в локальном файле
`plan-domain.local.md`, см. §6, он вне git: выбранное имя не должно утекать в
историю коммитов до покупки домена). Легенда честная: «started as PNG tools,
now all formats» — это контент-плюс, а не баг. Адреса страниц
(`/tools/crop-png`) и localStorage-ключи не переименовывать (см. решение про
`slug` ниже). Точка истины по бренду: константа в i18n + README; хардкод в
компонентах недопустим.
- **Форматы: формат = выбор в UI, не отдельные страницы.** Download-кнопка
каждого инструмента — с селектором PNG/JPG/WebP/BMP (+quality для lossy). Райз
«выбор формата/качества в Download» вытащен из backlog №7 в S1f: механика уже
есть (`Tool.output`), нужна как продуктовая основа «image
tools»-позиционирования. В направлении «из формата в PNG» отдельных
инструментов нет (загрузка принимает любой формат) — см. §5.
- **Адреса страниц `-png` сохраняются, внутренние имена — без.** Инструмент
работает с любым форматом, поэтому его `id` не должен врать (`crop`, а не
`crop-png`); png-интент держат адрес страницы и имя скачиваемого файла.
**Реализовано:** реестр разделён на инструменты (`registry/tools/`, уникальный
`id`) и страницы (`registry/pages/`, уникальный `slug`), роут —
`tools/[slug]/`; модель — `docs/architecture.md`, раздел 3. **Следствие для
SEO: редиректы не понадобились, canonical и sitemap не меняются, адреса те
же** — поэтому это была безопасная правка до индексации, а не после. Побочная
выгода: широкие интенты («crop image online») переносятся теми же страницами
контентом — H1 «Crop image», текст «works with PNG, JPG, WebP»; URL-алиасы без
`-png` по-прежнему не создаются (канонический беспорядок). Побочный эффект
модели: страница может ссылаться на один инструмент, а инструмент —
обслуживать много страниц, поэтому следующие шаги (свести convert-страницы на
один `convert`, селектор формата в Download — S1f) не требуют новой миграции
реестра. Задача на консолидацию — `docs/backlog.md`.
- **Индексируемая локаль — EN** (базовая; конкурент англоязычный). RU-версия —
отдельно и позже: префикс `/ru/`, prerender обеих локалей, hreflang
(сознательно отложено). Пока язык переключается на клиенте (localStorage) —
для SEO не учитываем.
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`, см.
`BASE_PATH` в `web/vite.config.ts` и `deploy.yml`) ломает абсолютные URL,
canonical и «корневость» страниц. Свой домен + корень. Текущий деплой — GitHub
Pages (`.github/workflows/deploy.yml`); перенос на свой домен выполняется
сменой origin в CI и `PUBLIC_SITE_ORIGIN`.
- **URL-схема инструментов:** короткие плоские адреса — `/tools/<slug>` для
страницы, `/list-tools` для каталога, где `<slug>` берётся из записи
`registry/pages/`. Схему не менять без отдельного SEO-плана: после индексации
переименование URL означает 301 и потерю позиций. Разделение инструмента и
страницы (решение выше) адресов не меняет.
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя
подрывать собственными скриптами.
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
каждому инструменту, с ручной вычиткой.
## 1. Аудит (2026-09-09)
Фундамент уже есть:
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
JS. Это главный технический козырь.
- Страницы в `web/src/lib/registry/pages` со «SEO-шными» slug (`crop-png`,
`add-border-png`, …) — URL уже повторяют паттерн конкурента, при этом
внутренние имена инструментов png-интента не держат. Актуальный состав:
`docs/tools-map.md`.
- На корневых страницах инструментов есть базовые `<title>` и `robots`; полный
SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2.
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
инструментов против конкурента: `docs/tools-map.md`.
- EN-тексты в реестре + i18n-словари ru/en.
Чего нет (пробелы закрываются этапами ниже):
| Пробел | Где сейчас | Этап |
| -------------------------------------------------- | ----------------------------------- | ---- |
| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
| `sitemap.xml` | нет | S1 |
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
| `rel=canonical` | нет | S1 |
| OG/Twitter-теги, OG-картинки | нет | S1 |
| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
| Контент страницы (H1-текст, how-to, FAQ, related) | только title/description | S2 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + приватная аналитика | нет | S4 |
Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она
блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают
трафика. Подробности и условие снятия — §4, S1.
## 2. Конкурент (onlinepngtools.com)
Снято с живого сайта (2026-09-09):
- ~311 URL в sitemap, все плоские (`/crop-png`, `/create-transparent-png`);
title вида «Crop a PNG – Online PNG Maker».
- Страница инструмента: h1 + 2–3 абзаца («World's simplest…», import/crop
примеры), примеры «before/after», how-to-блок, огромные футерные перелинковки
(серии Logo*/Icon*/Stamp*), страницы-хабы, price-wall.
- Сила: возраст домена, ссылочная масса, бренд, охват, серии-клоны под длинные
хвосты.
- Слабость (наш контраргумент): реклама, ежедневные лимиты free-плана, платное
коммерческое использование, апселл-модалки, страницы по ~450 КБ.
- Наш ответ в контенте каждой страницы: **no upload (всё локально в браузере),
no signup, no limits, бесплатно в т.ч. для коммерческого использования, open
source**.
## 3. Стратегия
1. **Не лобовая, а ядро интентов.** 80% объёма поиска приносят ~30–50 запросов.
Покрытие инструментов у нас почти полное (список — `docs/tools-map.md`);
приоритет — контент и качество страниц ядра, затем хвосты.
2. **Страница = лендинг, а не «пустая коробка».** Шаблон контента: H1 → 1–2
абзаца «что делает» → как использовать (шаги) → параметры → FAQ (3–5 вопросов
уровня People-Also-Ask) → related tools по категории.
3. **CWV как измеримое преимущество.** Лёгкие быстрые страницы против ~450-КБ
страниц конкурента; после редизайна не потерять скорость.
4. **GEO.** LLM-системы цитируют страницы с прямыми определениями, списками,
таблицами и уникальными фактами. Всё это уже есть в данных реестра — отдавать
в HTML-структуре, без воды.
5. **Информационный контур — потом, не вместо.** Гайды по форматам (PNG/JPEG/
WebP: история, модификации, lossy/lossless, прозрачность, «png vs jpeg») дают
E-E-A-T, перехватывают сравнительные запросы и служат хабами внутренних
ссылок на инструменты. Но это этап S3e и только после того, как написано ядро
S2: транзакционная страница приносит конверсию, информационная — только
трафик. Тонкие FAQ-страницы «про всё» не заводить — несколько сильных
материалов с ссылками на 3–5 инструментов каждый.
6. **Дистрибуция.** GitHub-репо (звёзды/форки = сигналы), alternative.to,
Product Hunt, Reddit по интенту — реальный путь набрать первые бэклинки без
бюджетов.
## 4. Этапы
### S1. Технический минимум (закрывает блокер индексации)
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
**domain-required** (после покупки и DNS-настройки).
- [ ] **[S1h]** Снять `noindex, nofollow` со страниц инструментов
(`web/src/routes/tools/[slug]/+page.svelte`) — заменить на
`<meta name="robots" content="index, follow" />`. **Это первым
обязательным шагом S1:** пока тег стоит, остальные задачи S1 готовят
инфраструктуру, но не приводят трафик. Порядок: S1h ставится после
S1b/S1c/S1d, иначе Google проиндексирует сайт без sitemap и canonical.
Снимать `noindex` до готовности S2-контента осознанно не стоит: в индекс
попадут тонкие страницы с одним title/description.
#### S1-free: без домена
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
файл `static/og/[slug].png`, ссылка из `Seo.svelte`. Относительные пути
(`/og/crop-png.png`) работают без домена; при деплое на собственном домене
OG-картинки будут абсолютными и корректными.
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
для lossy) для всех инструментов — механика `Tool.output` уже есть
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
параллельно с текущими quality-задачами. Побочно снимает `quality` из схем
страниц — после этого 14 convert-страниц сводятся на один инструмент
(`docs/backlog.md`).
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в
png. Существующие страницы не трогать: `convert-png-to-jpg`,
`convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами
со своим контентом (§5).
#### S1-domain: с доменом (после покупки + DNS)
- [ ] **[S1a]** Перевести прод на собственный домен: убрать `BASE_PATH` из
`deploy.yml`, задать `PUBLIC_SITE_ORIGIN`, перенаправить
`https://<юзер>/<repo>/…` на корневые URL (301). Делается одновременно с
S1d (canonical), чтобы Google проиндексировал сразу с правильным доменом.
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
`build/` из `TOOLS` реестра (`web/src/lib/registry`) + статических
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`),
учёт опционального `BASE_PATH`.
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
cleanup — вернуть минимальный allow-all.
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
актуальному маршруту), OG-теги (title/description/type/url/image),
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
для абсолютных URL в canonical и OG:url.
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
> `build/` лежит корректный `sitemap.xml` со всеми инструментами; canonical/OG
> присутствуют на всех страницах; `noindex` отсутствует в собранном HTML
> (`pnpm --dir web build` и проверка `build/tools/*/index.html`);
> `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job
> `e2e` non-blocking, поэтому его статус читается по отдельному job
> (`docs/decisions.md`, раздел 10).
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
вручную пачками по категориям (самый трудоёмкий этап: объём растёт вместе
с числом инструментов × 2 локали; RU — отложить до решения о `/ru/`).
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
внутри категории + 2–3 «соседних» интента).
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI.
- [ ] **[S2d]** Первая волна контента — топ-30 инструментов ядра (приоритет по
вордстату: transparent png, jpg→png, compress, resize, crop, rotate,
circle crop, add text, pixelate, grayscale…).
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
рантайме.
### S3. Структурированные данные + GEO
- [ ] **[S3a]** JSON-LD: `SoftwareApplication` (+ `offers` price 0) и `FAQPage`
на страницах инструментов; `WebSite` + `Organization` на главной;
`BreadcrumbList` на всех внутренних. Учесть: Google ограничил rich results
по FAQ/HowTo для мелких сайтов — разметка нужна прежде всего для
LLM-парсинга, она безвредна.
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
инструментов с одним предложением на каждый (генерится из реестра тем же
скриптом, что sitemap).
- [ ] **[S3c]** Хабы категорий с текстом: 2–3 абзаца на категорию (что за
группа, когда какие инструменты применять), не голые списки карточек.
- [ ] **[S3d]** GEO-редактура: каждое описание начинается прямым определением
(«Crop PNG is a free tool that cuts a rectangular area from a PNG image
right in your browser»), списки/таблицы для фактов, без вводных «в
современном мире…». Проверка: страница отвечает на запрос одним абзацем
«без скролла».
- [ ] **[S3e]** _(опционально)_ Информационные гайды по форматам: 3–4 сильных
материала (`/guides/png-format`, `/guides/jpeg-vs-png`, …): история и
модификации формата, lossy/lossless, прозрачность, когда какой формат
выбирать. Каждый — с ссылками на 3–5 связанных инструментов (хабы
перелинковки), FAQ из гайдов продублирован как FAQPage-разметка. Приоритет
ниже S2/S3a–d: только когда ядро написано и есть ресурс.
- Гейты: валидация JSON-LD (Rich Results Test без ошибок); `llms.txt` актуален
при добавлении инструментов (генерация, а не ручное редактирование).
### S4. Измерение и запуск
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
мониторинг индексации и запросов. Бесплатно и обязательно.
- [ ] **[S4b]** Аналитика без трекинга: self-hosted Plausible/Umami либо
Cloudflare Web Analytics. GA/ЯМ не ставим (позиционирование).
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
подача на alternative.to (страница против конкурента), Product Hunt,
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
- [ ] **[S4d]** _(опционально, позже)_ Сравнительные посадочные «vs …» (без
негатива: «no limits, no signup, no upload») — по мере роста доверия
домена.
- [ ] **[S4e]** Контроль CWV: Lighthouse на страницы ядра после каждого крупного
этапа редизайна; цель — зелёные метрики на мобильных.
## 5. Приоритизация инструментов (первая волна контента)
Источник — `docs/tools-map.md` (наш состав и замер по конкуренту — §2). Ids ниже
— **фактические id реестра**; первая волна — топ-интенты, где конкуренты
ранжируются, а у нас уже есть инструмент и он не хуже:
- прозрачность/фон: create-empty-png, remove-background-png, change-png-opacity,
set-alpha-channel-png
- конвертация: convert-png-to-jpg, convert-png-to-webp, png-to-bmp, svg-to-png,
png-to-base64
- геометрия: crop-png, resize-png, rotate-png, round-corners-png, flip-png,
change-aspect-ratio-png
- цвет/фильтры: grayscale-png, invert-colors-png, pixelate-png, add-text-png,
watermark-tile-png
- генерация: linear-gradient-png, create-empty-png
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
> инструментов нет и создавать их не хочется — загрузка файла уже принимает
> любой формат, а конвертер «во что-то» закрывает обратное направление. Это
> high-volume интент без посадочной страницы. Решение (страница-обёртка на
> существующий инструмент / приём в H1 и тексте / ничего) требует вордстата и
> должно быть принято до старта S2, иначе первая волна контента закроет не ядро
> запросов.
Новая волна форматов (после S1f): jpg↔webp и другие конвертации «формат →
формат» — только по подтверждённому вордстату; базовые кейсы закрывает селектор
формата в Download (S1f), страницы заводим лишь там, где интент стабильно
ищется.
- **crop-png: H1 «Crop image online — free, no upload»**, текст «works with PNG,
JPG, WebP» — широкий интент обслуживается контентом страницы с png-слагом,
слаг при этом остаётся якорем дешёвого png-интента.
### Форматные варианты страниц (`crop-jpeg`, `crop-webp`, …)
Три возможных механики — важно не перепутать:
1. **301-редирект на существующую страницу** (`/crop-jpg` → `/crop-jpeg`, или
`/crop-png` → новый слаг после переименования) — законно **только** для
опечаток, альтернативных написаний и переименований **уже опубликованной**
страницы: URL, которого никогда не было в индексе, редиректом не закрывается.
Для самостоятельного запроса редирект — это отказ от страницы: Google
перестаёт показывать редирект, ранжировать будет только цель. Как посадочная
под «crop jpeg» не работает вообще.
2. **Зеркало с canonical на `/crop-png`** — технически легально, но бесполезно:
canonical-дубль в индекс не попадает, видимости не добавляет, а массовое
производство таких зеркал выглядит как накрутка площади и при ошибке в
canonical превращается в дорвейный кластер. Не делаем.
3. **Самостоятельная страница = самостоятельный контент.** Заводить `/crop-jpeg`
можно только если: (а) интент подтверждён вордстатом, (б) на странице
собственный текст — H1 «Crop JPEG», FAQ про качество/артефакты/ когда
выбирать JPEG вместо PNG, отличия от png-кейса, (в) текст написан или выверен
человеком, а не получен подстановкой формата в шаблон. Реестром
автоматизировать **данные** (тот же tool, другой дефолтный output) — можно;
автоматизировать **текст** — нельзя: массовая подстановка {tool}×{format} в
шаблон — doorway-паттерн, риск deindex всего сайта.
Как правильно получать видимость под «jpeg»/«webp» без клонов: широкие
формулировки закрывает контент существующей страницы (H1 «Crop image», «works
with PNG, JPG, WebP» в тексте + селектор формата в Download, S1f). Отдельные
форматные страницы — точечные исключения по вордстату, не серия.
### Convert-инструменты: страницы и «универсальный конвертер»
Разводить два разных интента:
- **«convert png to jpg»** — интент «страница-конвертер»: пользователь ищет
конкретную пару форматов (лучшее качество конверсии, меньше ошибок), это
готовые лендинги. Существующие (`convert-png-to-jpg`, `convert-png-to-webp`,
`png-to-bmp`, `svg-to-png`, `base64-to-png`, …) **оставить и наполнить
контентом как все**: свой H1 («Convert PNG to JPG — free, no upload»), FAQ про
качество/прозрачность/фон, related. Селектор формата (S1f) их **не заменяет**
— он про «уже открыл инструмент и хочу другой формат на выходе».
- **«convert image» / «image converter»** — интент «швейцарский нож». Под него —
**один будущий инструмент `/convert-image`**: сначала пустой **core-tool** в
`core/` — функция ре-энкода («копия с перекодированием»: декодирует любой
поддержанный исходник в ImageData и кодирует в выбранный формат; поверх неё
S1f строит селектор формата). Инструмент и страницу добавлять **только вместе
с собственным текстом** («Convert any image to PNG, JPG or WebP in your
browser») — core в коде не обязывает к странице. Этот же шаблон потом
закрывает форматные волны (jpg→webp и т.п.) без новых страниц.
Не делать: серию `convert-jpeg`/`convert-webp`-клонов с подстановкой формата
(doorway, см. выше) и «универсальный конвертер» как единственную страницу вместо
форматных лендингов — теряется ядро «convert png to jpg»-запросов.
Правило добавления новых инструментов: брать из хвоста конкурента только те
интенты, которые подтверждены вордстатом, и сразу с контентом S2 (страница без
текста — потраченный URL).
## 6. Переименование бренда и домен (сделать до S1-domain, дёшево сейчас)
Выбранное имя и домен **в этом репозитории не записаны**: детальный чек-лист
лежит в локальном файле `plan-domain.local.md` рядом с этим планом, он исключён
из git через `.gitignore`, чтобы имя не попало в историю коммитов до покупки
домена. Агент, работающий из клона, этого файла не увидит — при необходимости
попросить чек-лист у пользователя.
- [ ] **[R1]** Механический переезд бренда на новое имя. Список файлов и строк с
хардкодом — в локальном плане (раздел «Где живёт бренд в коде»); здесь
только SEO-ограничения. **НЕ трогать:** адреса страниц инструментов
(`/tools/crop-png`), ключи localStorage (`easy-png-tools:*` — или
мигрировать отдельной мелочью), историю `docs/archive`. Бренд должен стать
i18n-константой, а не тремя независимыми хардкодами.
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
перевести прод на него без `BASE_PATH` (§0). Домен нужен до S1-domain:
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
блокируют). R2 (домен) — блокер для S1-domain (canonical + sitemap), а
S1-domain — блокер для S1h (снятие `noindex`). После индексации переименование
= переезд с 301, потеря позиций и ссылок — не делать.
## 7. Что осознанно НЕ делаем
- Клонирование всего каталога конкурента (низкокачественные дубли + нет ресурсов
на контент) — вместо этого ядро + обоснованные хвосты.
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
- GA/реклама/трекинг — противоречит позиционированию.
- Дорвеи, программные зеркала, покупные ссылки.
- Массово-сгенерированные форматные клоны (`crop-jpeg`/`crop-webp` как копии
`crop-png` с подстановкой формата в шаблонный текст) — doorway-паттерн, риск
deindex всего сайта. Форматная страница — только уникальный контент + вордстат
(§5); редиректы — только для опечаток/переименований.
- URL-алиасы без `-png` для существующих инструментов и зеркальные
страницы-конвертеры «на каждый формат» без вордстата.
## 8. Связанные документы
- `docs/tools-map.md` — карта «наш инструмент ↔ интент конкурента».
- `docs/backlog.md` — пункты SEO/GEO в общем бэклоге.
- `docs/roadmap.md` — фазы 2–8 (ядро TS→Rust/wasm, CLI), фаза 11 (пайплайн);
фазы 9–10 — API и PWA.
- `docs/plan-platform.md` — планы API и PWA (общее ядро, оффлайн-режим).
- `plan-domain.local.md` — локальный чек-лист переименования и покупки домена;
**в git не входит** (`.gitignore`), в клоне отсутствует.
-121
View File
@@ -1,121 +0,0 @@
# Quality gates
> Статус: **active**. Каноническое описание быстрых проверок: команды, coverage,
> warnings, CI. Почему гейт такой, а не иначе, — `docs/decisions.md`.
Команды выполняются из корня репозитория.
## Каноническая команда
```text
pnpm verify
```
`verify` выполняет последовательно:
1. `pnpm --dir web check` — SvelteKit sync и `svelte-check`.
2. `pnpm --dir web test:coverage` — полный Vitest-набор вместе с
coverage-гейтом.
3. `pnpm --dir web test:rules` — отдельный быстрый прогон тестов ESLint-правил.
4. `pnpm --dir web lint:all` — ESLint, Stylelint и token audit.
5. `pnpm --dir web format:check` — форматирование кода `web/`.
6. `pnpm check:docs` — форматирование Markdown (`AGENTS.md`, `README.md`,
`docs/`).
7. `pnpm --dir web build` — production build.
Полный `test` уже включает тесты lint-правил; отдельный `test:rules` в `verify`
оставлен как явный быстрый диагностический этап.
## Coverage
`pnpm --dir web test:coverage` считает покрытие только по `src/lib` и падает
ниже порога. Пороги и список исключений из scope — в `web/vitest.config.ts`
(`coverage.thresholds`, `coverage.exclude`); здесь они не дублируются, чтобы
числа не устарели молча (`AGENTS.md`).
Порог — нижняя граница, а не оценка качества: он блокирует регрессию и не
требует сначала закрывать весь долг. Актуальные проценты даёт
`pnpm --dir web test:coverage`. Локальный замер занимает единицы секунд, поэтому
отдельного прогона в `verify` нет — `test:coverage` заменяет `test`.
Исключения из scope и их причины:
| Исключение | Причина |
| ------------------------------------------- | ------------------------------------------- |
| `**/*.test.ts`, `**/test-helpers.ts` | сами тесты |
| `**/*.svelte.ts` | runes, не работают в node |
| `executor/executor.worker.ts` | entry worker, исполняется только в браузере |
| `components/define.ts` | type-only, нет исполняемого кода |
| `core/domText.ts`, `core/io.ts` | canvas, `createImageBitmap`, нужен браузер |
| `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts` | данные, а не логика |
`registry/*` из scope НЕ исключены: низкое покрытие там означает непокрытую
логику, а не ограничение окружения. Включение браузерных модулей в scope —
отдельное решение, а не молчаливое расширение списка.
## E2E
E2E не входит в `verify`, потому что Playwright поднимает build и статический
сервер:
```text
pnpm --dir web test:e2e
```
Запускать его обязательно для изменений маршрутов, загрузки файла, результата,
download, темы или локализации. Изменения runtime fixtures/загрузки файла
проверяются отдельным `web/e2e/png-fixtures.spec.ts`; ручные бинарники из
`tests/fixtures/` не входят в `verify`.
Локально прогон идёт с `workers: 1` и заметно медленнее. Правила нагрузки на
машину пользователя и диагностика флаков — `docs/agent-workflow.md`, раздел 3;
матрица проектов и ожидаемое поведение декодеров — `docs/testing-strategy.md`,
раздел 7; статус e2e в CI и порядок чтения прогона — `docs/decisions.md`,
разделы 9–10.
## Быстрые подмножества
Матрица «изменение → минимальный набор проверок»:
| Изменение | Команда |
| ---------------------------------------------------- | ------------------------------------------------------- |
| Только 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` |
| Маршрут, загрузка, результат, download, тема, локаль | `pnpm --dir web test:e2e` |
| Перед ревью или merge | `pnpm verify` |
## Errors и warnings
- `errors` завершают соответствующий инструмент ненулевым кодом.
- ESLint запускается с `--max-warnings=0`: новые ESLint warnings блокируют gate.
- Token audit оставляет unused-token сообщение неблокирующим: новый токен
появляется вместе с потребителем, но стиль не блокирует деплой (решение —
`docs/decisions.md`, раздел 6).
- Резолв `var()` в `app.css` — ошибка: несуществующий токен молча роняет
объявление, поэтому этот check failing, в отличие от unused.
- `dict-consistency` использует base-locale fallback для registry metadata и
pattern-based policy для временно неиспользуемых `tools.*.params`; это не
молчаливый `allowPaths`-список.
`lint:all` может завершиться успешно при warnings — это не означает чистый
baseline. Агент показывает число errors, warnings и token warnings отдельно.
## 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` | push в `main`, `pull_request`, dispatch | `verify`, `e2e` |
Результат после пуша читается **по отдельным job** — процедура в
`docs/decisions.md`, раздел 10.
-186
View File
@@ -1,186 +0,0 @@
# План развития: easy-png-tools
> **Статус:** фаза 1 выполнена — сайт живёт на типизированном реестре,
> production UI — корневые маршруты. Текущий фокус: SEO S1-free
> (`docs/plan-seo.md`) + открытые задачи (`docs/backlog.md`). Домен нужен для
> SEO (S1-domain). Фазы 2–8 — будущие; 9–10 спланированы в
> `docs/plan-platform.md`; 11 — пайплайн.
>
> **Идентификаторы.** Ссылаться на фазу этого документа. Пространства
> идентификаторов задач: `FA-*` — правки полей и раскладки
> (`docs/plan-fields-audit-fixes.md`); `S*` — SEO/GEO (`docs/plan-seo.md`); `A*`
> — API, `P*` — PWA, `F*` — шрифты (`docs/plan-platform.md`); `R*` — бренд и
> домен (`docs/plan-seo.md`, §6). Буквы `Q*`/`C*` в архивных документах
> относятся к волнам редизайна и как идентификаторы задач не используются.
## 0. Решения
Продуктовые и стратегические — здесь; устройство кода (реестр, исполнитель,
слои) — `docs/architecture.md`, разделы 2–3.
- **Набор утилит для изображений, всё в браузере** (SvelteKit +
`adapter-static`), данные не покидают машину.
- **Первый и главный приоритет — полностью рабочий сайт**: максимум простых
инструментов, UX/UI, пайплайны (несколько инструментов подряд), описания
инструментов.
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` —
эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки
Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат зафиксирован этим
решением и станет общим для браузера, wasm и CLI; реализуется в фазе 11, не в
фазе 1.
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из
TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций,
**perceptual diff** для canvas-зависимых (правила сравнения — раздел 3).
## 1. Где что лежит
Актуальная карта модулей, поток данных и рецепт добавления инструмента —
`docs/architecture.md`. Список реализованных инструментов — `docs/tools-map.md`,
открытые продуктовые задачи — `docs/backlog.md`.
## 2. Фазы
### Фаза 1 — Полноценный TS-сайт (выполнена)
Поставлено: каркас SvelteKit + static, дизайн-система и общий лейаут; ядро над
`ImageData` (загрузка/декодирование, кодирование, операции по категориям) без
DOM-зависимостей; типизированный реестр с универсальным рендерером страницы и
формы; массовая реализация EASY-инструментов; i18n, темы, состояния
загрузки/ошибок. Checkpoint достигнут: загрузил PNG → применил инструмент →
скачал результат, без wasm.
**В фазе 1 пайплайна нет.** Workspace цепочек с сохранением в localStorage и
экспортом в JSON — фаза 11, не поставленная часть фазы 1. Страница инструмента
работает ровно с одной записью реестра; что именно зарезервировано под пайплайн
(компонент `StepCard`, ключи `chain.*`), перечислено в `docs/architecture.md`,
раздел 1 «Чего в коде нет».
Незакрытый долг по фазе — в `docs/backlog.md`; он не входит в фазы 2–8 и 11.
### Фаза 2 — Тестовые PNG и генератор эталонов
- Фикстурные входы: градиенты/паттерны/шум + пара «настоящих» PNG.
- Правило: сравниваем **распакованные пиксели**, не байты файла.
- **Checkpoint:** в `tests/reference/<op>/` лежат `input-*.png` +
`expected-*.png`.
### Фаза 3 — Эталонные файлы и UI-регресс
> UI-регресс на Playwright уже существует и закрывает фазу 3 частично
> (`web/e2e/`, browser matrix). Недостающая часть фазы — именно эталоны.
- Скрипт генерации эталонов: грузит страницу, гоняет TS-реализацию, сохраняет
`expected-*.png`.
- **Checkpoint:** регресс-тесты падают при изменении выхода эталона.
### Фаза 4 — Rust-ядро
- Порт 1:1 чистых операций (первым делом те, что покрыты эталонными файлами) с
`PixelImage` ↔ TS `ImageData`.
- `clamp_to_u8` с семантикой `ToUint8Clamp` (round-half-to-even, ≥255 → 255) +
юнит-тесты на краях.
- `cargo test` читает эталонные файлы напрямую, **без браузера**.
- **Checkpoint:** `cargo test` зелёный на всех эталонных файлах.
### Фаза 5 — WASM на сайте
- Спайк WASM + Vite (проверить интеграцию) → wasm-bindgen-биндинги.
- Свитч A/B: TS-реализация заменяется на wasm-вызов.
- Playwright-проверка: wasm-результат == эталон.
- **Checkpoint:** сайт работает через WASM; A/B TS vs WASM идентично.
### Фаза 6 — Rust-CLI
- CLI: обход папки, `--pipeline pipeline.json`, запись результатов.
- Воспроизведение эталонов из консоли.
- **Checkpoint:** CLI выдаёт те же пиксели, что сохранены в эталонных файлах.
### Фаза 7 — Сквозная проверка идентичности
- Матрица: **эталонные файлы (из TS) ↔ Rust-native ↔ Rust-wasm**, одна команда
`harness compare`.
- **Checkpoint:** сходимость по всем покрытым операциям во всех трёх рантаймах.
### Фаза 8 — Масштабирование и продвинутое
- Порт оставшихся EASY → MEDIUM (свёртки, морфология, квантование; кодеки через
крейт `image`).
- Canvas-зависимые операции (текст, градиенты, штампы, `ctx.filter`): остаются в
TS с perceptual-классом либо реимплементация в Rust. Для текста выбран курс на
реимплементацию в Rust (рендер без DOM + шрифты OFL с лицензионными
паспортами) — `docs/plan-platform.md`, §4.
- batch-страница: дроп папки → wasm по всем файлам → zip. localStorage-пресеты
(тот же JSON, что у CLI).
- HARD: compress/optimize через oxipng/zopfli (в Rust — нативно).
### Фаза 9 — API (монетизация) — см. `docs/plan-platform.md`
Серверная обёртка над тем же ядром (нативный Rust или wasm на edge — решает
спайк), ключи и free tier, платные лимиты, индексируемая документация `/api`.
Чек-лист и решения — `docs/plan-platform.md`, §1.
### Фаза 10 — PWA — см. `docs/plan-platform.md`
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из
`plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
### Фаза 11 — Пайплайн (цепочки шагов)
Продуктовая фича, **не зависит от wasm-фаз**: работает на существующем
исполнителе и реестре, поэтому может идти параллельно с SEO. Номер 11 выбран
чтобы не сдвигать уже используемые ссылки на фазы 2–10.
- **Формат шага** — `{id, params}`, список шагов применяется последовательно,
результат предыдущего идёт в следующий. Формат зафиксирован в §0 как общий для
браузера, wasm и CLI, поэтому версионируется с первого дня. Он уже совпадает с
полем `Page.steps` (`{ id, params? }`), так что страница может быть готовой
цепочкой без новой модели данных; исполняется пока первый шаг.
- **Первый шаг фазы:** модуль состояния цепочки + сохранение в localStorage +
экспорт/импорт JSON (ошибки разбора уже описаны ключами
`errors.badPipelineShape` / `errors.pipelineVersion` / `errors.noSteps`).
- **Затем:** редактирование шагов (drag-n-drop, удаление, добавление), переход
«один инструмент → цепочка» без перезагрузки, сохранённые именованные цепочки,
отображение маски результата.
- **Checkpoint:** собрал цепочку из N шагов → сохранил → вернулся позже → скачал
результат последнего шага, всё в браузере.
Ограничения и открытые вопросы (1 → many не терминален, many → 1, частичные
ошибки, имена частей) — `docs/backlog.md`, раздел «Идеи». Пайплайн из архивного
UI-v1 удалён; что осталось в коде под эту фазу — `docs/architecture.md`,
раздел 1.
## 3. Правила сравнения (для фаз 4–7)
| Класс операции | Критерий |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| Чистые пиксельные: цвет, альфа, порог, свёртки, морфология, квантование | **бит-в-бит** |
| Canvas-растеризация: текст, градиенты, антиалиасинг, `drawImage`, `ctx.filter` | **perceptual diff** (допуск: % изменившихся пикселей / цветовое расстояние) |
| PNG-декод | бит-в-бит (lossless) |
| JPEG-декод | perceptual (разные декодеры дают лёгкий дрейф) |
| Кодирование | сравниваем декодированные пиксели, не байты файла |
| compress/optimize | своя метрика: размер + визуальная дельта |
## 4. Риски и контрмеры
- **Реестр разрастается, страницы дублируются** — один универсальный рендерер
страниц/форм из записей реестра; инструмент = данные + `run`.
- **Семантика пайплайна** — зафиксирована: каждый шаг применяется к результату
предыдущего; единый тип `PixelImage` на всём пути; схема параметров на шаге.
- **Canvas-операции не бит-в-бит** — класс perceptual закладывается на Фазе 2–3,
а не в конце.
- **WASM + Vite** — спайк на Фазе 5 до массовой интеграции (риск отложен
сознательно: сайт уже живёт на TS).
- **Расползание реестра при будущем Rust-порте** — схема параметров в JSON, её
можно парсить и из Rust.
- **Разные декодеры PNG** — фикстурные PNG генерировать самим, чтобы не тащить
чужие артефакты.
## 5. Команды верификации
Команды текущего этапа и матрица «изменение → проверки» —
`docs/quality-gates.md` (`pnpm verify` — быстрый gate). Команды фаз 2–8
(`cargo test`, `harness compare`) **в проекте не существуют** и появятся вместе
с соответствующими фазами: до этапа 2 проверять нечем, кроме `pnpm verify` и
e2e.
-133
View File
@@ -1,133 +0,0 @@
# Стратегия 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-контракт.
Состав и причины исключений из coverage-гейта, а также пороги — в
`docs/quality-gates.md`; здесь они не дублируются.
## 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. В комментарии указывается ссылка на неё. После исправления тест и
ссылка обновляются вместе.
Если тест сравнивает два состояния одного элемента, он ждёт изменения значения
(`not.toHaveText`, `not.toHaveAttribute`), а не его видимости. Пересчёт идёт
через debounce, поэтому `toBeVisible()` после второго ввода проходит на старом
значении. Такой тест флакает по таймингу и обычно ловится на CI, а не локально.
## 6. Проверки
Матрица «изменение → минимальный набор проверок» и состав `pnpm verify` — в
`docs/quality-gates.md`. Для изменений e2e дополнительно уместны
`pnpm --dir web check` и `pnpm --dir web format:check`.
`verify` не включает Playwright: E2E остаётся отдельным тяжёлым gate. В CI
non-blocking E2E не считается заменой локальному smoke; перед ревью фиксируются
число passed, skipped и известные fixme. Статус e2e в CI — `docs/decisions.md`,
раздел 9.
## 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 | ни один движок |
- `workers` — 1 локально и 2 в CI: Playwright не умеет per-project лимит
воркеров, а browser matrix ограничена памятью, а не CPU. Правила нагрузки на
машину и порядок диагностики флаков — `docs/agent-workflow.md`, раздел 3;
обоснование отсутствия per-project таймаутов — `docs/decisions.md`, раздел 7.
- Visual snapshots и per-project таймауты сознательно не используются —
`docs/decisions.md`, разделы 5 и 7.
-178
View File
@@ -1,178 +0,0 @@
# Аудит использования - UX\UI
> Статус: **active**. **Дата прохода: 2026-09-13** (инструменты и поля). Здесь
> только «что плохо» — наблюдения, без порядка работ и без критериев приёмки.
> Новый проход — новая дата сверху; закрытая находка не удаляется, а помечается
> ссылкой на задачу в `docs/backlog.md`.
>
> Как исправлять — `docs/backlog.md` (задачи) и
> `docs/plan-fields-audit-fixes.md` (порядок правок полей). Находка в аудите и
> задача в бэклоге — не дубль: пока задачи нет, находка просто висит.
>
> Ids ниже **не сверены с реестром** и часть из них в коде отсутствует
> (`webp-to-png`, `color-wheel-generator`, `png-info`, `png-to-hsl`). Перед
> работой по любому пункту — найти соответствующий инструмент в
> `web/src/lib/registry/` или `docs/tools-map.md`; несуществующий id не
> resurrect'ить, а переименовать пункт. Чтобы не сверять вручную каждый раз, ищи
> по смыслу: категория в заголовке раздела + ключевое слово в пункте →
> `docs/tools-map.md`, где id уже фактические.
## везде
- возможность переключать пиксели\проценты
- инструменты без параметров выглядят странно, пересмотреть, может добавить
какую-то надпись "нет параметров"
- удаление\маски и т.д. - надо добавить галочку сглаживание
- производительность: почему flip заметно тормозит, но нет индикации работы
инструмента (также sharpen-png)
- производительность: исследовать, есть ли смысл на инструментах геометрии
выводить рамку результата до полноценной работы (например на skew \ rotate
будет удобнее)
## аудит инструментов
### transparency
- add-stroke-png: добавить галочку "расширить на толщину линии"
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо
расширять на толщину линии
- circle-mask-png: размеры заданы в процентах, надо задавать в пикселях
- square-mask-png: размеры заданы в процентах, надо задавать в пикселях
- star-mask-png: размеры заданы в процентах, надо задавать в пикселях, проверить
форму звезды - сейчас выпуклые края, радиус задан до 100%, а более 50 - смысле
нет
- wavy-mask-png:размеры заданы в процентах, надо задавать в пикселях,
пересмотреть форму и параметры. как будто неудобно конфигурировать
- remove-alpha-channel-png: выбор цвета фона, как будет работать с
полупрозрачной картинкой?
- extract-alpha-mask-png: галочка инвертировать маску
- round-corners-png: размеры заданы в пикселях, должна быть возможность задать в
процентах, нету сглаживания по вырезанным пикселям
- invert-alpha-png: края со сглаживанием странно обрабатываются, может
галочка\слайдер обрезать сглаживание?
- remove-background-png: возможность выбора точки, от которой удалять (или
отдельный инструмент?)
- make-thicker-png: расширять картинку на толщину линии
- make-thinner-png: уменьшать от краев, если картинка начинается от края, то
остается выступ
- feather-edges-png: расширять картинку на толщину линии, (выбор цвета возможен?
другой инструмент?)
- clean-edges-png: непонятный инструмент (слишком маленький максимум?). найти
тест кейс для него
- despeckle-alpha-png: найти более заметный тест-кейс
- close-holes-png: проверить, как должен работать, картинки с большой
полупрозрачной областью - закрывает всё, по описанию должен закрывать только
небольшие дырки
### colors
- two-colors-png: в range slider добавить описание что это luminance threshold
- gamma-png: проверить правильность работы - яркое изображение выглядит не
контрастным, проверить максимум и минимум
- temperature-png: проверить правильность работы - не слишком ли отдает
желтым\синим?
- quantize-png: максимум 64 - не слишком мало? сделать кнопки с пресетами
- custom-palette-png: копирует two-colors-png, только ввод через текстовое поле
и нет выбора порога luminance
- dithering-png: проверить алгоритм Floyd–Steinberg на малом количестве цветов
- sepia-png: не нужна настройка силы эффекта?
- extract-channel-png: мульти вывод сразу в 3 канала?
- swap-channels-png: обдумать описание подобных инструментов через flow более
простых?
- black-and-white-png: аналог two-colors-png, только без выбора цветов
- posterize-png: не нужен вариант настройки для каждого канала отдельно?
- auto-contrast-png: найти тестовый кейс
- decrease-color-count-png: сделать кнопки вместо селекта
- png-to-hsl: проверить, что `space as rgb` должна выдавать одинаковые
результаты, вне зависимости от выбора h s l. может выбрать отдельные кнопки?
- png-to-hsv: тоже самое ^
- png-to-hsi: тоже самое ^, нужно ли разделять на отдельные инструменты?
- png-to-cmyk: тоже самое ^
- png-to-lab: тоже самое ^
### geometry
- add-border-png: прозрачность цвета (аудит полей)
- fit-on-background-png: цвет
- change-canvas-size-png: использовать position9 вместо селекта
- resize-png: по умолчанию размеры 0х0, надо поменять; выбор метода
масштабирования
- crop-png: offset - слишком большие значения
- rotate-png: кнопки вместо select
- flip-png: кнопки вместо select
- add-padding-png: чем отличается от add-border-png?
- center-by-alpha-png: найти тест кейс
- zoom-png: выбор точки центра
- trim-empty-space-png: выбор от 0 до 254 (надо везде привести к единому стилю -
переключение 0-100 проценты и 0-255)
- change-aspect-ratio-png: не видно что меняется, добавить рамку вокруг
результата, а не вокруг div
- swap-orientation-png: кнопки вместо select
- symmetric-copy-png: кнопки вместо select, два выбора в селекте `keep side` не
работают (в зависимости от выбора `axis`)
- shift-png: цвет (прозрачность)
- split-into-parts-png: результат с несколькими файлами, обдумать процесс
### filters
- vignette-png: выбор точки центра, цвет, кроме силы нужен еще параметр прироста
силы (как называется?)
- pixelate-png: разные алгоритмы? выглядит не очень. тест кейс?
- randomize-pixels-png: настройка размеров другим вариантом - на сколько кусков
делить. seed - поменять
- add-noise-png: кнопки вместо select, seed - поменять
- sharpen-png: добавить тест кейс
- silhouette-png: аналог extract-alpha-mask-png?
### text
- add-text-png: plate снизу большой отступ, сделать галочку для этого (некоторые
буквы будут обрезаться - y, g)
- watermark-tile-png: заполняет не полностью
### analyze
- extract-color-from-png: надо описать или дать выбор на основе какого параметра
работает threshold
- show-transparent-png: кнопки вместо select
- show-grayscale-pixels-png: кнопки вместо select
- show-color-pixels-png: кнопки вместо select
- light-pixel-mask-png: кнопки вместо select
- verify-is-png: название неверное, в описании указано изображение, а фактически
проверяется текст
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный
вердикт? может отдельные поля - 0\1, yes\no?
### generators
- color-spectrum-png: кнопки вместо select
- draw-grid-png: выбор цвета фона
- placeholder-png: с галочкой "показать текст" нет заполнения фона
- blend-two-png: высота не задается
- step-colors-png: кнопки вместо select, высота не задается, не слишком ли
маленький max steps?
- emoji-to-png: выбор эмодзи
- color-wheel-png: сглаживание краев
- complementary-png: кнопки вместо select(?), не меняется, всего 2 цвета, высота
не задается
- triadic-png: кнопки вместо select, высота не задается
- tetradic-png: кнопки вместо select, высота не задается
- analogous-png: кнопки вместо select, высота не задается
- monochromatic-png: кнопки вместо select, высота не задается
- shades-png: кнопки вместо select, высота не задается
- mix-colors-png: высота не задается
- sort-colors-png: кнопки вместо select, высота не задается
## аудит полей
- у полей plate, position9 и font-style вторая подпись дублирует первую
- position offset: должны быть отдельные range slider на x и y
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
- поле seed (random) - range slider не нужен, поле для ввода и "randomize"
кнопка
- поле текста: нормальная высота по умолчанию (не одна строка), для svg —
форматирование или подсветка синтаксиса, для bin\hex — колонки, для
hex\bytes\rgba — пример в placeholder
-245
View File
@@ -1,245 +0,0 @@
# Карта инструментов: реализовано · добавить · идеи
> Статус: **active**. Живой документ для сверки с каталогом.
>
> **Карта перечисляет `slug` страниц (`web/src/lib/registry/pages/`) — то есть
> адреса вида `/tools/crop-png`:** это `slug: "crop-png"` у страницы, которая
> вызывает инструмент `id: "crop"`. Внутренние имена инструментов png-интента не
> держат, поэтому они от карты отделены; модель — `docs/architecture.md`,
> раздел 3. Источник правды — реестр. Этот список навигационный: сверять перед
> использованием. Формат записи: `slug — параметры через запятую`. Сравнение с
> каталогом источника (объём, нишевые клоны серий Logo*/Icon*/Stamp*Signature*)
> — замер в `docs/plan-seo.md`, §2; здесь он не дублируется.
>
> Количество инструментов здесь не фиксируется — оно выводится из раздела 1 и из
> `TOOLS` в `registry/index.ts`, и дублировать его цифрой значит заставлять себя
> обновлять документ при каждом добавлении (`AGENTS.md`).
---
## 1. Реализовано
Ids приведены к фактическим в `web/src/lib/registry/pages/`.
**Формат строки (проверяется тестом
`web/src/lib/registry/tools-map-doc.test.ts`):** в буллите всё **до первого
тире** `—` — это slug страниц через `/` или `,`; всё после тире — свободное
описание параметров и не разбирается. Второй slug нельзя оставлять после тире,
иначе он не попадёт в разбор и страница будет считаться неописанной.
### Конвертация (`convert.ts`)
Отдельных «из формата в PNG» инструментов **нет**: входной формат выбирается
загрузкой файла, любой инструмент принимает PNG/JPG/WebP/GIF/BMP. Все конвертеры
исходят из PNG.
- svg-to-png — width результата (текстовый вход: SVG-код)
- png-to-bmp — 24-бит, фон вместо альфы
- convert-png-to-jpg — background, quality
- convert-png-to-webp — quality
- png-to-base64 / base64-to-png — строка
- png-to-data-uri / data-uri-to-png — строка
- png-to-hex / hex-to-png — rrggbbaa по строкам / tokens + width
- png-to-bytes / bytes-to-png — десятичные RGBA-байты по строкам / tokens +
width
- png-to-rgb-values / rgb-values-to-png — rgba(r,g,b,a) по пикселям / числа +
width
### Прозрачность (`alpha.ts`)
- change-png-opacity — percent
- set-alpha-channel-png — percent
- remove-alpha-channel-png — без параметров (фон `#ffffff` захардкожен, см.
`docs/plan-fields-audit-fixes.md` D1)
- extract-alpha-mask-png — без параметров
- invert-alpha-png — без параметров
- remove-background-png — color, tolerance, outerOnly, smooth
- remove-color-from-png — targetColor, tolerance
- add-stroke-png — color, thickness
- find-contour-png — color, thickness
- make-thicker-png / make-thinner-png — radius
- harden-alpha-png — threshold
- feather-edges-png — radius (размытие только альфы)
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного)
- despeckle-alpha-png / close-holes-png — radius
- center-by-alpha-png — без параметров
- round-corners-png — radius
- circle-mask-png — size (диаметр, % меньшей стороны), offsetX, offsetY
- square-mask-png — widthPct, heightPct, offsetX, offsetY
- star-mask-png — points, innerRadius, size, rotation, offsetX, offsetY
- wavy-mask-png — size, amplitude, waves, phase, offsetX, offsetY
### Цвет (`color.ts`)
- grayscale-png / invert-colors-png / sepia-png / auto-contrast-png — без
параметров
- adjust-brightness-contrast-png — brightness, contrast
- change-png-hue — degrees
- extract-channel-png — channel (r/g/b)
- swap-channels-png — pair (r-g/r-b/g-b)
- black-and-white-png — threshold
- posterize-png — levels
- two-colors-png — lightColor, darkColor, threshold
- temperature-png — percent
- gamma-png — value
- tint-png — color, strength
- quantize-png — colors (k, median-cut)
- decrease-color-count-png — maxColors (пресеты 2…256)
- custom-palette-png — colors (hex через запятую, ближайший цвет)
- dithering-png — colors (k), pattern (Floyd–Steinberg / Bayer 4×4)
### Разложение каналов (`color.ts`)
- png-to-hsl, png-to-hsv, png-to-hsi, png-to-cmyk, png-to-ycbcr, png-to-lab —
component (h/s/l и т.п.), display (`gray` | `space-as-rgb`)
### Геометрия (`geometry.ts`)
- resize-png — width/height (размеры source по умолчанию; 0=авто для одной
стороны), keepAspect
- crop-png — x, y, width/height (полная область source по умолчанию)
- rotate-png — angle (90/180/270)
- flip-png — axis (h/v)
- skew-png — degX, degY
- rotate-free-png — angle
- zoom-png — scale
- shift-png — offsetX, offsetY, color фона
- add-padding-png — padding, transparent, color
- add-border-png — thickness, color
- fit-on-background-png — width, height, transparent, color
- tile-png — columns, rows
- split-into-parts-png — columns, rows (мультифайловый вывод, zip)
- trim-empty-space-png — threshold (альфа)
- change-canvas-size-png — width, height, anchor (3×3)
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad)
- swap-orientation-png — target (portrait/landscape)
- symmetric-copy-png — axis, keepSide
### Фильтры (`filters.ts`)
- blur-png — radius
- sharpen-png — strength
- vignette-png — strength
- jpeg-artifacts-png — quality (имитация пережатия jpg/webp)
- pixelate-png — blockSize (закрывает и их Color Blocks)
- randomize-pixels-png — blockSize, seed
- add-noise-png — amount, mode (mono/color), seed
- silhouette-png — color, threshold
### Анализ и вердикты (`analyze.ts`)
- png-file-size — без параметров (DOM-инструмент: кодирует в PNG и отдаёт
размер)
- png-is-transparent / png-is-grayscale / png-orientation — текстовый вердикт
- verify-is-png — текстовый источник (base64/data-uri), вердикт по сигнатуре
### Маски по свойствам пикселей (`analyze.ts`)
- show-transparent-png — color, opacity (подсветка прозрачных/полупрозрачных)
- show-grayscale-pixels-png — tolerance, mode (binary/highlight),
highlightColor, highlightOpacity
- show-color-pixels-png — tolerance, mode, highlightColor, highlightOpacity
- light-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
- dark-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
- unique-color-mask-png — rarity (макс. повторов), mode, highlightColor,
highlightOpacity
- extract-color-from-png — color, tolerance (оставить близкие, остальное
прозрачным)
### Генерация (`generate.ts`)
- create-empty-png — width, height, transparent, color
- single-color-png — width, height, color
- random-noise-png — width, height, seed
- linear-gradient-png — width, height, fromColor, toColor, direction
- color-spectrum-png — width, height, direction, saturation, lightness
- random-colors-png — width, height, blockSize, seed
- draw-grid-png — width, height, cols, rows, lineWidth, color, transparentBg
- placeholder-png — width, height, backgroundColor, color, showText
- blend-two-png — pair (from/to), width
- step-colors-png — pair (from/to), steps, width
- emoji-to-png — emoji, size
- text-to-png — text, fontSize, font, bold, color, transparentBg,
backgroundColor, padding
- color-wheel-png — size, lightness
- complementary-png / triadic-png / tetradic-png / analogous-png /
monochromatic-png / shades-png — общая база палитры: baseColor, width, layout;
`shades-png` дополнительно count, depth
- mix-colors-png — colors, width
- sort-colors-png — colors, порядок (hue/brightness/saturation), width
### Текст (`text.ts`)
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin,
plate, plateColor, plateOpacity
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate,
plateColor, plateOpacity
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY,
font, bold
---
## 2. Можно добавить — из onlinepngtools
Всё из этого списка **в реестре отсутствует**.
### Разложение каналов — остаток
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → см. идеи в
`docs/backlog.md`)
### Края и силуэт — остаток
- glow — radius, color, intensity
- shadow — offsetX, offsetY, blur, color, alpha
### Эффекты — остаток
- censor-region / erase-region — область (MEDIUM: нужен UI выделения → см. идеи)
- whirl — угол, центр, радиус (MEDIUM)
### Сортировка/блоки пикселей
- sort-pixels — blockSize, ключ (яркость/канал), направление (MEDIUM)
- slow-reveal / fade-in / fade-out / disappear — анимационные (→ идеи)
### Генераторы — остаток
- multi-color-gradient — список стопов (нужен новый тип параметра → MEDIUM-UI)
### Конвертеры — остаток
- png-to-gif — MEDIUM (однокадровый GIF-энкодер руками)
- gif-to-frames — MEDIUM (мультифайловый вывод → идеи)
- change-bit-depth — MEDIUM (пересборка PNG)
### Сжатие и качество
- compress, reduce-to-size, pick-a-color, extract-barcode — **не реализованы**;
заведены в `docs/backlog.md` (reduce-to-size зависит от UX-райза «Download»).
Пипетка для выбора цвета уже есть в превью, отдельная страница не планируется.
Извлечение штрихкодов — HARD, вне планов.
---
## 3. Идеи на рассмотрение (нужна архитектура или спорная ценность)
Развёрнутые описания этих идей живут в `docs/backlog.md`, раздел «Идеи»; здесь
только список.
- **Region-инструменты** — нужен selection-компонент на превью: censor-region,
erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area.
Один раз делаем selection — получаем шесть инструментов.
- **Мультифайловый выход** — механизм «результат = набор файлов» (zip)
реализован на `split-into-parts-png`. Остальные 1→many: gif-to-frames,
separate-colors, multiply-grid-as-files — подключаются по мере нужды.
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
extract-barcode.
- **Нишевые серии** (logo/icon/stamp/signature — замер в шапке документа) —
сознательно не копируем: это обычные операции над конкретным контентом, у нас
они доступны через базовые инструменты + цепочки.
- **Входные «из формата в PNG»** (jpg-to-png, webp-to-png) — решение и его SEO-
последствия описаны в разделе 1 выше; отдельные страницы под эти интенты —
предмет решения в `docs/plan-seo.md` §5.
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Redirecting...</title>
<script>
const NEW_DOMAIN = "https://easy-image-tools.com";
const OLD_REPO_NAME = "easy-png-tools";
</script>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: #ddd;
color: #222;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #222;
color: #ddd;
}
}
</style>
<script>
(function () {
let currentPath = window.location.pathname;
if (currentPath.startsWith("/" + OLD_REPO_NAME)) {
currentPath = currentPath.substring(OLD_REPO_NAME.length + 1);
}
const newUrl =
NEW_DOMAIN +
currentPath +
window.location.search +
window.location.hash;
window.location.replace(newUrl);
})();
</script>
<noscript>
<meta
http-equiv="refresh"
content="0; url=https://easy-image-tools.com"
/>
<link rel="canonical" href="https://easy-image-tools.com" />
</noscript>
</head>
<body>
<p>
"Easy png tools" now named "Easy image tools" and moved to a new domain.
Please follow this link: <a href="https://easy-image-tools.com"></a>.
</p>
</body>
</html>
-30
View File
@@ -1,30 +0,0 @@
{
"name": "easy-png-tools",
"version": "0.1.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "pnpm --dir web test",
"verify": "pnpm --dir web check && pnpm --dir web test:coverage && pnpm --dir web test:rules && pnpm --dir web lint:all && pnpm --dir web format:check && pnpm check:docs && pnpm --dir web build",
"dev": "pnpm --dir web dev",
"build": "pnpm --dir web build",
"format": "pnpm format:docs && pnpm --dir web format",
"format:docs": "prettier --write AGENTS.md README.md docs --log-level warn --cache",
"check:docs": "prettier --check AGENTS.md README.md docs --cache",
"generate:fixtures": "node tests/fixtures/manual-edge-cases/generate.mjs"
},
"keywords": [],
"author": "Ku6epXBOCTuK",
"license": "MIT",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^11.20.0",
"onFail": "download"
}
},
"type": "module",
"devDependencies": {
"prettier": "^3.9.6"
}
}
-223
View File
@@ -1,223 +0,0 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: ^11.20.0
version: 11.25.0
pnpm:
specifier: ^11.20.0
version: 11.25.0
packages:
'@pnpm/exe@11.25.0':
resolution: {integrity: sha512-X19R2uC+VAJ4UJQE9c/PCdOXbDaWnZtad7WUrTHBFQuMVaK9MAHbyO/WgahQCuRtTmJkLbxcWKKCk+JeTYFm/g==}
hasBin: true
'@pnpm/linux-arm64@11.25.0':
resolution: {integrity: sha512-ra8akqhzsbcOhKSJ9fFV8H+Oc9uGQAcp/XmIBEdT+8hPPoZCit3+RNCHmg8bLoNvpSLtRvZE0WFCMj7WyzxeBA==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.25.0':
resolution: {integrity: sha512-pQl/L10diKQCbF73viRrtVU8qVWMTudUCSG1uZ+EWBNKtU9Sbxe6Q378diXDb1uTwSgUVMQoypxlC1MTzh7qvQ==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.25.0':
resolution: {integrity: sha512-cYcrbB/xN1N6/5VUlFuHblb1gNyJgZv1CF5Pk1T0sHJxQMY4DFJV3OqHVAgahxyUfSTaQcVLvH1H2JFvd/+sgw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.25.0':
resolution: {integrity: sha512-HXDtaeQod16DDMUUcVLIMxvEc1jKn7IYsNPwbwAPs/Je/d3umRPJi3Ejjdn6e9qpXN6Oon+yvSsJr7B9oDt6KA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.25.0':
resolution: {integrity: sha512-m/eAgEqKhiSexGxWPHNXNnSRI0hudymg6K7LbrU2EoDs9IgJ28OKEz9mGxMzhkYBweEquR4k5teMNes/aToy9w==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.25.0':
resolution: {integrity: sha512-oAeECbtZ+eJziaBmPUkwJM8Dx7KVQ5FO367AXTjD2HGfB5ob1Bcu5hoUF5RBTgDBiP9fRQ1u13uAEpqar/6NLA==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.25.0':
resolution: {integrity: sha512-8/n+wCc0a8TRYTMFqti93Vh0cscdJ25xfMyYSDdXrLUh2ccxSFuS+SE7cNPjd/nOXoFAJvdW0kwfbyRPLa16/w==}
cpu: [x64]
os: [win32]
'@reflink/reflink-darwin-arm64@0.1.19':
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@reflink/reflink-darwin-x64@0.1.19':
resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@reflink/reflink-linux-arm64-gnu@0.1.19':
resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@reflink/reflink-linux-arm64-musl@0.1.19':
resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@reflink/reflink-linux-x64-gnu@0.1.19':
resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@reflink/reflink-linux-x64-musl@0.1.19':
resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@reflink/reflink-win32-arm64-msvc@0.1.19':
resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@reflink/reflink-win32-x64-msvc@0.1.19':
resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@reflink/reflink@0.1.19':
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
engines: {node: '>= 10'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.25.0:
resolution: {integrity: sha512-XN6SW08HX3Jetx+64YpC/+eEUkeJ8ZthxzHLhyHsKKruFg4BqNWvT+2ypCzb8wDv4j2zVrDUoXtNY+EfirfJVg==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.25.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.25.0
'@pnpm/linux-x64': 11.25.0
'@pnpm/linuxstatic-arm64': 11.25.0
'@pnpm/linuxstatic-x64': 11.25.0
'@pnpm/macos-arm64': 11.25.0
'@pnpm/win-arm64': 11.25.0
'@pnpm/win-x64': 11.25.0
'@pnpm/linux-arm64@11.25.0':
optional: true
'@pnpm/linux-x64@11.25.0':
optional: true
'@pnpm/linuxstatic-arm64@11.25.0':
optional: true
'@pnpm/linuxstatic-x64@11.25.0':
optional: true
'@pnpm/macos-arm64@11.25.0':
optional: true
'@pnpm/win-arm64@11.25.0':
optional: true
'@pnpm/win-x64@11.25.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
optional: true
'@reflink/reflink-darwin-x64@0.1.19':
optional: true
'@reflink/reflink-linux-arm64-gnu@0.1.19':
optional: true
'@reflink/reflink-linux-arm64-musl@0.1.19':
optional: true
'@reflink/reflink-linux-x64-gnu@0.1.19':
optional: true
'@reflink/reflink-linux-x64-musl@0.1.19':
optional: true
'@reflink/reflink-win32-arm64-msvc@0.1.19':
optional: true
'@reflink/reflink-win32-x64-msvc@0.1.19':
optional: true
'@reflink/reflink@0.1.19':
optionalDependencies:
'@reflink/reflink-darwin-arm64': 0.1.19
'@reflink/reflink-darwin-x64': 0.1.19
'@reflink/reflink-linux-arm64-gnu': 0.1.19
'@reflink/reflink-linux-arm64-musl': 0.1.19
'@reflink/reflink-linux-x64-gnu': 0.1.19
'@reflink/reflink-linux-x64-musl': 0.1.19
'@reflink/reflink-win32-arm64-msvc': 0.1.19
'@reflink/reflink-win32-x64-msvc': 0.1.19
detect-libc@2.1.2: {}
pnpm@11.25.0: {}
---
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
prettier:
specifier: ^3.9.6
version: 3.9.6
packages:
prettier@3.9.6:
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
engines: {node: '>=14'}
hasBin: true
snapshots:
prettier@3.9.6: {}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 B

-1
View File
@@ -1 +0,0 @@
this is definitely not a png file
-329
View File
@@ -1,329 +0,0 @@
import { deflateSync } from "node:zlib";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(data) {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++)
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const out = Buffer.alloc(8 + data.length + 4);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, "ascii");
data.copy(out, 8);
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
}
function makePng(width, height, pixelAt) {
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; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const stride = width * 4 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0; // filter: none
for (let x = 0; x < width; x++) {
const [r, g, b, a] = pixelAt(x, y);
const p = y * stride + 1 + x * 4;
raw[p] = r;
raw[p + 1] = g;
raw[p + 2] = b;
raw[p + 3] = a;
}
}
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makePalettePng(width, height, palette, pixelAt) {
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; // bit depth
ihdr[9] = 3; // color type: indexed
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
// PLTE: palette entries (R,G,B × count), padded to multiple of 3
const plteData = Buffer.alloc(256 * 3);
for (let i = 0; i < palette.length && i < 256; i++) {
plteData[i * 3] = palette[i][0];
plteData[i * 3 + 1] = palette[i][1];
plteData[i * 3 + 2] = palette[i][2];
}
// Raw image data: 1 byte per pixel (index)
const stride = width + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0;
for (let x = 0; x < width; x++) {
raw[y * stride + 1 + x] = pixelAt(x, y);
}
}
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("PLTE", plteData),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function make16bitPng(width, height, pixelAt) {
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] = 16; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const stride = width * 8 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0;
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([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makeCorruptPng() {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(4, 0);
ihdr.writeUInt32BE(4, 4);
ihdr[8] = 8;
ihdr[9] = 2; // RGB
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const stride = 4 * 3 + 1;
const raw = Buffer.alloc(4 * stride);
for (let y = 0; y < 4; y++) {
raw[y * stride] = 0;
for (let x = 0; x < 4; x++) {
const p = y * stride + 1 + x * 3;
raw[p] = 255;
raw[p + 1] = 0;
raw[p + 2] = 0;
}
}
const idat = chunk("IDAT", deflateSync(raw));
// Corrupt CRC: flip a byte in the CRC field
idat[idat.length - 1] ^= 0xff;
return Buffer.concat([sig, chunk("IHDR", ihdr), idat, chunk("IEND", Buffer.alloc(0))]);
}
function makeTruncatedPng() {
return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
}
const outDir = join(__dirname);
// 1. PNG 1×1
writeFileSync(join(outDir, "1x1.png"), makePng(1, 1, () => [255, 0, 0, 255]));
// 2. PNG 1×1 fully transparent
writeFileSync(
join(outDir, "1x1-transparent.png"),
makePng(1, 1, () => [0, 0, 0, 0]),
);
// 3. Large 1024×1024 checkerboard (≈1 megapixel RGBA)
writeFileSync(
join(outDir, "1024x1024-checker.png"),
makePng(1024, 1024, (x, y) =>
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255],
),
);
// 4. 1920×1080 gradient (≈2 megapixels, tests larger displays)
writeFileSync(
join(outDir, "1920x1080-gradient.png"),
makePng(1920, 1080, (x, y) => [
Math.floor((x / 1920) * 255),
Math.floor((y / 1080) * 255),
128,
255,
]),
);
// 5. Transparency checkerboard (black/white with alpha pattern)
writeFileSync(
join(outDir, "alpha-checkerboard.png"),
makePng(64, 64, (x, y) => {
const alpha = (x + y) % 3 === 0 ? 0 : 255;
const base = (x % 2 === y % 2) ? 0 : 255;
return [base, base, base, alpha];
}),
);
// 6. Palette / indexed-color PNG (4 colors: red, green, blue, white)
const palette = [
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[255, 255, 255],
];
writeFileSync(
join(outDir, "palette-4colors.png"),
makePalettePng(32, 32, palette, (x, y) => {
const idx = (Math.floor(x / 8) + Math.floor(y / 8)) % palette.length;
return idx;
}),
);
// 7. Palette with many colors (256-color full palette)
const fullPalette = Array.from({ length: 256 }, (_, i) => [
i,
(i * 3) % 256,
(i * 7) % 256,
]);
writeFileSync(
join(outDir, "palette-256.png"),
makePalettePng(64, 64, fullPalette, (x, y) => (x + y * 64) % 256),
);
// 8. 16-bit RGBA PNG (subtle gradient to test high bit depth)
writeFileSync(
join(outDir, "16bit-subtle.png"),
make16bitPng(32, 32, (x, y) => [
Math.floor((x / 32) * 65535),
Math.floor((y / 32) * 65535),
32768,
65535,
]),
);
// 9. Corrupt PNG (bad CRC in IDAT)
writeFileSync(join(outDir, "corrupt-bad-crc.png"), makeCorruptPng());
// 10. Truncated PNG (only header, no data)
writeFileSync(join(outDir, "truncated.png"), makeTruncatedPng());
// 11. Non-PNG file disguised as PNG
writeFileSync(
join(outDir, "fake-png.txt"),
Buffer.from("this is definitely not a png file"),
);
// 12. All-black fully opaque
writeFileSync(
join(outDir, "all-black-opaque.png"),
makePng(64, 64, () => [0, 0, 0, 255]),
);
// 13. All-white fully opaque
writeFileSync(
join(outDir, "all-white-opaque.png"),
makePng(64, 64, () => [255, 255, 255, 255]),
);
// 14. All-transparent
writeFileSync(
join(outDir, "all-transparent.png"),
makePng(64, 64, () => [0, 0, 0, 0]),
);
// 15. Semi-transparent gradient (alpha 0→255 across width)
writeFileSync(
join(outDir, "alpha-gradient.png"),
makePng(128, 64, (x, y) => [
0,
100,
255,
Math.floor((x / 128) * 255),
]),
);
// 16. Noise pattern (random-ish pixels)
writeFileSync(
join(outDir, "noise-pattern.png"),
makePng(100, 100, (x, y) => {
const v = ((x * 7919 + y * 104729) % 256);
return [v, (v * 3) % 256, (v * 7) % 256, 255];
}),
);
// 17. Minimal size: 2×2 with extreme colors
writeFileSync(
join(outDir, "2x2-extreme.png"),
makePng(2, 2, (x, y) => {
if (x === 0 && y === 0) return [0, 0, 0, 255];
if (x === 1 && y === 0) return [255, 255, 255, 255];
if (x === 0 && y === 1) return [255, 0, 0, 128];
return [0, 0, 255, 0];
}),
);
// 18. Long strip (1×4000) — tests scroll / vertical tools
writeFileSync(
join(outDir, "1x4000-strip.png"),
makePng(1, 4000, (x, y) => [y % 256, (y * 2) % 256, (y * 3) % 256, 255]),
);
// 19. Wide strip (4000×1) — tests horizontal handling
writeFileSync(
join(outDir, "4000x1-strip.png"),
makePng(4000, 1, (x, y) => [x % 256, (x * 2) % 256, (x * 3) % 256, 255]),
);
// 20. PNG with very high alpha variance (sparse transparency)
writeFileSync(
join(outDir, "sparse-alpha.png"),
makePng(100, 100, (x, y) => {
const sparse = ((x * 31 + y * 17) % 100) < 10;
return [sparse ? 0 : 100, sparse ? 0 : 200, 255, sparse ? 0 : 255];
}),
);
console.log("Generated 20 edge case PNG files in:", outDir);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 B

-28
View File
@@ -1,28 +0,0 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Playwright
/test-results
/playwright-report
/e2e-results
-1
View File
@@ -1 +0,0 @@
engine-strict=true
-9
View File
@@ -1,9 +0,0 @@
node_modules
build
.svelte-kit
dist
pnpm-lock.yaml
static
*.log
.DS_Store
audit
-23
View File
@@ -1,23 +0,0 @@
{
"useTabs": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"proseWrap": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.md",
"options": {
"useTabs": false
}
},
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
-50
View File
@@ -1,50 +0,0 @@
import { expect, test } from "playwright/test";
import {
expectNoErrors,
toolLink,
trackErrors,
useEnglish,
} from "./helpers/page";
test.beforeEach(async ({ page }) => {
await useEnglish(page);
});
test("catalog exposes a total and category headings", async ({ page }) => {
const sink = trackErrors(page);
await page.goto("/list-tools");
const total = page.getByRole("status", { name: /tools available/i });
await expect(total).toBeVisible();
await expect(total).toHaveAttribute("aria-label", /^\d+ /);
await expect(page.getByRole("heading", { level: 2 }).first()).toBeVisible();
expectNoErrors(sink);
});
test("catalog search narrows results", async ({ page }) => {
await page.goto("/list-tools");
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
await expect(toolLink(page, "resize-png")).toBeVisible();
await expect(toolLink(page, "flip-png")).toBeHidden();
});
test("category filter narrows the catalog and resets", async ({ page }) => {
await page.goto("/list-tools");
const convert = page.getByRole("button", { name: /convert/i, exact: true });
const all = page.getByRole("button", { name: /^all$/i });
await convert.click();
await expect(convert).toHaveAttribute("aria-pressed", "true");
await expect(toolLink(page, "convert-png-to-jpg")).toBeVisible();
await expect(toolLink(page, "flip-png")).toBeHidden();
await all.click();
await expect(all).toHaveAttribute("aria-pressed", "true");
await expect(toolLink(page, "flip-png")).toBeVisible();
});
test("workspace search matches a tool by title", async ({ page }) => {
await page.goto("/");
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
await expect(toolLink(page, "flip-png")).toBeVisible();
await expect(toolLink(page, "resize-png")).toBeHidden();
});
-62
View File
@@ -1,62 +0,0 @@
import { expect, test } from "playwright/test";
import {
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
} from "./helpers/page";
const GENERATOR_IDS = [
"create-empty-png",
"random-noise-png",
"linear-gradient-png",
"color-spectrum-png",
"random-colors-png",
"draw-grid-png",
"placeholder-png",
"blend-two-png",
"step-colors-png",
"emoji-to-png",
"color-wheel-png",
"complementary-png",
"triadic-png",
"tetradic-png",
"analogous-png",
"monochromatic-png",
"shades-png",
"mix-colors-png",
"sort-colors-png",
"text-to-png",
] as const;
test.describe("generators", () => {
test("single-color-png auto-runs and reacts to parameters", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "single-color-png");
await expect(resultImage(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0);
await page.getByRole("spinbutton", { name: "Width" }).fill("32");
await page.getByRole("spinbutton", { name: "Height" }).fill("24");
await expect(resultImage(page)).toHaveJSProperty("naturalWidth", 32);
await expect(resultImage(page)).toHaveJSProperty("naturalHeight", 24);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
for (const id of GENERATOR_IDS) {
test(`tool ${id} produces a result image`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, id);
await expect(resultImage(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(
0,
);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
}
});
-288
View File
@@ -1,288 +0,0 @@
import { deflateSync } from "node:zlib";
export interface SourceFile {
name: string;
mimeType: string;
buffer: Buffer;
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(data: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++)
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
function makeIhdr(
width: number,
height: number,
bitDepth: number,
colorType: number,
): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = bitDepth;
ihdr[9] = colorType;
return ihdr;
}
function chunk(type: string, data: Buffer): Buffer {
const out = Buffer.alloc(8 + data.length + 4);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, "ascii");
data.copy(out, 8);
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
}
function findChunk(png: Buffer, type: string): number {
let offset = PNG_SIGNATURE.length;
while (offset + 12 <= png.length) {
const length = png.readUInt32BE(offset);
const chunkType = png.subarray(offset + 4, offset + 8).toString("ascii");
if (chunkType === type) return offset;
offset += 12 + length;
}
throw new Error(`PNG chunk ${type} not found`);
}
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
export function makePng(
width: number,
height: number,
pixelAt: (x: number, y: number) => [number, number, number, number],
): Buffer {
const stride = width * 4 + 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 * 4;
raw[p] = r;
raw[p + 1] = g;
raw[p + 2] = b;
raw[p + 3] = a;
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 8, 6)),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function makeIndexedPng(
width: number,
height: number,
palette: [number, number, number][],
pixelAt: (x: number, y: number) => number,
): Buffer {
const plte = Buffer.alloc(palette.length * 3);
for (const [index, color] of palette.entries()) {
plte[index * 3] = color[0];
plte[index * 3 + 1] = color[1];
plte[index * 3 + 2] = color[2];
}
const stride = width + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
raw[y * stride + 1 + x] = pixelAt(x, y);
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 8, 3)),
chunk("PLTE", plte),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function make16BitPng(
width: number,
height: number,
pixelAt: (x: number, y: number) => [number, number, number, number],
): Buffer {
const stride = width * 8 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const [r, g, b, a] = pixelAt(x, y);
const p = y * stride + 1 + x * 8;
raw.writeUInt16BE(r, p);
raw.writeUInt16BE(g, p + 2);
raw.writeUInt16BE(b, p + 4);
raw.writeUInt16BE(a, p + 6);
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 16, 6)),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function withPngChunk(png: Buffer, type: string, data: Buffer): Buffer {
const iendOffset = png.length - 12;
return Buffer.concat([
png.subarray(0, iendOffset),
chunk(type, data),
png.subarray(iendOffset),
]);
}
export function withBadChunkCrc(png: Buffer, type: string): Buffer {
const copy = Buffer.from(png);
const offset = findChunk(copy, type);
const length = copy.readUInt32BE(offset);
copy[offset + 8 + length + 3] ^= 0xff;
return copy;
}
export function truncatePng(png: Buffer, length: number): Buffer {
return Buffer.from(png.subarray(0, length));
}
export function truncateChunkData(
png: Buffer,
type: string,
length: number,
): Buffer {
const offset = findChunk(png, type);
return truncatePng(png, offset + 8 + length);
}
export function withTrailingBytes(png: Buffer, tail: Buffer): Buffer {
return Buffer.concat([png, Buffer.from(tail)]);
}
const checker = (x: number, y: number): [number, number, number, number] =>
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
const alphaGrid = (x: number, y: number): [number, number, number, number] =>
(x + y) % 3 === 0 ? [0, 0, 0, 0] : [0, 128, 255, 255];
const solidRed = (): [number, number, number, number] => [255, 0, 0, 255];
export function asSourceFile(buffer: Buffer, name: string): SourceFile {
return { name, mimeType: "image/png", buffer };
}
export const opaquePng: SourceFile = asSourceFile(
makePng(64, 48, checker),
"opaque.png",
);
export const transparentPng: SourceFile = asSourceFile(
makePng(64, 64, alphaGrid),
"transparent.png",
);
export const onePixelPng: SourceFile = asSourceFile(
makePng(1, 1, solidRed),
"1px.png",
);
export const landscapePng: SourceFile = asSourceFile(
makePng(64, 16, checker),
"landscape.png",
);
export const largePng: SourceFile = asSourceFile(
makePng(256, 256, checker),
"large.png",
);
export const corruptPng: SourceFile = {
name: "corrupt.png",
mimeType: "image/png",
buffer: Buffer.from("this is definitely not a png file", "utf8"),
};
const specialPng = makePng(8, 8, checker);
const specialWithText = withPngChunk(
specialPng,
"tEXt",
Buffer.from("Comment\0q6d", "ascii"),
);
const palette4 = [
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[255, 255, 255],
] as [number, number, number][];
const palette256 = Array.from({ length: 256 }, (_, index) => [
index,
(index * 3) % 256,
(index * 7) % 256,
]) as [number, number, number][];
export const crcBadIdatPng = asSourceFile(
withBadChunkCrc(specialPng, "IDAT"),
"crc-bad-idat.png",
);
export const crcBadAncillaryPng = asSourceFile(
withBadChunkCrc(specialWithText, "tEXt"),
"crc-bad-ancillary.png",
);
export const truncatedHeaderPng = asSourceFile(
truncatePng(specialPng, PNG_SIGNATURE.length + 12 + 13),
"truncated-header.png",
);
export const truncatedNoIendPng = asSourceFile(
truncatePng(specialPng, specialPng.length - 12),
"truncated-no-iend.png",
);
export const idatCutPng = asSourceFile(
truncateChunkData(specialPng, "IDAT", 4),
"idat-cut.png",
);
export const garbageTailPng = asSourceFile(
withTrailingBytes(specialPng, Buffer.from("trailing-garbage", "ascii")),
"garbage-tail.png",
);
export const palette4Png = asSourceFile(
makeIndexedPng(
8,
8,
palette4,
(x, y) => (Math.floor(x / 2) + Math.floor(y / 2)) % palette4.length,
),
"palette-4.png",
);
export const palette256Png = asSourceFile(
makeIndexedPng(16, 16, palette256, (x, y) => (x + y * 16) % 256),
"palette-256.png",
);
export const sixteenBitPng = asSourceFile(
make16BitPng(8, 8, (x, y) => [x * 8000, y * 8000, 32768, 65535]),
"16bit.png",
);
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
export const svgMarkup =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6">' +
'<rect width="10" height="6" fill="#ff0000"/></svg>';
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
`${r} ${g} ${b} ${a}`;
/** Строка для bytes-to-png / rgb-values-to-png, ровно 32 пикселя в ширину по умолчанию. */
export function pixelRow(
count: number,
rgba: [number, number, number, number],
): string {
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
}
-120
View File
@@ -1,120 +0,0 @@
import { readFile } from "node:fs/promises";
import {
expect,
type Download,
type Locator,
type Page,
} from "playwright/test";
import type { SourceFile } from "./fixtures";
export const TEST_IDS = {
source: "source-image",
result: "result-image",
verdict: "result-verdict",
empty: "empty-state",
} as const;
export type ErrorSink = { errors: string[] };
export function trackErrors(page: Page): ErrorSink {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
page.on("console", (m) => {
if (m.type() === "error") errors.push(`console: ${m.text()}`);
});
return { errors };
}
export async function useEnglish(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
}
export async function openTool(page: Page, slug: string): Promise<void> {
await useEnglish(page);
await page.goto(`/tools/${slug}`);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
}
export function toolLink(page: Page, slug: string): Locator {
return page.locator(`a[href="/tools/${slug}"]`);
}
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> {
const input = page.locator('input[type="file"]');
await expect(input).toHaveCount(1);
await input.setInputFiles({
name: file.name,
mimeType: file.mimeType,
buffer: file.buffer,
});
}
export async function downloadResultFile(page: Page): Promise<Download> {
const button = page.getByRole("button", { name: "Download result" });
await expect(button).toBeVisible();
const [download] = await Promise.all([
page.waitForEvent("download"),
button.click(),
]);
return download;
}
export async function downloadResultBytes(
page: Page,
): Promise<{ name: string; bytes: Uint8Array }> {
const download = await downloadResultFile(page);
const path = await download.path();
if (!path) throw new Error("download path is unavailable");
return {
name: download.suggestedFilename(),
bytes: new Uint8Array(await readFile(path)),
};
}
export async function downloadResult(page: Page): Promise<string> {
return (await downloadResultFile(page)).suggestedFilename();
}
export async function expectNoErrorAlert(page: Page): Promise<void> {
await expect(errorAlert(page)).toHaveCount(0);
}
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
expect(sink.errors, "нет console/page errors").toEqual([]);
}
-51
View File
@@ -1,51 +0,0 @@
import { expect, test } from "playwright/test";
import { pixelRow } from "./helpers/fixtures";
import { openTool, toolLink, useEnglish } from "./helpers/page";
test("поиск каталога находит по названию из другой локали", async ({
page,
}) => {
await useEnglish(page);
await page.goto("/list-tools");
const input = page.getByRole("textbox");
const flipCard = toolLink(page, "flip-png");
await input.fill("отразить");
await expect(flipCard).toContainText("Flip PNG");
await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(flipCard).toContainText("Отразить PNG");
await input.fill("flip");
await expect(flipCard).toContainText("Отразить PNG");
});
test("RU/EN: переключение переводит заголовок инструмента", async ({
page,
}) => {
await openTool(page, "resize-png");
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
const enBtn = page.getByRole("button", { name: "EN", exact: true });
const heading = page.getByRole("heading", { level: 1 });
await ruBtn.click();
await expect(heading).toHaveText("Изменить размер PNG");
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
await enBtn.click();
await expect(heading).toHaveText("Resize PNG");
});
test("ошибка run'а локализуется и переводится при смене языка", async ({
page,
}) => {
await openTool(page, "bytes-to-png");
await page
.getByRole("textbox", { name: "Text data" })
.fill(pixelRow(33, [255, 0, 0, 255]));
await page.getByRole("button", { name: "Render text" }).click();
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
await expect(alert).toHaveText(/\S/);
await expect(alert).not.toHaveText(/^errors\./);
const english = await alert.textContent();
await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(alert).not.toHaveText(english ?? "");
await expect(alert).not.toHaveText(/^errors\./);
});
-54
View File
@@ -1,54 +0,0 @@
import { expect, test } from "playwright/test";
import { expectNoErrors, trackErrors, useEnglish } from "./helpers/page";
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
test.beforeEach(async ({ page }) => {
await useEnglish(page);
});
for (const route of ROUTES) {
test(`page ${route} loads without errors`, async ({ page }) => {
const sink = trackErrors(page);
const resp = await page.goto(route);
expect(resp?.status()).toBe(200);
await expect(page.getByRole("main")).toBeVisible();
expectNoErrors(sink);
});
}
test("unknown tool route responds 404 on static build", async ({ page }) => {
const resp = await page.goto("/tools/definitely-not-a-tool");
expect(resp?.status()).toBe(404);
});
test("theme toggle persists the selected theme", async ({ page }) => {
const sink = trackErrors(page);
await page.goto("/");
const main = page.getByRole("main");
const before = await main.getAttribute("data-theme");
expect(["light", "dark"]).toContain(before);
await page.getByRole("button", { name: "Toggle theme" }).click();
await expect(main).not.toHaveAttribute("data-theme", before ?? "");
const after = await main.getAttribute("data-theme");
const stored = await page.evaluate(() => localStorage.getItem("theme"));
expect(["light", "dark"]).toContain(after);
expect(stored).toBe(after);
await page.reload();
await expect(main).toHaveAttribute("data-theme", after ?? "");
expectNoErrors(sink);
});
test("language toggle marks the active button", async ({ page }) => {
await page.goto("/");
const group = page.getByRole("group", { name: "Language" });
const en = group.getByRole("button", { name: "EN" });
await en.click();
await expect(en).toHaveAttribute("aria-pressed", "true");
await expect(group.getByRole("button", { name: "RU" })).toHaveAttribute(
"aria-pressed",
"false",
);
});
-217
View File
@@ -1,217 +0,0 @@
import { unzipSync } from "fflate";
import { expect, test } from "playwright/test";
import {
corruptPng,
landscapePng,
largePng,
onePixelPng,
opaquePng,
} from "./helpers/fixtures";
import {
downloadResultBytes,
errorAlert,
expectNoErrorAlert,
expectNoErrors,
openTool,
rangeInput,
resultImage,
sourceImage,
trackErrors,
uploadImage,
useEnglish,
} from "./helpers/page";
test("resize-png uses the source dimensions by default", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"64",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"48",
);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("crop-png starts with the full source area", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "crop-png");
await uploadImage(page, opaquePng);
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"64",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"48",
);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("flip-png: full flow - upload, result, download", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, landscapePng);
await expect(resultImage(page)).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("flip-png.png");
expect(Array.from(bytes.subarray(0, 8))).toEqual([
137, 80, 78, 71, 13, 10, 26, 10,
]);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("convert-png-to-jpg: produces JPEG and quality affects size", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "convert-png-to-jpg");
await uploadImage(page, largePng);
await expect(resultImage(page)).toBeVisible();
const quality = rangeInput(page, "Quality");
await quality.fill("10");
const low = await downloadResultBytes(page);
expect(low.name).toBe("convert-png-to-jpg.jpg");
expect(Array.from(low.bytes.subarray(0, 3))).toEqual([255, 216, 255]);
await quality.fill("90");
const high = await downloadResultBytes(page);
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("convert-png-to-webp: produces WebP and quality affects size", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "convert-png-to-webp");
await uploadImage(page, largePng);
await expect(resultImage(page)).toBeVisible();
const quality = rangeInput(page, "Quality");
await quality.fill("10");
const low = await downloadResultBytes(page);
expect(low.name).toBe("convert-png-to-webp.webp");
expect(Array.from(low.bytes.subarray(0, 4))).toEqual([82, 73, 70, 70]);
expect(Array.from(low.bytes.subarray(8, 12))).toEqual([87, 69, 66, 80]);
await quality.fill("90");
const high = await downloadResultBytes(page);
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("png-to-bmp: produces a BMP download", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "png-to-bmp");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("png-to-bmp.bmp");
expect(Array.from(bytes.subarray(0, 2))).toEqual([66, 77]);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("reset restores defaults and re-runs with the source", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "blur-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
const radius = rangeInput(page, /radius/i);
await radius.fill("20");
await expect(radius).toHaveValue("20");
await page.getByRole("button", { name: "Reset" }).click();
await expect(radius).toHaveValue("4");
await expect(sourceImage(page)).toBeVisible();
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("split-into-parts-png downloads a zip with named PNG parts", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "split-into-parts-png");
await uploadImage(page, landscapePng);
await rangeInput(page, "Columns").fill("3");
await rangeInput(page, "Rows").fill("2");
await expect(page.getByText("part-1-3.png")).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("split-into-parts-png.zip");
const entries = unzipSync(bytes);
expect(Object.keys(entries)).toEqual([
"part-1-1.png",
"part-1-2.png",
"part-1-3.png",
"part-2-1.png",
"part-2-2.png",
"part-2-3.png",
]);
for (const bytes of Object.values(entries)) {
expect(Array.from(bytes.subarray(0, 8))).toEqual([
137, 80, 78, 71, 13, 10, 26, 10,
]);
}
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("blur: changing slider updates result image", async ({ page }) => {
await openTool(page, "blur-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
const firstSource = await resultImage(page).getAttribute("src");
await rangeInput(page, /radius/i).fill("20");
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
});
test("runs without Web Worker (fallback)", async ({ browser }) => {
const context = await browser.newContext();
try {
await context.addInitScript(() => {
Object.defineProperty(window, "Worker", {
value: undefined,
configurable: true,
});
});
const page = await context.newPage();
await useEnglish(page);
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, landscapePng);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
} finally {
await context.close();
}
});
test("invalid file upload shows error, no crash", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, corruptPng);
await expect(errorAlert(page)).toBeVisible();
await expect(resultImage(page)).toHaveCount(0);
expectNoErrors(sink);
});
for (const fixture of [onePixelPng, largePng]) {
test(`handles ${fixture.name} correctly`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, fixture);
await expect(resultImage(page)).toBeVisible();
expectNoErrors(sink);
});
}
-72
View File
@@ -1,72 +0,0 @@
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);
}
});
}
});
-104
View File
@@ -1,104 +0,0 @@
import { expect, test } from "playwright/test";
import {
opaquePng,
pixelRow,
svgMarkup,
tinyBase64,
transparentPng,
} from "./helpers/fixtures";
import {
expectNoErrorAlert,
expectNoErrors,
openTool,
renderText,
resultImage,
trackErrors,
uploadImage,
verdictStatus,
} from "./helpers/page";
async function expectVerdict(page: Parameters<typeof verdictStatus>[0]) {
const verdict = verdictStatus(page);
await expect(verdict).toBeVisible();
await expect(verdict).toHaveText(/\S/);
}
for (const [id, input] of [
["base64-to-png", tinyBase64],
["hex-to-png", "ff0000ff"],
["bytes-to-png", pixelRow(32, [255, 0, 0, 255])],
[
"rgb-values-to-png",
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
],
["data-uri-to-png", `data:image/png;base64,${tinyBase64}`],
["svg-to-png", svgMarkup],
] as const) {
test(`text input → ${id} produces result image`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, id);
await expect(async () => {
await renderText(page, input);
await expect(resultImage(page)).toBeVisible();
}).toPass({ timeout: 25_000 });
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
}
test("png-to-base64 shows decoded text result", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "png-to-base64");
await uploadImage(page, opaquePng);
const output = page.getByLabel("Text result");
await expect(output).toBeVisible();
await expect(output).toHaveText(/\S/);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("verify-is-png renders different verdicts for valid and invalid input", async ({
page,
}) => {
await openTool(page, "verify-is-png");
const verdict = verdictStatus(page);
await renderText(page, tinyBase64);
await expect(verdict).toBeVisible();
const validText = await verdict.textContent();
await renderText(page, "aGVsbG8=");
await expect(verdict).not.toHaveText(validText ?? "");
});
test("png-is-transparent: opaque image renders a verdict", async ({ page }) => {
await openTool(page, "png-is-transparent");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-is-transparent: transparent image renders a verdict", async ({
page,
}) => {
await openTool(page, "png-is-transparent");
await uploadImage(page, transparentPng);
await expectVerdict(page);
});
test("png-is-grayscale: colored image renders a verdict", async ({ page }) => {
await openTool(page, "png-is-grayscale");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-orientation: landscape image renders a verdict", async ({ page }) => {
await openTool(page, "png-orientation");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-file-size: renders a verdict", async ({ page }) => {
await openTool(page, "png-file-size");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
-147
View File
@@ -1,147 +0,0 @@
import { expect, test } from "playwright/test";
import type { SourceFile } from "./helpers/fixtures";
import { opaquePng, transparentPng } from "./helpers/fixtures";
import {
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
textResult,
uploadImage,
verdictStatus,
} from "./helpers/page";
type Kind = "image" | "text-out" | "verdict";
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
// ── convert ────────────────────────────────────────────────
{ id: "convert-png-to-jpg", kind: "image" },
{ id: "convert-png-to-webp", kind: "image" },
{ id: "png-to-bmp", kind: "image" },
{ id: "png-to-base64", kind: "text-out" },
{ id: "png-to-data-uri", kind: "text-out" },
{ id: "png-to-hex", kind: "text-out" },
{ id: "png-to-bytes", kind: "text-out" },
{ id: "png-to-rgb-values", kind: "text-out" },
// ── alpha ──────────────────────────────────────────────────
{ id: "remove-background-png", kind: "image", file: transparentPng },
{ id: "remove-color-from-png", kind: "image", file: transparentPng },
{ id: "round-corners-png", kind: "image", file: transparentPng },
{ id: "add-stroke-png", kind: "image", file: transparentPng },
{ id: "circle-mask-png", kind: "image", file: transparentPng },
{ id: "wavy-mask-png", kind: "image", file: transparentPng },
{ id: "find-contour-png", kind: "image", file: transparentPng },
{ id: "feather-edges-png", kind: "image", file: transparentPng },
{ id: "clean-edges-png", kind: "image", file: transparentPng },
{ id: "make-thicker-png", kind: "image", file: transparentPng },
{ id: "make-thinner-png", kind: "image", file: transparentPng },
{ id: "despeckle-alpha-png", kind: "image", file: transparentPng },
{ id: "close-holes-png", kind: "image", file: transparentPng },
{ id: "harden-alpha-png", kind: "image", file: transparentPng },
{ id: "invert-alpha-png", kind: "image", file: transparentPng },
{ id: "set-alpha-channel-png", kind: "image", file: transparentPng },
{ id: "extract-alpha-mask-png", kind: "image", file: transparentPng },
{ id: "remove-alpha-channel-png", kind: "image", file: transparentPng },
{ id: "square-mask-png", kind: "image", file: transparentPng },
{ id: "star-mask-png", kind: "image", file: transparentPng },
// ── color ──────────────────────────────────────────────────
{ id: "grayscale-png", kind: "image" },
{ id: "invert-colors-png", kind: "image" },
{ id: "sepia-png", kind: "image" },
{ id: "quantize-png", kind: "image" },
{ id: "dithering-png", kind: "image" },
{ id: "decrease-color-count-png", kind: "image" },
{ id: "two-colors-png", kind: "image" },
{ id: "tint-png", kind: "image" },
{ id: "auto-contrast-png", kind: "image" },
{ id: "png-to-hsl", kind: "image" },
{ id: "png-to-cmyk", kind: "image" },
{ id: "gamma-png", kind: "image" },
{ id: "temperature-png", kind: "image" },
{ id: "custom-palette-png", kind: "image" },
{ id: "adjust-brightness-contrast-png", kind: "image" },
{ id: "change-png-opacity", kind: "image" },
{ id: "change-png-hue", kind: "image" },
{ id: "swap-channels-png", kind: "image" },
{ id: "black-and-white-png", kind: "image" },
{ id: "posterize-png", kind: "image" },
{ id: "png-to-hsv", kind: "image" },
{ id: "png-to-hsi", kind: "image" },
{ id: "png-to-ycbcr", kind: "image" },
{ id: "png-to-lab", kind: "image" },
// ── geometry ───────────────────────────────────────────────
{ id: "flip-png", kind: "image" },
{ id: "rotate-png", kind: "image" },
{ id: "rotate-free-png", kind: "image" },
{ id: "fit-on-background-png", kind: "image" },
{ id: "add-padding-png", kind: "image" },
{ id: "add-border-png", kind: "image" },
{ id: "tile-png", kind: "image" },
{ id: "change-canvas-size-png", kind: "image" },
{ id: "trim-empty-space-png", kind: "image" },
{ id: "swap-orientation-png", kind: "image" },
{ id: "skew-png", kind: "image" },
{ id: "zoom-png", kind: "image" },
{ id: "center-by-alpha-png", kind: "image" },
{ id: "change-aspect-ratio-png", kind: "image" },
{ id: "symmetric-copy-png", kind: "image" },
{ id: "shift-png", kind: "image" },
// ── filters ────────────────────────────────────────────────
{ id: "blur-png", kind: "image" },
{ id: "sharpen-png", kind: "image" },
{ id: "pixelate-png", kind: "image" },
{ id: "add-noise-png", kind: "image" },
{ id: "vignette-png", kind: "image" },
{ id: "silhouette-png", kind: "image" },
{ id: "randomize-pixels-png", kind: "image" },
{ id: "jpeg-artifacts-png", kind: "image" },
// ── text ───────────────────────────────────────────────────
{ id: "add-text-png", kind: "image" },
{ id: "date-stamp-png", kind: "image" },
{ id: "watermark-tile-png", kind: "image" },
// ── analyze (image output) ─────────────────────────────────
{ id: "extract-color-from-png", kind: "image", file: transparentPng },
{ id: "show-transparent-png", kind: "image", file: transparentPng },
{ id: "light-pixel-mask-png", kind: "image", file: transparentPng },
{ id: "extract-channel-png", kind: "image", file: transparentPng },
{ id: "unique-color-mask-png", kind: "image", file: transparentPng },
{ id: "show-grayscale-pixels-png", kind: "image", file: transparentPng },
{ id: "show-color-pixels-png", kind: "image", file: transparentPng },
{ id: "dark-pixel-mask-png", kind: "image", file: transparentPng },
// ── analyze (verdict output) ───────────────────────────────
{ id: "png-is-transparent", kind: "verdict" },
{ id: "png-is-grayscale", kind: "verdict" },
{ id: "png-orientation", kind: "verdict" },
{ id: "png-file-size", kind: "verdict" },
];
test.describe("smoke: tools produce output without errors", () => {
for (const { id, kind, file: fixture } of CASES) {
test(`tool ${id} (${kind})`, async ({ page }) => {
const sink = trackErrors(page);
const outputLocator = () => {
switch (kind) {
case "image":
return resultImage(page);
case "text-out":
return textResult(page);
case "verdict":
return verdictStatus(page);
}
};
// toPass: устойчивость к hydration-рейсу (ввод до гидрации SvelteKit
// может не обработаться с первого раза).
await expect(async () => {
await openTool(page, id);
await uploadImage(page, fixture ?? opaquePng);
await expect(outputLocator()).toBeVisible({ timeout: 8_000 });
}).toPass({ timeout: 25_000 });
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
}
});
-197
View File
@@ -1,197 +0,0 @@
# ESLint-плагины и дизайн-токены: детали
> Статус: **active**. Область применения — `web/eslint-plugins/**`,
> `web/scripts/**`, конфиги линтинга. Остальная рутина разработки её не требует;
> детали нужны только при правке самих линт-правил или конфигов. Команды и
> пороги — `docs/quality-gates.md`.
## Структура
- `web/eslint-plugins/` — локальные ESLint-плагины.
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
токенов) и мини-словари `lib/i18n/`.
- `web/scripts/`:
- `lint-all.mjs` — оркестратор `lint:all`.
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
- `__tests__/` — тесты аудита и HCT-плагина; подхватываются обычным
`vitest run` через `scripts/**/*.test.mjs`, поэтому входят в `verify`.
- `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css.
## Конфигурация ESLint (`web/eslint.config.js`)
Устроена инкрементально:
- На **весь код** — парсинг TS/Svelte + правило
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
`prefer: 'type-imports'`).
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
устаревшие пути в `eslint.config.js`.
- Базовый `js.configs.recommended` + `globals.node` навешаны на инфраструктуру
линтинга: `eslint-plugins/**/*.js` и `scripts/**/*.mjs`.
TS/svelte-рекомендации туда не подключаются.
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
`let`).
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
там запрещены tseslint) — при добавлении файлов в `__tests__/` /
`__fixtures__/` дописать их туда же.
- design-tokens правила применяются к тем же scoped-путям
(`src/lib/components/**`, `src/routes/**`).
- `i18n/no-hardcoded-user-text` применяется к обоим scoped-путям
(`src/lib/components/**` и `src/routes/**`): хардкод в `+page.svelte` и
`+layout.svelte` — такой же production-UI. Исключение одно — `/kit`, витрина с
намеренно латинскими подписями; выключается отдельным объектом конфига с
`files: ["**/src/routes/kit/**/*.svelte"]`, потому что негативные паттерны в
`files` не сужают область действия правила. Опция `allowWords` — brand не
хардкодится в правиле.
## Правила плагина design-tokens
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
color-токен (`--color-*`/`--brand-*`) и наоборот.
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
производные от токенов (`var()`, `calc()`, unitless-числа).
- `design-tokens/no-undefined-in-svelte` — `var(--x)` в `<style>` должен быть
определён в `src/app.css` (словарь токенов) или локально в компоненте. Файл
читается один раз и кэшируется (не `glob`-зависим).
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
## Плагин i18n (`i18n/`)
### `i18n/dict-consistency`
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
правила.
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
расхождения:
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
пропавшая целая секция репортится один раз (не по каждому потомку);
- **пустые значения**: `""` и строка из одних пробелов;
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
когда отклоняется одна-единственная локаль.
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
(синтакс, без типов).
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
(осознанные отклонения до достижения zero-warn).
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
отсутствовать только в базовой локали; в текущей конфигурации это тексты
страниц `pages.*.title` и `pages.*.description` (EN-строка берётся из
реестра). Пустые значения и placeholders для присутствующих ключей всё равно
проверяются.
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
проверяются на missing; в текущей конфигурации это неиспользуемые
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
диске.
### `i18n/no-hardcoded-user-text`
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
report-only (`warn`), baseline — 0 срабатываний.
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
скобках (`{expr}`) тоже — они считаются в другом месте.
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
имена файлов и версии (`result.png`, `v0.1.0`, `v1`), размеры (`512×512`),
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
`/kit` — витрина компонентов с намеренно латинскими подписями (исключение
живёт в `eslint.config.js`, а не в правиле).
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
продукта передаётся конфигом, а не зашито в правило.
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
переменные, которым разрешено быть hex/rgb, остальные цвета — только `hct()` и
только в app.css.
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в app.css,
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
подтягиваются через `@csstools/postcss-global-data`).
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
Нюансы stylelint:
- Правило `custom-property-pattern` тестирует паттерн **без** `--` (`--x` →
`x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
`ignore`, не в `except`): пустые строки между подряд идущими токенами
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
токены в app.css отдельными блоками.
## Токен-аудит (`check-tokens.mjs`)
Лёгкий оркестратор поверх `web/scripts/token-audit/*`. Проверяет:
- **Parity**: каждый цветовой токен из `:root` обязан иметь пару в
`[data-theme="dark"]` и наоборот. Производные токены (значение содержит
`var()`, напр. `hct(from var(--...))`) из пары исключены — они наследуют тему
автоматически.
- **hct-only для цветов**: ЛЮБОЕ цветовое значение в app.css обязано быть
`hct(...)` — и литерал, и производное `hct(from var(...) h c t)`. Исключения:
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
`color-mix(...)` (единственный легальный способ смешать два токена).
`oklch()/rgb()/#hex` в `--color-*` запрещены.
- **Резолв `var()`**: каждая ссылка `var(--x)` в app.css должна указывать на
токен, определённый в этом же файле. `var(--x, fallback)` не считается ошибкой
— fallback легален без определения. Провал (exit 1), потому что несуществующий
токен молча роняет объявление.
- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не
используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`,
поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение
оставить проверку неблокирующей зафиксировано в `docs/quality-gates.md`.
Провалами (exit 1) считаются parity, hct-авторство и резолв `var()`.
## Тесты кастомных линт-правил
Линт-правила покрыты юнит-тестами (Vitest, `test:rules`):
- `__tests__/design-tokens.test.ts` — три чистых правила
(`no-hardcoded-in-svelte`, `no-category-mismatch`,
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
`__fixtures__/src/app.css` (не из реального `src/app.css`).
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
`process.cwd()` не трогается, реальные `src/` не читаются.
Запуск: `pnpm --dir web test:rules` (`vitest run eslint-plugins/__tests__`).
Полный `pnpm --dir web test` тоже их гоняет.
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
добавление/правка стабов — тоже работа с валидным кодом.
@@ -1,11 +0,0 @@
:root {
--color-fg: #101010;
--space-1: calc(var(--size-1) / 2);
--z-nav: 100;
--duration-fast: 120ms;
--text-md: 0.875rem;
}
@@ -1 +0,0 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -1 +0,0 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -1 +0,0 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -1,28 +0,0 @@
import type { Dict } from "./dict";
export const de: Dict = {
header: {
workspace: "Arbeitsbereich",
catalog: "Katalog",
},
home: {
hero: "Hallo {name}!",
restoreLast: "{title} wiederherstellen",
},
errors: {
sourceRequired: "Quelle erforderlich",
},
pages: {
addBorder: {
title: "Rahmen hinzufügen",
description: "Zeichnet einen {kind}-Rahmen.",
},
},
tools: {
addBorder: {
results: {
done: "Rahmen {what}",
},
},
},
};
@@ -1,20 +0,0 @@
export const LOCALES = ["en", "ru", "de"] as const;
export const BASE_LOCALE: "en" = "en";
export type Dict = {
header: Record<string, string>;
home: Record<string, string>;
errors: Record<string, string>;
pages?: Record<string, PageStrings>;
tools: Record<string, ToolStrings>;
};
export type PageStrings = {
title?: string;
description?: string;
};
export type ToolStrings = {
results?: Record<string, string>;
};
@@ -1,24 +0,0 @@
import type { Dict } from "./dict";
export const en: Dict = {
header: {
workspace: "Workspace",
catalog: "Catalog",
},
home: {
hero: "Hello {name}!",
restoreLast: "Restore {title}",
},
errors: {
sourceRequired: "Source image required",
},
// Базовая локаль: тексты страниц берутся из EN-строк реестра.
pages: {},
tools: {
addBorder: {
results: {
done: "Border {what}",
},
},
},
};
@@ -1,28 +0,0 @@
import type { Dict } from "./dict";
export const ru: Dict = {
header: {
workspace: "Рабочая область",
catalog: "Каталог",
},
home: {
hero: "Привет, {name}!",
restoreLast: "Вернуть {title}",
},
errors: {
sourceRequired: "Нужен исходник",
},
pages: {
addBorder: {
title: "Добавить рамку",
description: "Рисует рамку {kind}.",
},
},
tools: {
addBorder: {
results: {
done: "Рамка {what}",
},
},
},
};
@@ -1 +0,0 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -1 +0,0 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -1 +0,0 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -1,205 +0,0 @@
// Tests for the design-tokens plugin rules (web/eslint-plugins/design-tokens).
//
// Strategy:
// - no-hardcoded-in-svelte, no-category-mismatch, no-token-definition-in-svelte
// are pure AST checks -> standard RuleTester with string cases.
// - no-undefined-in-svelte reads the token dictionary from <cwd>/src/app.css,
// so it runs through the Linter API with cwd pinned to the fixtures dir
// (see __tests__/helpers.ts). The fixture dictionary has exactly five
// tokens; anything else must be reported even if it exists in the REAL
// web/src/app.css.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import noCategoryMismatch from "../design-tokens/no-category-mismatch.js";
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js";
import noUndefined from "../design-tokens/no-undefined-in-svelte.js";
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
const parserOptions = {
parser: tseslint.parser,
};
// Vitest doesn't expose describe/it as globals (no `globals: true` in the
// config), so RuleTester would fall back to its synchronous default handler.
// Register the real ones so each case becomes a proper vitest test.
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions,
},
});
/** Wrap CSS from a <style> block into a minimal Svelte component. */
const component = (style: string): string =>
`<div class="box">x</div>\n\n<style>\n${style}\n</style>\n`;
const frame = (style: string) => ({
code: component(style),
filename: "Component.svelte",
});
describe("design-tokens/no-hardcoded-in-svelte", () => {
ruleTester.run("no-hardcoded-in-svelte", asRuleModule(noHardcoded), {
valid: [
frame(".box { color: var(--color-fg); }"),
frame(".box { padding: var(--space-3); }"),
frame(".box { background-color: transparent; }"),
frame(".box { width: 100%; }"),
frame(".box { line-height: 1.5; }"),
frame(".box { transition: var(--duration-fast); }"),
frame(".box { z-index: var(--z-nav); }"),
frame(".box { font-size: var(--text-md); }"),
frame("<div>no style block at all</div>"),
],
invalid: [
{
...frame(".box { color: #ff0000; }"),
errors: [{ messageId: "hardcodedColor" }],
},
{
...frame(".box { padding: 16px; }"),
errors: [{ messageId: "hardcodedSize" }],
},
{
...frame(".box { transition: 200ms; }"),
errors: [{ messageId: "hardcodedDuration" }],
},
{
...frame(".box { z-index: 100; }"),
errors: [{ messageId: "hardcodedZIndex" }],
},
{
...frame(
"@media (max-width: 640px) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "hardcodedBreakpoint" }],
},
{
...frame(
"@media (min-width: var(--bp-m)) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "varInMedia" }],
},
{
...frame(
".box { color: color-mix(in srgb, var(--color-a), var(--color-b)); }",
),
errors: [{ messageId: "colorMix" }],
},
],
});
});
describe("design-tokens/no-category-mismatch", () => {
ruleTester.run("no-category-mismatch", asRuleModule(noCategoryMismatch), {
valid: [
frame(".box { padding: var(--space-1); }"),
frame(".box { color: var(--color-fg); }"),
frame(".box { border: 1px solid var(--color-border); }"),
],
invalid: [
{
...frame(".box { padding: var(--color-fg); }"),
errors: [{ messageId: "categoryMismatch" }],
},
{
...frame(".box { color: var(--space-1); }"),
errors: [{ messageId: "categoryMismatch" }],
},
],
});
});
describe("design-tokens/no-token-definition-in-svelte", () => {
ruleTester.run(
"no-token-definition-in-svelte",
asRuleModule(noTokenDefinition),
{
valid: [
frame(".box { --local: var(--color-fg); }"),
frame(".box { --local: calc(var(--space-1) * 2); }"),
frame(".box { --ratio: 1.5; }"),
],
invalid: [
{
...frame(".box { --local: #ff0000; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: 12px; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: oklch(0.5 0.1 240); }"),
errors: [{ messageId: "tokenPrimitive" }],
},
],
},
);
});
describe("design-tokens/no-undefined-in-svelte", () => {
const undefinedConfig: FlatConfig = [
{
files: ["**/*.svelte"],
plugins: {
"design-tokens": {
rules: { "no-undefined-in-svelte": asRuleModule(noUndefined) },
},
},
rules: { "design-tokens/no-undefined-in-svelte": "error" },
languageOptions: {
parser: svelteParser,
parserOptions,
},
},
];
it("accepts tokens defined in the fixture app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-fg); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("accepts component-local overrides", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { --local-x: var(--color-fg); color: var(--local-x); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("flags an unknown token even if it exists in the real web/src/app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-love); }"),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--color-love");
});
it("flags unknown tokens referenced inside @media params", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(
"@media (width >= var(--bp-xs)) { .box { width: var(--text-md); } }",
),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--bp-xs");
});
});
@@ -1,216 +0,0 @@
// Tests for i18n/dict-consistency (web/eslint-plugins/i18n/dict-consistency.js).
//
// The rule reads its own dict dir from disk (LOCALES in dict.ts + sibling
// locale files), so it runs through the Linter API with cwd pinned to the
// fixture tree (see __tests__/helpers.ts). The committed fixtures at
// __fixtures__/src/lib/i18n/ are mutually consistent (zero-warn) — tests feed
// MODIFIED variants of en.ts / ru.ts as the linted code so each check is
// exercised against the on-disk references.
//
// Reporting contract: each file reports only its OWN gaps. A key missing in a
// file produces exactly ONE `missingKey` warning, emitted on the incomplete
// file itself (the union of all keys comes from the on-disk dicts), regardless
// of how many other locales have the key. Placeholder parity is compared
// against the canonical set — the one shared by the most locales (ties by
// LOCALES order) — so a deviation is reported exactly once.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import tseslint from "typescript-eslint";
import dictConsistency from "../i18n/dict-consistency.js";
import {
asRuleModule,
FIXTURES_SRC,
verifyInFixtures,
type FlatConfig,
} from "./helpers.js";
const I18N_DIR = path.join(FIXTURES_SRC, "lib", "i18n");
const fixture = (name: string) =>
fs.readFileSync(path.join(I18N_DIR, name), "utf8");
function lint(code: string, relFile: string, options?: unknown) {
const rule = options ? ["warn", options] : "warn";
const config: FlatConfig = [
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
{
files: ["**/*.ts"],
plugins: {
i18n: {
rules: { "dict-consistency": asRuleModule(dictConsistency) },
},
},
rules: { "i18n/dict-consistency": rule as "warn" },
},
];
return verifyInFixtures(config, code, relFile);
}
function messageIds(messages: { messageId?: string }[]) {
return messages.map((m) => m.messageId);
}
const registryPolicy = {
baseLocaleFallback: ["pages.*.title", "pages.*.description"],
ignoreMissingPatterns: ["tools.*.params"],
};
describe("i18n/dict-consistency", () => {
it("stays clean on the consistent fixture dicts", () => {
expect(
messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts", registryPolicy)),
).toEqual([]);
expect(
messageIds(lint(fixture("ru.ts"), "lib/i18n/ru.ts", registryPolicy)),
).toEqual([]);
expect(
messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts", registryPolicy)),
).toEqual([]);
});
it("allows page texts only in the base locale", () => {
// EN не переводит заголовки страниц: их берёт EN-строка реестра.
expect(
messageIds(lint(fixture("en.ts"), "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 tool object with nothing but ignored results", () => {
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", {
...registryPolicy,
ignoreMissingPatterns: ["tools.*.results"],
}),
),
).toEqual([]);
});
it("still reports empty page text", () => {
const ruEmpty = fixture("ru.ts").replace(
'title: "Добавить рамку",',
'title: "",',
);
expect(messageIds(lint(ruEmpty, "lib/i18n/ru.ts", registryPolicy))).toEqual(
["emptyValue"],
);
});
it("flags a leaf key missing in the current file", () => {
const enMissing = fixture("en.ts").replace(
'\t\trestoreLast: "Restore {title}",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts", registryPolicy);
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"home.restoreLast"');
expect(messages[0].message).toContain(" in en");
});
it("reports a missing whole section once, not per child key", () => {
const enNoHeader = fixture("en.ts").replace(/\theader: \{[\s\S]*?\},/, "");
const messages = lint(enNoHeader, "lib/i18n/en.ts", registryPolicy);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"header"');
expect(messages[0].message).not.toContain("header.workspace");
});
it("emits ONE warning per gap even when several locales own the key", () => {
const enMissing = fixture("en.ts").replace(
'\t\tsourceRequired: "Source image required",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts", registryPolicy);
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"errors.sourceRequired"');
expect(messages[0].message).toContain(" in en");
});
it("flags empty and whitespace-only values", () => {
const enEmpty = fixture("en.ts")
.replace('workspace: "Workspace",', 'workspace: "",')
.replace('catalog: "Catalog",', 'catalog: " ",');
const messages = lint(enEmpty, "lib/i18n/en.ts", registryPolicy);
expect(messageIds(messages)).toEqual(["emptyValue", "emptyValue"]);
expect(messages[0].message).toContain('"header.workspace"');
expect(messages[0].message).toContain(" in en");
expect(messages[1].message).toContain('"header.catalog"');
});
it("flags a placeholder lost in translation (vs the canonical set)", () => {
const ruLostPlaceholder = fixture("ru.ts").replace(
'hero: "Привет, {name}!",',
'hero: "Привет!",',
);
const messages = lint(ruLostPlaceholder, "lib/i18n/ru.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
expect(messages[0].message).toContain('"home.hero"');
expect(messages[0].message).toContain("{} vs expected {name}");
});
it("flags an extra placeholder introduced in one locale", () => {
const ruExtraPlaceholder = fixture("ru.ts").replace(
'description: "Рисует рамку {kind}.",',
'description: "Рисует рамку {kind} от {author}.",',
);
const messages = lint(ruExtraPlaceholder, "lib/i18n/ru.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
expect(messages[0].message).toContain('"pages.addBorder.description"');
expect(messages[0].message).toContain("{author, kind} vs expected {kind}");
});
it("honours allowPaths for conscious deviations", () => {
const enMissing = fixture("en.ts").replace(
'\t\tsourceRequired: "Source image required",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts", {
...registryPolicy,
allowPaths: ["errors.sourceRequired"],
});
expect(messageIds(messages)).toEqual([]);
});
it("ignores files that are not locale dicts", () => {
const notALocale = `
export const helper = { a: "b" };
`;
expect(messageIds(lint(notALocale, "lib/i18n/t.ts"))).toEqual([]);
expect(messageIds(lint(notALocale, "lib/i18n/misc.ts"))).toEqual([]);
});
});
-49
View File
@@ -1,49 +0,0 @@
// Shared harness for testing the custom ESLint rules in eslint-plugins/.
//
// Two strategies, picked per rule in the test files:
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
// - Linter API + fixtures — needed when a rule reads real files, such as
// no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
// dictionary. `verifyInFixtures` runs a rule with cwd fixed to
// `__fixtures__/`, without touching process.cwd() or the real src/ tree.
import { Linter } from "eslint";
import type { Rule } from "eslint";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const FIXTURES_DIR = path.resolve(HERE, "../__fixtures__");
export const FIXTURES_SRC = path.join(FIXTURES_DIR, "src");
/** Flat-config shape the Linter.verify accepts. */
export type FlatConfig = Parameters<Linter["verify"]>[1];
/**
* Run a rule (or a config) against `code` as if it were the fixture file
* `relFile` under `__fixtures__/src`. Returns the lint messages.
*/
export function verifyInFixtures(
config: FlatConfig,
code: string,
relFile: string,
) {
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
}
/** Wrap a script body into a minimal Svelte component (runs in runes mode). */
export function svelteComponent(script: string): string {
return `<main>Hello</main>\n\n<script lang="ts">\n${script}\n</script>\n`;
}
/**
* Narrow an inferred JS rule to the typed `Rule.RuleModule`. The plugin rule
* files are plain JS, so TypeScript infers a widened shape (`meta.type: string`)
* that `RuleTester.run` rejects; the shape is structurally correct at runtime,
* which is why existing rules keep their JS inference and only the tests cast.
*/
export function asRuleModule(rule: unknown): Rule.RuleModule {
return rule as Rule.RuleModule;
}
@@ -1,99 +0,0 @@
// Tests for the conventions/interface-props rule
// (web/eslint-plugins/conventions/interface-props.js).
//
// The rule is a pure AST check (no filesystem), so it runs through the standard
// RuleTester with svelte-eslint-parser + the TS sub-parser. No fixtures needed.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import interfaceProps from "../conventions/interface-props.js";
import { asRuleModule, svelteComponent } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const frame = (script: string) => ({
code: svelteComponent(script),
filename: "Component.svelte",
});
ruleTester.run("interface-props", asRuleModule(interfaceProps), {
valid: [
{ code: "<main>Hello</main>", filename: "Component.svelte" },
frame("let x = 1;"),
// $state / $derived calls are unrelated, not props.
frame("const s = $state(0);"),
frame("const doubled = $derived(s * 2);"),
frame(
"interface Props { label: string }\nlet { label }: Props = $props();",
),
frame(
"interface Props { label: string }\nlet { label = 'x' }: Props = $props();",
),
frame(
"interface Props { value: string }\nlet { value = $bindable() }: Props = $props();",
),
// Interface declared after the destructuring is still fine.
frame(
"let { label }: Props = $props();\ninterface Props { label: string }",
),
frame(
"interface Props { id: string; rest?: unknown }\nlet { id, ...rest }: Props = $props();",
),
frame(
"import type { Snippet } from 'svelte';\n" +
"interface Props { children?: Snippet }\n" +
"let { children }: Props = $props();",
),
],
invalid: [
{
// Untyped destructuring with a local interface -> autofixable.
...frame("interface Props { label: string }\nlet { label } = $props();"),
output: svelteComponent(
"interface Props { label: string }\nlet { label }: Props = $props();",
),
errors: [{ messageId: "untypedDestructure" }],
},
{
// Untyped destructuring without an interface -> report, no fix.
...frame("let { label } = $props();"),
errors: [{ messageId: "untypedDestructure" }],
},
{
// Binding the whole props object instead of destructuring.
...frame("let props = $props();"),
errors: [{ messageId: "noDestructure" }],
},
{
// Inline generic is banned outright.
...frame("const props = $props<{ a: string }>();"),
errors: [{ messageId: "inlineGeneric" }],
},
{
// A different type name than the local interface Props.
...frame(
"interface Props { a: string }\nlet { a }: StageProps = $props();",
),
errors: [{ messageId: "notNamedProps" }],
},
{
// Inline object props type instead of interface Props.
...frame("let { a }: { a: string } = $props();"),
errors: [{ messageId: "inlineObjectType" }],
},
{
// Reference to Props without any local declaration.
...frame("let { a }: Props = $props();"),
errors: [{ messageId: "missingInterface" }],
},
],
});
@@ -1,167 +0,0 @@
// Tests for the i18n/no-hardcoded-user-text rule
// (web/eslint-plugins/i18n/no-hardcoded-user-text.js).
//
// The rule is a pure AST check over the Svelte template, so it runs through the
// standard RuleTester with svelte-eslint-parser + the TS sub-parser. The
// exclusion list (brand, formats, units, registry source strings) is the whole
// point of the pilot, so every exclusion has a case here.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import rule, { hasUserWords } from "../i18n/no-hardcoded-user-text.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const OPTIONS = [{ allowWords: ["easy-png-tools"] }];
ruleTester.run("no-hardcoded-user-text", asRuleModule(rule), {
valid: [
// Translated text comes from t(), never from a literal.
{ code: '<p>{t("ui.reset")}</p>', filename: "Component.svelte" },
{
code: '<Button label={t("actions.download")} />',
filename: "Component.svelte",
},
// Formats, units and file names are not copy.
{ code: "<p>PNG</p>", filename: "Component.svelte" },
{ code: "<p>result.png</p>", filename: "Component.svelte" },
{ code: "<p>512×512</p>", filename: "Component.svelte" },
{ code: '<span class="u">px</span>', filename: "Component.svelte" },
{ code: "<p>1.0 s</p>", filename: "Component.svelte" },
{ code: "<p>v0.1.0</p>", filename: "Component.svelte" },
// Registry source strings and other shouted identifiers.
{ code: "<p>BACKGROUND</p>", filename: "Component.svelte" },
{ code: '<StepCard type="TRANSFORM" />', filename: "Component.svelte" },
// Technical attributes are out of scope.
{
code: '<Button variant="outline" tone="danger" size="lg" />',
filename: "Component.svelte",
},
{
code: '<a href="/tools/resize-png">{t("nav.home")}</a>',
filename: "x.svelte",
},
// Values written as expressions are computed elsewhere.
{
code: "<p>{value}</p>",
filename: "Component.svelte",
},
{
code: "<EmptyState title={copy} description={hint} />",
filename: "Component.svelte",
},
// Brand configured as an allowed word.
{
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
options: OPTIONS,
},
{
code: "<footer><span>easy-png-tools</span><span>v0.1.0</span></footer>",
filename: "Component.svelte",
options: OPTIONS,
},
// <style> and <script> bodies are code, not copy.
{
code: '<p>{t("ui.reset")}</p>\n<style>.a { color: red; }</style>',
filename: "Component.svelte",
options: OPTIONS,
},
{
code: '<script>const hint = "internal message";</script>\n<p>{t("ui.reset")}</p>',
filename: "Component.svelte",
options: OPTIONS,
},
],
invalid: [
{
code: "<p>Visible text</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<span class="axis-label">From</span>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="Download result" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="outline" variant="outline" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<ImageCard alt="Preview of the processed image" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Mixed content: the text part carries copy, the unit part does not.
code: "<p>Result {value} px</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Navigation copy is user text too.
code: '<a href="/tools/resize-png">Home</a>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Nested elements are walked too.
code: "<div><span><b>Delete file</b></span></div>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Without the option the brand word is reported: the exclusion is
// configuration, not a hardcoded constant inside the rule.
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
],
});
describe("hasUserWords", () => {
it("accepts technical fragments", () => {
for (const value of [
"",
" ",
"px",
"512×512",
"result.png",
"1.0 s",
"v0.1.0",
"× ·",
"BACKGROUND",
"·",
]) {
expect(hasUserWords(value), value).toBe(false);
}
});
it("accepts configured brand words", () => {
expect(hasUserWords("easy-png-tools", ["easy-png-tools"])).toBe(false);
expect(hasUserWords("easy-png-tools")).toBe(true);
});
it("accepts formats and units next to real copy", () => {
expect(hasUserWords("PNG 512×512")).toBe(false);
expect(hasUserWords("Result PNG")).toBe(true);
});
});
@@ -1,78 +0,0 @@
// Tests for the conventions/no-string-union-alias rule
// (web/eslint-plugins/conventions/no-string-union-alias.js).
//
// The rule is a pure AST check (no filesystem), so it runs through the standard
// RuleTester: plain .ts via the tseslint parser, and (because the rule is also
// enabled on new .svelte scripts) .svelte via the svelte tester. No fixtures.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import noStringUnionAlias from "../conventions/no-string-union-alias.js";
import { asRuleModule, svelteComponent } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tsTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
const svelteTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const frame = (script: string) => ({
code: svelteComponent(script),
filename: "Component.svelte",
});
tsTester.run("no-string-union-alias", asRuleModule(noStringUnionAlias), {
valid: [
{ code: "type X = 1 | 2;", filename: "types.ts" },
{ code: "type X = 'a' | number;", filename: "types.ts" },
{ code: "type X = string;", filename: "types.ts" },
{ code: "const KIND = { a: 1, b: 2 } as const;", filename: "types.ts" },
{ code: "function f(x: 'a' | 'b') {}", filename: "types.ts" },
{ code: "const x: 'a' | 'b' = 'a';", filename: "types.ts" },
{ code: "type X = 'a' | TemplateStrings;", filename: "types.ts" },
],
invalid: [
{
code: "type Kind = 'a' | 'b' | 'c';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
{
// Nested/parenthesized unions still resolve to string literals.
code: "type Kind = ('a' | 'b') | 'c';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
{
code: "type Lang =\n\t'en'\n\t| 'ru';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
],
});
// The rule also runs on new .svelte files (their scripts are TS).
svelteTester.run(
"no-string-union-alias (svelte script)",
asRuleModule(noStringUnionAlias),
{
valid: [frame("type X = 1 | 2;"), frame("const M = { a: 1 } as const;")],
invalid: [
{
...frame("type Kind = 'a' | 'b' | 'c';"),
errors: [{ messageId: "stringUnion" }],
},
],
},
);
-28
View File
@@ -1,28 +0,0 @@
/**
* Local ESLint plugin "conventions".
*
* Cross-cutting code conventions that the recommended rule sets don't enforce
* (and plain @typescript-eslint rules cannot express in one shot):
*
* - interface-props: Svelte 5 props always go through a local `interface
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
* type imports, no untyped destructuring);
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
* 'b'`) are banned in favor of a single `as const` object + indexed access,
* so literal sets live in exactly one place.
*
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
*/
import interfaceProps from "./interface-props.js";
import noStringUnionAlias from "./no-string-union-alias.js";
export default {
meta: {
name: "conventions",
version: "0.1.0",
},
rules: {
"interface-props": interfaceProps,
"no-string-union-alias": noStringUnionAlias,
},
};
@@ -1,135 +0,0 @@
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
//
// Convention (AGENTS.md): every typed props of a component is described by a
// local `interface Props`, and the props are destructured with the annotation:
//
// interface Props {
// label: string;
// accent?: boolean;
// children?: Snippet;
// }
// let { label, accent = false, children }: Props = $props();
//
// Banned instead:
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
// type away from the file structure;
// - untyped destructuring `let { ... } = $props()`;
// - binding the whole props object (`const props = $props()`);
// - any props type name other than the local `interface Props`.
//
// Inline `import('...')` type queries are NOT checked here: they are already
// banned by @typescript-eslint/consistent-type-imports.
//
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
// $props() only exists in instance <script> blocks, so no scope/type info is
// needed. Interfaces are collected from every <script> (module + instance).
import { isInsideScriptElement } from "./utils.js";
export default {
meta: {
type: "suggestion",
fixable: "code",
docs: {
description:
"Require a local `interface Props` and `let { ... }: Props = $props()` for Svelte 5 props; ban inline generics, untyped destructuring and non-local props type names.",
category: "Svelte conventions",
recommended: true,
},
schema: [],
messages: {
inlineGeneric:
"Avoid the inline generic `$props<T>()`; declare a local `interface Props` and destructure it: `let { ... }: Props = $props()`.",
untypedDestructure:
"Annotate the props destructuring with a local `interface Props`: `let { ... }: Props = $props()`.",
noDestructure:
"Destructure props with `let { ... }: Props = $props()` instead of binding the whole `$props()` object.",
notNamedProps:
"The props type must be the local `interface Props` (found '{{name}}').",
inlineObjectType:
"Declare a local `interface Props` instead of an inline object props type.",
missingInterface:
"No local `interface Props` is declared in this component; add one and use it as the props type.",
},
},
create(context) {
const interfaces = new Set();
return {
Program(node) {
for (const child of node.body) {
if (child.type !== "SvelteScriptElement") continue;
for (const stmt of child.body ?? []) {
if (stmt.type !== "TSInterfaceDeclaration") continue;
interfaces.add(stmt.id.name);
}
}
},
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "$props"
) {
return;
}
if (!isInsideScriptElement(node)) return;
// Inline generic $props<{ ... }>() is banned outright.
if (node.typeArguments?.params?.length) {
context.report({ node: node.callee, messageId: "inlineGeneric" });
return;
}
let declarator = node.parent;
while (declarator && declarator.type !== "VariableDeclarator") {
declarator = declarator.parent;
}
if (!declarator) return;
const id = declarator.id;
if (id.type === "ObjectPattern") {
const annotation = id.typeAnnotation?.typeAnnotation ?? null;
if (annotation) {
if (annotation.type === "TSTypeReference") {
const typeName = annotation.typeName;
if (!typeName || typeName.type !== "Identifier") return;
if (typeName.name !== "Props") {
context.report({
node: typeName,
messageId: "notNamedProps",
data: { name: typeName.name },
});
} else if (!interfaces.has("Props")) {
context.report({
node: typeName,
messageId: "missingInterface",
});
}
} else if (annotation.type !== "TSImportType") {
context.report({
node: annotation,
messageId: "inlineObjectType",
});
}
} else {
context.report({
node: id,
messageId: "untypedDestructure",
...(interfaces.has("Props")
? {
fix: (fixer) => fixer.insertTextAfter(id, ": Props"),
}
: {}),
});
}
return;
}
if (id.type === "Identifier") {
context.report({ node: id, messageId: "noDestructure" });
}
},
};
},
};
@@ -1,64 +0,0 @@
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
//
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
// duplicates the literal set: the same set is re-typed in many places, and new
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
// source. Prefer a single `as const` object as the source of truth and derive
// the type from it with indexed access.
//
// const KIND = { a: ..., b: ..., c: ... } as const;
// type Kind = (typeof KIND)[keyof typeof KIND];
//
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
// property types (one-off uses) are left alone.
/** True when the union and all its (possibly nested) members are string literals. */
function allStringLiterals(/** @type {any} */ union) {
for (const member of union.types) {
if (member.type === "TSUnionType") {
if (!allStringLiterals(member)) return false;
} else if (member.type === "TSLiteralType") {
const literal = member.literal;
if (!(literal.type === "Literal" && typeof literal.value === "string")) {
return false;
}
} else {
return false;
}
}
return true;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban string-literal union type aliases in favor of an `as const` object plus `(typeof X)[keyof typeof X]`.",
category: "TypeScript conventions",
recommended: true,
},
schema: [],
messages: {
stringUnion:
"Prefer one `as const` object over the string-literal union alias '{{name}}' — literal sets duplicated across types or files drift apart. Derive the type instead: `const {{constName}} = {...} as const; type {{name}} = (typeof {{constName}})[keyof typeof {{constName}}]`.",
},
},
create(context) {
return {
TSTypeAliasDeclaration(node) {
const annotation = node.typeAnnotation;
if (!annotation || annotation.type !== "TSUnionType") return;
if (!allStringLiterals(annotation)) return;
context.report({
node: node.id,
messageId: "stringUnion",
data: {
name: node.id.name,
constName: node.id.name.toUpperCase(),
},
});
},
};
},
};
-15
View File
@@ -1,15 +0,0 @@
// Shared helpers for the conventions plugin (web/eslint-plugins/conventions).
/**
* True when the node sits somewhere under a Svelte <script> block
* (SvelteScriptElement). Used to limit $props() checks to script code.
* @param {any} node
*/
export function isInsideScriptElement(node) {
let cursor = node.parent;
while (cursor && cursor.type !== "Program") {
if (cursor.type === "SvelteScriptElement") return true;
cursor = cursor.parent;
}
return false;
}
-64
View File
@@ -1,64 +0,0 @@
// Shared constants for the design-tokens ESLint plugin.
// Single source for property lists and regexes so each rule cooks from the
// same vocabulary.
// =====================================================================
// Properties that accept a COLOR.
// =====================================================================
export const COLOR_PROPS =
/^(color|background|background-color|background-image|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
// =====================================================================
// Properties that accept a SIZE (px/rem/em).
// z-index is handled separately (it's an integer, not a length).
// =====================================================================
export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
// =====================================================================
// SHORTHAND properties that accept BOTH a color and a size. The browser
// assigns their sub-properties by value type at runtime (length -> width,
// color -> ...-color), so they cannot be category-checked positionally.
// border -> border-width + border-color
// outline -> outline-width + outline-color
// text-decoration -> text-decoration-line/-color/...
// column-rule -> column-rule-width + column-rule-color
// The longhands they expand to (border-width, border-color, ...) are already
// covered individually by SIZE_PROPS / COLOR_PROPS.
// =====================================================================
export const MIXED_PROPS =
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
// =====================================================================
// Properties that carry a DURATION (ms/s) — transitions/animations.
// =====================================================================
export const DURATION_PROPS =
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
// =====================================================================
// A hardcoded COLOR literal: hex / color functions / named colors.
// Regex legend:
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
// - names: common CSS color names on a word boundary
// =====================================================================
export const COLOR_LITERAL =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
// =====================================================================
// A hardcoded SIZE literal (px/rem/em), fractional allowed: 0.5rem.
// =====================================================================
export const FORBIDDEN_SIZE_TOKEN = /\d+(?:\.\d+)?(?:px|rem|em)\b/g;
// =====================================================================
// A hardcoded DURATION literal (ms/s): 200ms, 0.3s.
// =====================================================================
export const FORBIDDEN_DURATION_TOKEN = /\d+(?:\.\d+)?(?:ms|s)\b/g;
// =====================================================================
// Token PREFIXES by category (tests run against the var() name, e.g.
// --color-bg, --brand-main, --space-1, --z-header).
// =====================================================================
export const COLOR_TOKEN = /^--(?:color|brand)-/;
export const SIZE_TOKEN = /^--(?:space|size|text|radius|bp|z)-/;
export const DURATION_TOKEN = /^--(?:duration|ease|motion)-/;
@@ -1,95 +0,0 @@
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
// A size property (padding, gap, font-size ...) must use a SIZE token
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
// (color, background, border-color ...) must use a COLOR token
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
// is a sign the wrong token is being reused.
import {
COLOR_PROPS,
COLOR_TOKEN,
MIXED_PROPS,
SIZE_PROPS,
SIZE_TOKEN,
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Collect the token names referenced by var() in a value.
const collectVars = (value) =>
Array.from(value.matchAll(/var\(\s*(--[\w-]+)/g), (m) => m[1]);
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban using a color token in size properties and a size token in color properties; each property must use tokens of its own category.",
category: "Design tokens",
recommended: true,
},
messages: {
categoryMismatch:
"Token '{{token}}' in '{{prop}}' belongs to the {{category}} category; expected a {{expected}} token.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
// Mixed shorthands (border, outline, text-decoration,
// column-rule) legitimately take BOTH a size and a color.
// The browser assigns sub-properties by value type at runtime,
// not by position, so skip category checks for them.
// Their longhands (border-width, border-color, ...) are still
// covered individually.
if (MIXED_PROPS.test(prop)) return;
const isSizeProp = SIZE_PROPS.test(prop);
const isColorProp = COLOR_PROPS.test(prop);
if (!isSizeProp && !isColorProp) return;
for (const token of collectVars(value)) {
// A size property that holds a COLOR token.
if (isSizeProp && COLOR_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "color",
expected: "size",
});
}
// A color property that holds a SIZE token (z-radius/space/text...).
if (isColorProp && SIZE_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "size",
expected: "color",
});
}
}
});
},
};
},
};
@@ -1,171 +0,0 @@
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
// Everything visual in a Svelte <style> block must come from design tokens
// (var(--...)); direct literals are banned. color-mix() is banned too — its
// result must be tokenized in the design CSS file.
import {
COLOR_LITERAL,
COLOR_PROPS,
DURATION_PROPS,
FORBIDDEN_DURATION_TOKEN,
FORBIDDEN_SIZE_TOKEN,
MIXED_PROPS,
SIZE_PROPS,
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban hardcoded colors, sizes, durations and z-index in Svelte <style> blocks; use CSS variables (design tokens).",
category: "Design tokens",
recommended: true,
},
messages: {
hardcodedColor:
"Hardcoded color '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedSize:
"Hardcoded size '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedDuration:
"Hardcoded duration '{{value}}' in '{{prop}}'. Use a var(--duration-...) token.",
hardcodedZIndex:
"Hardcoded z-index '{{value}}'. Use a var(--z-...) token.",
hardcodedBreakpoint:
"Hardcoded breakpoint '{{value}}' in @media. Use the @custom-media name instead: declare `@custom-media --bp-* (...)` in app.css and write `@media (--bp-*)`.",
varInMedia:
"var() inside @media '{{value}}'. Custom properties do not resolve in media queries — declare `@custom-media --bp-* (...)` in app.css and use `@media (--bp-*)`.",
colorMix:
"color-mix() in component ({{value}}). Tokenize the result in the design CSS file.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// =====================================================================
// 3) NO HARDCODED BREAKPOINTS IN @media
// @media conditions must use --bp-* tokens, not raw lengths.
// Regex: (max|min)-width followed by a NON-var(() number.
// =====================================================================
const BREAKPOINT_RE =
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
// Custom properties do not resolve inside @media conditions, so any
// var(--bp-*) inside @media is a latent bug — the block never applies.
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
// Sub-rule 6: color-mix() is banned anywhere in a component style.
const COLOR_MIX_RE = /color-mix\s*\(/gi;
// --- @media breakpoints --- //
root.walkAtRules("media", (atRule) => {
const params = atRule.params ?? "";
let m;
BREAKPOINT_RE.lastIndex = 0;
while ((m = BREAKPOINT_RE.exec(params)) !== null) {
report(atRule, "hardcodedBreakpoint", { value: m[1] });
}
VAR_IN_MEDIA_RE.lastIndex = 0;
while ((m = VAR_IN_MEDIA_RE.exec(params)) !== null) {
report(atRule, "varInMedia", { value: m[1] });
}
});
// --- declarations --- //
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
// Sub-rule 6: color-mix in any property.
if (COLOR_MIX_RE.test(value)) {
report(decl, "colorMix", { value: value.trim() });
}
// Sub-rule 4: durations in transition/animation props.
if (DURATION_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const m = withoutVars.match(FORBIDDEN_DURATION_TOKEN);
if (m) {
report(decl, "hardcodedDuration", { prop, value: m[0] });
}
}
// Sub-rule 7: z-index must come from a var(--z-...).
if (prop === "z-index") {
const withoutVars = stripVars(value).trim();
if (/^-?\d+$/.test(withoutVars)) {
report(decl, "hardcodedZIndex", { value: withoutVars });
}
}
// Only inspect known layout properties from here on.
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
// Sub-rule 2: sizes.
// Fire only on a real remaining px/rem/em token — percentages
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
// The only legal absolute length is a 0px reset line.
// MIXED_PROPS (border/outline shorthands) are checked for their
// SIZE half too — a border width must come from var(--size-*).
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const reported = new Set();
for (const match of withoutVars.matchAll(FORBIDDEN_SIZE_TOKEN)) {
const shown = match[0];
if (shown !== "0px" && !reported.has(shown)) {
reported.add(shown);
report(decl, "hardcodedSize", { prop, value: shown });
}
}
}
// Sub-rule 1: colors.
// Remaining value must be only allowed keywords
// (currentColor/transparent/inherit/none/0); otherwise a color
// literal is reported.
if (COLOR_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const withoutKeywords = withoutVars
.replace(
/\b(?:currentcolor|transparent|inherit|none|initial|unset|revert)\b/gi,
"",
)
.trim();
if (withoutKeywords !== "" && withoutKeywords !== "0") {
const reported = new Set();
for (const match of value.matchAll(COLOR_LITERAL)) {
const shown = match[0].trim();
if (!reported.has(shown)) {
reported.add(shown);
report(decl, "hardcodedColor", { prop, value: shown });
}
}
}
}
});
},
};
},
};
@@ -1,82 +0,0 @@
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
// A custom property may be DEFINED inside a component's <style> block, but its
// value must not introduce a design primitive: a raw color literal (hex/rgb/
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
// (colors/sizes) a single source of truth in app.css, while still allowing
// local DERIVED variables built from tokens: var(--...), calc(), unitless
// ratios (--ratio: 1.5) — those are legitimate component-local state.
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban defining a custom property whose value introduces a raw color or size primitive in a Svelte <style> block; primitives belong in the design CSS file. Derived var()/calc()/unitless values are allowed.",
category: "Design tokens",
recommended: true,
},
messages: {
tokenPrimitive:
"Custom property '{{prop}}' defines a primitive '{{value}}' in a component. Move it to app.css or derive it from tokens via var()/calc().",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// Only inspect custom property DEFINITIONS (--foo: value).
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
if (!prop.startsWith("--")) return;
const value = decl.value ?? "";
if (value.trim() === "") return;
const primitive = stripVars(value);
// A color literal in the definition → primitive.
const colorHit = primitive.match(COLOR_LITERAL);
if (colorHit) {
report(decl, "tokenPrimitive", {
prop,
value: colorHit[0].trim(),
});
return;
}
// An absolute size (px/rem/em) in the definition → primitive.
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
if (sizeHit) {
report(decl, "tokenPrimitive", {
prop,
value: sizeHit[0],
});
}
});
},
};
},
};
@@ -1,91 +0,0 @@
// Rule: NO UNDEFINED CSS-TOKEN USAGE IN COMPONENTS.
// A var(--...) referenced in a Svelte <style> block must exist in the design
// token file (src/app.css) or be a local override defined in the same
// component. Catches typos and drop-in tokens that were never added to the
// "single source of truth".
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// A custom property DEFINITION: "--x:" followed by a colon.
const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
// A var() REFERENCE (primary argument only — fallbacks are optional overrides).
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
const definedCache = new Map();
function getDefinedTokens(cwd) {
const file = resolve(cwd, "src/app.css");
if (!existsSync(file)) return null;
if (definedCache.has(file)) return definedCache.get(file);
const content = readFileSync(file, "utf-8");
const tokens = new Set();
for (const match of content.matchAll(DEFINED_RE)) {
tokens.add(match[0]);
}
definedCache.set(file, tokens);
return tokens;
}
export default {
meta: {
type: "problem",
docs: {
description:
"Disallow referencing a CSS variable that is not defined in app.css (design token file) and not defined locally in the component.",
category: "Design tokens",
recommended: true,
},
messages: {
undefinedToken:
"CSS variable '{{token}}' is not defined in app.css and not locally in this component.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
const globalTokens = getDefinedTokens(context.cwd);
if (!globalTokens) return {};
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// Custom properties defined INSIDE this component count as defined.
const localTokens = new Set();
root.walkDecls((decl) => {
if (decl.prop?.startsWith("--")) localTokens.add(decl.prop);
});
const checkValue = (node, text) => {
if (!text) return;
let match;
VAR_REF_RE.lastIndex = 0;
while ((match = VAR_REF_RE.exec(text)) !== null) {
const token = match[1];
if (globalTokens.has(token) || localTokens.has(token)) continue;
report(node, "undefinedToken", { token });
}
};
root.walkDecls((decl) => checkValue(decl, decl.value ?? ""));
root.walkAtRules((atRule) => checkValue(atRule, atRule.params ?? ""));
},
};
},
};
@@ -1,19 +0,0 @@
// Shared helpers to talk to the postcss AST of Svelte <style> blocks that
// svelte-eslint-parser exposes. All rules use the same plumbing.
// Returns the postcss Root of the <style> block, or null when the file is not
// a Svelte component (or its style failed to parse).
export function getStyleRoot(context) {
const parserServices = context.sourceCode?.parserServices;
if (!parserServices || typeof parserServices.getStyleContext !== "function") {
return null;
}
const styleContext = parserServices.getStyleContext();
return styleContext?.status === "success" ? styleContext.sourceAst : null;
}
// Returns a converter from a postcss node to an ESLint loc, or null if the
// parser doesn't expose it.
export function getStyleNodeLoc(context) {
return context.sourceCode?.parserServices?.styleNodeLoc ?? null;
}
-412
View File
@@ -1,412 +0,0 @@
/**
* Cross-locale dictionary linter (i18n/dict-consistency).
*
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just
* against the base locale). The set of locales comes from `LOCALES` in the
* sibling `dict.ts`, so a new locale is picked up automatically — no rule
* changes needed. The rule only ever warns (a missing translation must not
* break the build) — see docs/plan-preview-i18n.md §Фаза 8.
*
* The diagnostic lives where the fix lives — each file reports its OWN gaps:
* - missing keys: the union of all keys is collected from the on-disk dicts;
* whatever the current file lacks is reported here (a missing whole
* section is reported once, not per descendant key);
* - empty values: `""` or whitespace-only values;
* - placeholders: for a shared key the `{name}` set is compared against the
* canonical set (the one shared by most locales, ties broken by BASE_LOCALE
* then LOCALES order) — catches a variable lost in translation exactly once,
* even when only one locale deviates.
*
* The rule self-filters: files whose basename is not one of LOCALES are
* ignored, so it can be attached to the whole `lib/i18n/` directory.
*
* Options (object, all optional):
* - allowPaths: dot-path keys excluded from every check.
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
* - ignoreMissingPatterns: patterns temporarily excluded from missing-key
* checks.
*/
import fs from "node:fs";
import path from "node:path";
import tseslint from "typescript-eslint";
const PLACEHOLDER = /\{(\w+)\}/g;
/** Parse a TS source string into an ESTree Program (syntax only). */
function parseTs(code, filename) {
const result = tseslint.parser.parseForESLint(code, {
filePath: filename,
ecmaVersion: "latest",
sourceType: "module",
});
return result.ast;
}
/** Unwrap `as const` / `satisfies` wrappers around a value node. */
function unwrap(node) {
while (
node &&
(node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression")
) {
node = node.expression;
}
return node;
}
/**
* The exported dict `export const <name>: Dict = {...}` — the declarator id
* (for reporting) and the object literal, or null.
*/
function findDictExport(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration") continue;
const decl = node.declaration;
if (!decl || decl.type !== "VariableDeclaration") continue;
const d = decl.declarations[0];
if (!d || d.id.type !== "Identifier") continue;
const init = unwrap(d.init);
if (init && init.type === "ObjectExpression") {
return { id: d.id, object: init };
}
}
return null;
}
function findBaseLocale(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration") continue;
const decl = node.declaration;
if (!decl || decl.type !== "VariableDeclaration") continue;
for (const d of decl.declarations) {
if (d.id.type !== "Identifier" || d.id.name !== "BASE_LOCALE") {
continue;
}
const init = unwrap(d.init);
if (isStringLiteral(init)) return init.value;
}
}
return null;
}
/** The `LOCALES` list exported from `dict.ts`, or null. */
function findLocalesList(ast) {
for (const node of ast.body) {
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 !== "LOCALES") continue;
const init = unwrap(d.init);
if (init && init.type === "ArrayExpression") {
return init.elements
.map((el) => (isStringLiteral(el) ? el.value : null))
.filter((v) => typeof v === "string");
}
}
}
return null;
}
/**
* String literal node. The parser emits `Literal` in some ESTree versions and
* `StringLiteral` in others — accept both.
*/
function isStringLiteral(node) {
if (!node) return false;
if (node.type === "StringLiteral") return true;
return node.type === "Literal" && typeof node.value === "string";
}
const OBJECT = "object";
const STRING = "string";
const OPAQUE = "opaque";
/** Leaf kind of a property value (objects are followed, the rest are opaque). */
function entryKind(node) {
if (node.type === "ObjectExpression") return OBJECT;
if (isStringLiteral(node)) return STRING;
if (
node.type === "TemplateLiteral" &&
node.expressions.length === 0 &&
node.quasis.length === 1
) {
return STRING;
}
return OPAQUE;
}
function entryValue(node) {
if (isStringLiteral(node)) return node.value;
if (node.type === "TemplateLiteral") return node.quasis[0].value.cooked;
return null;
}
function propKey(prop) {
if (prop.key.type === "Identifier") return prop.key.name;
if (isStringLiteral(prop.key)) return prop.key.value;
return null;
}
/**
* Flatten an exported dict object into `path -> { node, kind, value }`.
* Computed properties are skipped; intermediate objects are recorded too, so a
* missing whole section is reported once (children of a missing path are
* skipped when reporting).
*/
function buildKeyMap(objectNode) {
const map = new Map();
const stack = [[objectNode, []]];
while (stack.length > 0) {
const [obj, prefix] = stack.pop();
for (const prop of obj.properties) {
if (prop.type !== "Property" || prop.computed) continue;
const key = propKey(prop);
if (key == null) continue;
const dot = [...prefix, key].join(".");
const kind = entryKind(prop.value);
map.set(dot, {
node: prop,
kind,
value: kind === STRING ? entryValue(prop.value) : null,
});
if (kind === OBJECT) stack.push([prop.value, [...prefix, key]]);
}
}
return map;
}
function placeholders(value) {
const set = new Set();
for (const m of String(value).matchAll(PLACEHOLDER)) set.add(m[1]);
return set;
}
function formatSet(set) {
return `{${[...set].sort().join(", ")}}`;
}
function matchesPattern(key, pattern) {
const keyParts = key.split(".");
const patternParts = pattern.split(".");
return (
keyParts.length >= patternParts.length &&
patternParts.every(
(part, index) => part === "*" || part === keyParts[index],
)
);
}
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
function* ancestorPaths(key) {
let i = key.lastIndexOf(".");
while (i !== -1) {
yield key.slice(0, i);
i = key.lastIndexOf(".", i - 1);
}
}
// Sibling dicts are read once per directory and cached for the whole lint run
// (mirrors how no-undefined-in-svelte caches the token dictionary).
const dirCache = new Map();
function loadDir(dir) {
if (!dirCache.has(dir)) dirCache.set(dir, readDir(dir));
return dirCache.get(dir);
}
function readDir(dir) {
const dictPath = path.join(dir, "dict.ts");
if (!fs.existsSync(dictPath)) return null;
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
const locales = findLocalesList(ast);
const baseLocale = findBaseLocale(ast);
if (!locales || locales.length === 0) return null;
const dicts = [];
for (const locale of locales) {
const file = path.join(dir, `${locale}.ts`);
if (!fs.existsSync(file)) {
// Declared in LOCALES but not yet translated — treated as an empty
// dict so every key it lacks reports `missing key ... in <locale>`.
dicts.push({ locale, map: new Map() });
continue;
}
const dictAst = parseTs(fs.readFileSync(file, "utf8"), file);
const dictExport = findDictExport(dictAst);
if (!dictExport) continue;
dicts.push({ locale, map: buildKeyMap(dictExport.object) });
}
// Union of every key across all locales (intermediate paths included), so a
// file can report exactly what it is missing.
const unionPaths = new Set();
// Non-empty string leaves per key: loccales contribute to the canonical
// placeholder set voting (empty values are already broken on their own).
const stringLeaves = new Map();
for (const d of dicts) {
for (const [key, entry] of d.map) {
unionPaths.add(key);
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
stringLeaves.get(key).push({
locale: d.locale,
set: placeholders(entry.value),
});
}
}
}
// Canonical placeholder set per key: the set shared by the most locales;
// on a tie the base locale wins before LOCALES order.
const canonicalSets = new Map();
for (const [key, entries] of stringLeaves) {
const counts = new Map();
for (const { set } of entries) {
const sig = [...set].sort().join("\u0000");
counts.set(sig, (counts.get(sig) || 0) + 1);
}
let bestCount = 0;
let bestLocale = null;
for (const { locale, set } of entries) {
const sig = [...set].sort().join("\u0000");
const count = counts.get(sig);
if (
count > bestCount ||
(count === bestCount &&
locale === baseLocale &&
bestLocale !== baseLocale)
) {
bestCount = count;
bestLocale = locale;
canonicalSets.set(key, set);
}
}
}
return { locales, baseLocale, dicts, unionPaths, canonicalSets };
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Check cross-locale consistency of the i18n dicts in lib/i18n (warn-only).",
category: "Best Practices",
},
schema: [
{
type: "object",
properties: {
allowPaths: {
type: "array",
items: { type: "string" },
},
baseLocaleFallback: {
type: "array",
items: { type: "string" },
},
ignoreMissingPatterns: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
],
messages: {
missingKey: 'missing key "{{key}}" in {{locale}}',
emptyValue: 'empty value "{{key}}" in {{locale}}',
placeholderMismatch:
'placeholder mismatch for "{{key}}" in {{locale}}: {{active}} vs expected {{expected}}',
},
},
create(context) {
const options = context.options[0] || {};
const allowPaths = new Set(options.allowPaths || []);
const baseLocaleFallback = options.baseLocaleFallback || [];
const ignoreMissingPatterns = options.ignoreMissingPatterns || [];
const filename = path.resolve(
context.filename || context.physicalFilename || "",
);
const locale = path.basename(filename, path.extname(filename));
const dirInfo = loadDir(path.dirname(filename));
if (!dirInfo || !dirInfo.locales.includes(locale)) return {};
const dictExport = findDictExport(context.sourceCode.ast);
if (!dictExport) return {};
const own = buildKeyMap(dictExport.object);
const isMissingAllowed = (key) => {
if (allowPaths.has(key)) return true;
if (
ignoreMissingPatterns.some((pattern) => matchesPattern(key, pattern))
) {
return true;
}
return (
locale === dirInfo.baseLocale &&
baseLocaleFallback.some((pattern) => matchesPattern(key, pattern))
);
};
const missing = [...dirInfo.unionPaths].filter((key) => !own.has(key));
const requiredMissing = missing.filter((key) => {
if (isMissingAllowed(key)) return false;
const descendants = missing.filter((candidate) =>
candidate.startsWith(`${key}.`),
);
return (
descendants.length === 0 ||
descendants.some((candidate) => !isMissingAllowed(candidate))
);
});
const requiredSet = new Set(requiredMissing);
const topMissing = requiredMissing.filter(
(key) =>
![...ancestorPaths(key)].some((ancestor) => requiredSet.has(ancestor)),
);
for (const key of topMissing) {
context.report({
node: dictExport.id,
messageId: "missingKey",
data: { key, locale },
});
}
// Empty values and shared-key placeholder parity.
for (const [key, entry] of own) {
if (allowPaths.has(key) || entry.kind !== STRING) continue;
if ((entry.value ?? "").trim() === "") {
context.report({
node: entry.node,
messageId: "emptyValue",
data: { key, locale },
});
continue;
}
const canonical = dirInfo.canonicalSets.get(key);
if (!canonical) continue;
const active = placeholders(entry.value);
if (
active.size === canonical.size &&
[...active].every((p) => canonical.has(p))
) {
continue;
}
context.report({
node: entry.node,
messageId: "placeholderMismatch",
data: {
key,
locale,
active: formatSet(active),
expected: formatSet(canonical),
},
});
}
return {};
},
};
-22
View File
@@ -1,22 +0,0 @@
/**
* Local ESLint plugin "i18n".
*
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in
* `lib/i18n/` is compared against all the others — key parity, empty values,
* `{placeholder}` parity — warn-only by design (a missing translation must not
* break the build). The set of locales comes from `LOCALES` in `dict.ts`, so
* new locales are picked up automatically.
*/
import dictConsistency from "./dict-consistency.js";
import noHardcodedUserText from "./no-hardcoded-user-text.js";
export default {
meta: {
name: "i18n",
version: "0.1.0",
},
rules: {
"dict-consistency": dictConsistency,
"no-hardcoded-user-text": noHardcodedUserText,
},
};
-19
View File
@@ -1,19 +0,0 @@
// Lists shared by the i18n rules.
// Attributes whose string value is shown to a user, so a literal there is
// hardcoded copy. Technical attributes (variant, tone, type, icon, size, role,
// href, id, class, name) are deliberately absent.
export const USER_TEXT_ATTRS = new Set([
"alt",
"aria-label",
"description",
"emptyText",
"error",
"eyebrow",
"hint",
"label",
"placeholder",
"suffix",
"title",
"unit",
]);
@@ -1,180 +0,0 @@
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
//
// Every user-visible string must come from the dictionaries via t(); a literal in
// the template silently ships English to the RU locale. Warn-only by design
// (report-only pilot), so the baseline stays green while the rule is calibrated.
//
// What is NOT reported (the pilot's exclusion list, from the plan):
// - brand and version strings ("easy-png-tools", "v0.1.0");
// - formats, file names and units ("PNG", "result.png", "512×512", "px", "%");
// - X/Y axis markers and other one-letter technical markers;
// - registry source strings (ALL CAPS identifiers such as "BACKGROUND");
// - /kit, which is a component gallery with deliberately hardcoded labels.
//
// Detection surface: SvelteText nodes (including the text parts of mixed content)
// and string-literal values of user-facing attributes. Values written as
// expressions ({...}) are not inspected — the value is computed elsewhere.
import { USER_TEXT_ATTRS } from "./lists.js";
// Formats, units and axis markers that look like words but are not copy.
const TECH_WORDS = new Set([
"png",
"webp",
"jpg",
"jpeg",
"bmp",
"gif",
"svg",
"zip",
"hct",
"oklch",
"rgb",
"rgba",
"px",
"em",
"rem",
"ms",
"s",
"x",
"y",
"kb",
"mb",
"gb",
"dpi",
"bit",
"bits",
"i18n",
"l10n",
"url",
"uuid",
"v1",
"v2",
]);
// Tokens made only of digits, separators and technical characters.
const TECHNICAL_ONLY_RE = /^[\d\s.,:;·×x/%()[\]{}+\-–—*=<>!?&#@'"`|~^$]*$/i;
// A file name, an extension, a version, a dimension pair, a size with a unit.
const TECH_TOKEN_RE =
/(\.(png|webp|jpg|jpeg|bmp|gif|svg|zip|json|ts|svelte|css|md)\b)|(\bv?\d+(\.\d+)+\b)|(\d+\s*[×x]\s*\d+)|(\d+(\.\d+)?\s*(px|em|rem|%|px|s\b))/i;
/** Collapse whitespace so mixed content like "Result px" can be judged. */
function normalize(value) {
return value.replace(/\s+/g, " ").trim();
}
/**
* True when the fragment carries a word that a user is meant to read.
*/
export function hasUserWords(raw, allowWords = []) {
const text = normalize(raw);
if (text === "") return false;
if (TECHNICAL_ONLY_RE.test(text)) return false;
// Registry source strings and other shouted identifiers are not copy.
if (/^[A-Z0-9_ -]+$/.test(text) && /[A-Z]/.test(text)) return false;
const allowed = new Set(allowWords.map((word) => word.toLowerCase()));
const words = text.split(/[\s·×]+/).filter(Boolean);
const candidates = [];
for (const word of words) {
// "512", "64", "0.3" and friends carry no letters at all.
if (!/[A-Za-z]/.test(word)) continue;
if (TECH_TOKEN_RE.test(word)) continue;
if (TECH_WORDS.has(word.toLowerCase())) continue;
// Brand and product names are configured per repository, not hardcoded here.
if (allowed.has(word.toLowerCase())) continue;
candidates.push(word);
}
return candidates.length > 0;
}
function reportLiteral(context, node, value, kind) {
context.report({
node,
messageId: "hardcodedText",
data: { value, kind },
});
}
// svelte-eslint-parser exposes an attribute value as a LIST of nodes, so a
// quoted literal is `value[0]`. Anything else ({expr}, spread, no value) means
// the text is computed elsewhere and is out of scope.
function literalValue(attr) {
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
const first = parts.find((part) => part && typeof part === "object");
if (!first || first.type !== "SvelteLiteral") return null;
return typeof first.value === "string" ? first.value : null;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Warn on hardcoded user-visible text in Svelte templates; use t() from the dictionaries.",
category: "i18n",
recommended: false,
},
messages: {
hardcodedText:
"Hardcoded user text ({{kind}}): '{{value}}'. Use t() with a key in both locale dictionaries.",
},
schema: [
{
type: "object",
properties: {
allowWords: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
],
},
create(context) {
const { allowWords = [] } = context.options[0] ?? {};
const program = context.sourceCode?.ast ?? context.parserServices?.ast;
function visitTemplate(nodes) {
for (const node of nodes ?? []) {
// <style> and <script> content is code, not copy. Their raw text
// arrives as a text child, so the elements are skipped wholesale.
if (node.type === "SvelteStyleElement") continue;
if (node.type === "SvelteScriptElement") continue;
if (node.type === "SvelteText") {
if (hasUserWords(node.value, allowWords)) {
reportLiteral(context, node, normalize(node.value), "text");
}
continue;
}
if (node.type === "SvelteElement") {
for (const attr of node.startTag?.attributes ?? []) {
const name = attr.key?.name;
if (typeof name !== "string") continue;
if (!USER_TEXT_ATTRS.has(name)) continue;
const value = literalValue(attr);
if (value === null) continue;
if (hasUserWords(value, allowWords)) {
reportLiteral(
context,
attr,
normalize(value),
`${name} attribute`,
);
}
}
}
visitTemplate(node.children);
}
}
return {
"Program:exit"() {
visitTemplate(program?.body);
},
};
},
};
-25
View File
@@ -1,25 +0,0 @@
/**
* Local ESLint plugin "design-tokens".
*
* Goal: a single source of truth for design. In Svelte components, hardcoded
* colors and sizes are banned; everything must come from CSS variables (tokens
* in app.css). Rules inspect the postcss AST of Svelte <style> blocks
* exposed by svelte-eslint-parser.
*/
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
import noTokenDefinitionInSvelte from "./design-tokens/no-token-definition-in-svelte.js";
import noUndefinedInSvelte from "./design-tokens/no-undefined-in-svelte.js";
export default {
meta: {
name: "design-tokens",
version: "0.2.0",
},
rules: {
"no-hardcoded-in-svelte": noHardcodedInSvelte,
"no-category-mismatch": noCategoryMismatch,
"no-token-definition-in-svelte": noTokenDefinitionInSvelte,
"no-undefined-in-svelte": noUndefinedInSvelte,
},
};

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