Compare commits

..
1 Commits
Author SHA1 Message Date
Ku6epXBOCTuK 299586fa87 feat: add redirect html 2026-10-13 08:05:16 +05:00
363 changed files with 64 additions and 44573 deletions
@@ -1,23 +0,0 @@
name: "Setup Cocogitto"
description: "Installs a specific version of Cocogitto and cleans up the workspace"
inputs:
version:
description: "Cocogitto version to install"
required: true
default: "7.0.0"
runs:
using: "composite"
steps:
- name: Install Cocogitto
shell: bash
run: |
VERSION="${{ inputs.version }}"
URL="https://github.com/cocogitto/cocogitto/releases/download/${VERSION}/cocogitto-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl -LOf "$URL"
tar -xzf "cocogitto-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
sudo mv $(find . -name cog -type f -executable) /usr/local/bin/
git clean -fd
cog --version
-77
View File
@@ -1,77 +0,0 @@
name: Bump version
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: bump
cancel-in-progress: false
jobs:
verify:
uses: ./.github/workflows/verify.yml
bump:
needs: verify
runs-on: ubuntu-latest
outputs:
bumped: ${{ steps.check-bump.outputs.bumped }}
new_version: ${{ steps.get-tag.outputs.version }}
steps:
- name: Generate token
id: generate_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.XBOCT_BOT_ID }}
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Setup Cocogitto
uses: ./.github/actions/setup-cocogitto
- name: Check if bump is needed
id: check-bump
run: |
BUMP_OUTPUT=$(cog bump --auto --dry-run 2>&1)
if [[ "$BUMP_OUTPUT" == *"No conventional commits"* ]]; then
echo "No bump needed."
echo "bumped=false" >> "$GITHUB_OUTPUT"
else
echo "Changes detected, proceeding..."
echo "bumped=true" >> "$GITHUB_OUTPUT"
fi
- name: Bump and push
if: steps.check-bump.outputs.bumped == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
cog bump --auto
git push origin main --tags
- name: Get new version
id: get-tag
if: steps.check-bump.outputs.bumped == 'true'
run: |
VERSION=$(git describe --tags --abbrev=0)
echo "Detected version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
release:
needs: bump
if: needs.bump.outputs.bumped == 'true'
uses: ./.github/workflows/release.yml
with:
version: ${{ needs.bump.outputs.new_version }}
secrets: inherit
-69
View File
@@ -1,69 +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:
uses: ./.github/workflows/verify.yml
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:
PUBLIC_SITE_ORIGIN: "https://easy-image-tools.com"
PUBLIC_GA_ENABLED: "true"
- 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
-52
View File
@@ -1,52 +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:
uses: ./.github/workflows/verify.yml
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
-58
View File
@@ -1,58 +0,0 @@
name: GitHub Release
on:
workflow_call:
inputs:
version:
required: true
type: string
workflow_dispatch:
inputs:
version:
description: "Версия для релиза (например, v0.2.0)"
required: true
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Generate token
id: generate_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.XBOCT_BOT_ID }}
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Setup Cocogitto
uses: ./.github/actions/setup-cocogitto
- name: Sync and reset
run: |
git fetch origin main --tags --force
git reset --hard origin/main
- name: Get changelog
run: |
cog changelog "${{ inputs.version }}" > GITHUB_CHANGELOG.md
if [ ! -s GITHUB_CHANGELOG.md ]; then
echo "Release ${{ inputs.version }}" > GITHUB_CHANGELOG.md
echo "::warning title=Changelog::Changelog was empty, used default text."
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.version }}
name: Release ${{ inputs.version }}
body_path: GITHUB_CHANGELOG.md
token: ${{ steps.generate_token.outputs.token }}
-39
View File
@@ -1,39 +0,0 @@
name: Verify
on:
workflow_call:
permissions:
contents: read
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
-6
View File
@@ -1,6 +0,0 @@
node_modules
# Отчёты разовых аудитов и артефакты прогонов
web/audit/cdp-audit*.txt
css-lint.txt
web/coverage
-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"
}
-108
View File
@@ -1,108 +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` |
| Направления развития, фазы 1–11 | `docs/roadmap.md` |
| SEO/GEO-план (адреса, `noindex`, sitemap) | `docs/plan-seo.md` |
| API/PWA/шрифты | `docs/plan-platform.md` |
| Сырые UX-находки по инструментам (вход аудита) | `docs/tools-audit.md` |
| Архивирование и удаление старых документов | `docs/archive/README.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`.
-351
View File
@@ -1,351 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - -
## [v0.9.0](https://github.com/Ku6epXBOCTuK/easy-image-tools/compare/b7ba1f501fc704b699dec67d46e69d6892432a38..v0.9.0) - 2026-10-12
#### Features
- update brand name, add domain - ([b7ba1f5](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/b7ba1f501fc704b699dec67d46e69d6892432a38)) - Ku6epXBOCTuK
- - -
## [v0.8.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f67ddb3b0a7c69316caa3bed30d976f41d7a1219..v0.8.0) - 2026-10-11
#### Features
- add tooltip for errors\warnings - ([be9ecd0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/be9ecd0e8edfbe83a6c148cad5c61cce22687ccb)) - Ku6epXBOCTuK
- add download size limit warning - ([f67ddb3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f67ddb3b0a7c69316caa3bed30d976f41d7a1219)) - Ku6epXBOCTuK
#### Bug Fixes
- move to popover realisation - ([6166960](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6166960c4b3a893e9a5be281a688a846b8a6e09f)) - Ku6epXBOCTuK
- update error\warning flow - ([3c9175f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c9175f8a39fa156009733a775efefb85ee0208d)) - Ku6epXBOCTuK
- - -
## [v0.7.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b..v0.7.0) - 2026-10-10
#### Features
- update dnd tools in pipeline - ([7bbcec5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b)) - Ku6epXBOCTuK
#### Bug Fixes
- update drag-n-drop tools - overlay, page scrim - ([dd9cb6d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dd9cb6d574181ffa7520adeff5faef5cbbd494ac)) - Ku6epXBOCTuK
- update dnd tools in pipeline - ([e866975](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e866975045aebda6353808d419189f6aae986687)) - Ku6epXBOCTuK
#### Performance Improvements
- add cache for images - ([5f4c383](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5f4c3833a199044c875b5b8e3dd8aa52b472f8a5)) - Ku6epXBOCTuK
#### Refactoring
- separate logic from schema tool view component - ([b7613e8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b7613e8c0ff82469de2585b1572fbae8f5a796fa)) - Ku6epXBOCTuK
- - -
## [v0.6.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/bac82f8442a8262e757d18f0cc2b5e7fb8291516..v0.6.0) - 2026-10-10
#### Features
- flow for multiple files - pipeline, tools - ([bac82f8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bac82f8442a8262e757d18f0cc2b5e7fb8291516)) - Ku6epXBOCTuK
#### Bug Fixes
- update field spec types - ([8b2172c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8b2172c39aa9cd41118b18d0a7028b4f2752cb8a)) - Ku6epXBOCTuK
- use toggle component with single pattern - ([4eb657d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4eb657dd505aacf2f453b43ab082a599090a6c45)) - Ku6epXBOCTuK
- mask result sanitize params - ([421f078](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/421f078f9051329ca9277425b60ee5b6adfa68b3)) - Ku6epXBOCTuK
- touched by step flag issue - ([d58f6c9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d58f6c90bee245c672f61dbd46e37a8d1df6fca7)) - Ku6epXBOCTuK
- run gen race condition - ([6dcbb7c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6dcbb7c0f8d8876f2ec479dcbce4dff7f9ab104a)) - Ku6epXBOCTuK
#### Refactoring
- better typing for tool icons - ([2ddb348](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2ddb348e54fe681594abe4abe44d3e435277d5c0)) - Ku6epXBOCTuK
- remove double logic, change names - ([6288b03](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6288b03ac0a1c8f63476a4682e43ba96e783955e)) - Ku6epXBOCTuK
- parse hex color - ([637f656](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/637f65622911034c59a1c62b0f5436675765b475)) - Ku6epXBOCTuK
- move fit to size function - ([0b929ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b929eced2980f7509263873f2ceb79cd87dbf52)) - Ku6epXBOCTuK
- separate output formats from io core - ([dfb1f53](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dfb1f531fa121f0750f5b093cec6c63b439df572)) - Ku6epXBOCTuK
- group props in view models - ([e51d6f3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e51d6f32396003c614ba5c8f97943cd37a0883b4)) - Ku6epXBOCTuK
- separate registry schema file - ([731f4ba](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/731f4ba90e3aad3f3a48c9e98abde0b357b3fdd1)) - Ku6epXBOCTuK
- separate schema tool state - ([0b3eeab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b3eeab959de1cf12542b9686b91e9e8c97eb888)) - Ku6epXBOCTuK
- separate schema tool runner - ([551da33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/551da338f38a96b2519e783d1f5d38e17d8d8c9e)) - Ku6epXBOCTuK
- separate SchemaAlignedLayout component - ([52cb9c8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/52cb9c8a1148f50825f9761f5bfbf8ba0776fca2)) - Ku6epXBOCTuK
- clamp\storage dry - ([07c6475](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/07c6475c214868ae035461223fc32c516194aa90)) - Ku6epXBOCTuK
- use derived by instead of effect - ([9916955](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9916955b967a858aff5554c40f65cf9197dda480)) - Ku6epXBOCTuK
- - -
## [v0.5.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/76039f7bf3247ef9711113608f5ec2d01a7fa56f..v0.5.0) - 2026-10-09
#### Features
- add show mask chip for tools - ([c22c6e5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c22c6e55741f99a6c8a79a82b7b3deadd33fa3dc)) - Ku6epXBOCTuK
- add chains page, update pipeline usage - ([a51e224](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a51e224d02ccd45ca0adc69135112b8a0a3839c7)) - Ku6epXBOCTuK
- add limit size to download flow - ([8dcc263](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8dcc263461111937d1b116cbfec41329c314b7b9)) - Ku6epXBOCTuK
#### Bug Fixes
- image decode error i18n - ([76039f7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/76039f7bf3247ef9711113608f5ec2d01a7fa56f)) - Ku6epXBOCTuK
- - -
## [v0.4.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/83e75b5589ee200b6e216bc0187404b8be086149..v0.4.0) - 2026-10-07
#### Features
- add og description\image\etc - ([31ace35](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/31ace359d7ff187cbe9ba968facc498e75e66b3d)) - Ku6epXBOCTuK
- add socials icons - ([fbe6139](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fbe6139585aeaca2b70b55224115c9e2b7653ca6)) - Ku6epXBOCTuK
- add favorites tools - ([2f11cb8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f11cb86ade08b91f48c823f3354f62260bae262)) - Ku6epXBOCTuK
- add split to parts by tile size - ([32cb369](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/32cb369ca96a96fc0194a63d8086db610f3f190a)) - Ku6epXBOCTuK
- aligned mode for pipeline - ([b7be973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b7be973b54091b071e0c751533a73ad1d0ea11fb)) - Ku6epXBOCTuK
- change tool inplace - ([1ba03b7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ba03b731d80212eedcf9d8985478dc78e2e32f4)) - Ku6epXBOCTuK
- collapse\expand steps in pipeline - ([5c58451](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c58451f2f5a0dde167d7b2bebcc8f3190f20212)) - Ku6epXBOCTuK
#### Bug Fixes
- select mode via segmented button in split by parts - ([676be38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/676be383d9fb71b7dc4e83d8c243dc39c60f6c36)) - Ku6epXBOCTuK
- add text to no param tools - ([e7d3925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e7d3925eb2063b0265a396bf693b4ed95b49782b)) - Ku6epXBOCTuK
- - -
## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06
#### Features
- add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK
- add transparency range slider to opacity fields - ([ba533e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba533e67fdba5e6b40f21c6b3ca375cf16426fef)) - Ku6epXBOCTuK
- upload image on click, tooltip - ([a0bc72e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a0bc72ef5d06b65507d6fe2ee3150872dbc146ae)) - Ku6epXBOCTuK
- split to parts show sizes of parts - ([684423d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/684423de679fc1b65195bd4fb1ce43290577722e)) - Ku6epXBOCTuK
#### Bug Fixes
- max height for source preview - ([0753c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0753c95583e83adb9fa25779bb5263a5cfe93ba2)) - Ku6epXBOCTuK
- scroll in result for split parts - ([7763fb7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7763fb7faa790c2e41f85362d375f576f28a4de1)) - Ku6epXBOCTuK
- range slider drag after regression - ([f00dae8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f00dae8b0158b9ff779511e1dd46cda8f5b6cb14)) - Ku6epXBOCTuK
- update split into parts max params - ([2a282b4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2a282b48f8d6be2bd3a4079f3a1a77031072b970)) - Ku6epXBOCTuK
- - -
## [v0.2.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f40a7c89ae5615f2c16f1154116d35fdc0e53125..v0.2.0) - 2026-10-04
#### Features
- add pipeline flow - ([6b946bf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b946bfb08729ead2515e87406cb3efd22ca9859)) - Ku6epXBOCTuK
- add select format to download button - ([e87bc1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e87bc1d7159e91acbf297351710c367eae543011)) - Ku6epXBOCTuK
- upload source by ctrl+v and dropzone - ([864f9cd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/864f9cdc9f355292d688a46f1d0383a893d252d3)) - Ku6epXBOCTuK
- add size presets for tools - ([91150a1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91150a14bf8203627132f6d3a409109845ed749f)) - Ku6epXBOCTuK
- touched fields dont reset on change source image - ([5743b9f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5743b9fb3f1eb7f3b4f9b01e35c9a0f897ee7411)) - Ku6epXBOCTuK
#### Bug Fixes
- keep aspect ratio flow - ([4d9cd2c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4d9cd2cfcbe6491dfe2ee6074e8f8c1d8abeee74)) - Ku6epXBOCTuK
- update range slider field component - ([e73986f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e73986fefd7306391711a39f719e48f60f5d3292)) - Ku6epXBOCTuK
- add title from i18n - ([62bdd5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/62bdd5a7c582c74118e964058f84108eaf0cf4cc)) - Ku6epXBOCTuK
#### Refactoring
- reuse badge component - ([409c98c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/409c98ca295be7b4541148c9568ed9b89524e37a)) - Ku6epXBOCTuK
- - -
## [v0.1.1](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/1db83d12ff717bc2e282fe21d35d282f261147f5..v0.1.1) - 2026-10-04
#### Bug Fixes
- google analytics only on production - ([e1fc11c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1fc11cb5491a6f5be5c823140830672ea669a62)) - Ku6epXBOCTuK
- shapes offset calculate - ([1db83d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1db83d12ff717bc2e282fe21d35d282f261147f5)) - Ku6epXBOCTuK
- - -
## [v0.1.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/fdf7e2a3cd68d6693f1c5581796fafaf8939dad4..v0.1.0) - 2026-10-04
#### Features
- change id - remove png intents - ([7b7e12f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b7e12fafcbd7444be61426cd0b90104de9de40f)) - Ku6epXBOCTuK
- move routes to slugs - ([d09c954](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d09c95469494ec5f243f1981f6bc08b6e9fa6eb7)) - Ku6epXBOCTuK
- separate i18n dict pages - ([9776e1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9776e1d3251761347440ed43d57fef851b2af150)) - Ku6epXBOCTuK
- separate slug pages from tools - ([7a3498c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7a3498c173daa63f0a9ec88f09e10798525be9bd)) - Ku6epXBOCTuK
- add source aware resolver, update reset, update generator - ([5f77c09](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5f77c097e2d58d1826a10f05217af973c4de7e81)) - Ku6epXBOCTuK
- remove v1 routes and libs - ([16d4288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/16d42889120566e45008fa94285fd518b37f967f)) - Ku6epXBOCTuK
- add new dics\translations - ([9a27388](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9a27388119bb1b14bd759a830a9444be6c498e1b)) - Ku6epXBOCTuK
- add icon for split tool, update docs - ([b91c1ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b91c1ab7ee5bad72a90d2c66543a2e04e8f8c70e)) - Ku6epXBOCTuK
- add ui for new split tool - ([2be8871](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2be887193084b0fc87f16c110264bad58dc7279c)) - Ku6epXBOCTuK
- add executor support for multifiles result - ([adb5785](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adb578540f95ccafd3d0e8c419760da1cb031dcb)) - Ku6epXBOCTuK
- add split to parts to geometry registry - ([a7a42d0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a7a42d0091af3cc8e61c16962c4000d5c0bd8bdd)) - Ku6epXBOCTuK
- add error texts - ([e1c3d0a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1c3d0a95ffd1b1a1245a88bf8db247c28fc0b64)) - Ku6epXBOCTuK
- add download zip with result files - ([ceee9ad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ceee9adead94c437785ede7053e4563a7cbf5054)) - Ku6epXBOCTuK
- add split to parts core tool - ([d94c5d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d94c5d1297bfbc35b08feab28b565eab152217f4)) - Ku6epXBOCTuK
- add files tool result - ([ee5f660](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ee5f660572676edf829faca3a238796468ab2c12)) - Ku6epXBOCTuK
- rename old -> v1 folder - ([33e3f3c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/33e3f3c4f035c0daeae8de687cd464b1572242e5)) - Ku6epXBOCTuK
- flatten lib/preview -> lib folder - ([1e5e096](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e5e096d5d61b5ca69aaf196928868ad67f2f4f5)) - Ku6epXBOCTuK
- flatten components/kit -> components folder - ([f8a1da6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f8a1da6ff79be1a22e0046db7c3701110479011a)) - Ku6epXBOCTuK
- move preview pages to root - ([45dc54b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/45dc54b6aa136f753de16856a2378fec5ecbd10f)) - Ku6epXBOCTuK
- move old root routes to /v1 routes - ([b6318f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6318f03ab60cea602606d88dd94ed3d0601dd0f)) - Ku6epXBOCTuK
- update filter tools to new types - ([d6706ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d6706abea22cd9fa587f377338200032cce0f38a)) - Ku6epXBOCTuK
- update alpha and colors tools to new types - ([bea010d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bea010dd907e1c5ffe0b3b2f43c5db6efac9d348)) - Ku6epXBOCTuK
- update geometry tools to new types - ([2937974](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2937974fe732690ca58e4d693e5c1a69ef4bfc90)) - Ku6epXBOCTuK
- update text tools to new types - ([1e85fe3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e85fe36a636a7ab4e4daf48c2877ba9e91eeccb)) - Ku6epXBOCTuK
- update convert and analyze tools to new types - ([60c7d61](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/60c7d61daba60f656522e3ab45ffb87149910e69)) - Ku6epXBOCTuK
- update generate tool to new types - ([6b0b064](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b0b0646d51d3b3f74e4d132df2bfd44be411530)) - Ku6epXBOCTuK
- update schema components - ([b6db1c8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6db1c8f19ceb15c6a5e81f4108c9a25dc180525)) - Ku6epXBOCTuK
- update executor for tools - ([ba8a518](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba8a51886129d3c128d21ee625a8c3a0d16ae5b0)) - Ku6epXBOCTuK
- update tools types - ([292e53c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/292e53c9c76d4e02efa4990b5d62868a197d39c7)) - Ku6epXBOCTuK
- add text tools - ([e5b76ca](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e5b76ca3e11eb0313988ea1d49578adb6f24bf1e)) - Ku6epXBOCTuK
- add text core tools and views - ([a497857](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a49785764b39af1707be5990b5adcb43eb7356c9)) - Ku6epXBOCTuK
- add output format for download - ([c0cebdc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0cebdc81f229fc2fab57b40c48b2fb6dd84f598)) - Ku6epXBOCTuK
- add generate tools - ([b3d460a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b3d460a951f69b505b153ae2e1af5596f8321049)) - Ku6epXBOCTuK
- add multiple colors field - ([6be9d10](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6be9d10c42580a984b78e95fb0e317b2d4d6993a)) - Ku6epXBOCTuK
- add analyze, generate, text tools and update plan - ([cc70750](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cc70750727aaf5d5c758aa64fa577262a5197b9b)) - Ku6epXBOCTuK
- add alpha tools - ([e2735c5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e2735c52abf1e3968b24e334bb3d52c4e7f4dcf3)) - Ku6epXBOCTuK
- add geometry tools - ([d383cf2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d383cf29e30ad3722b754eb07cad10251af784d3)) - Ku6epXBOCTuK
- add filters tools - ([bfad0c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bfad0c7d020fe8e19a5919ed42011710b955a1df)) - Ku6epXBOCTuK
- add colors tools - ([6750d5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6750d5a373c9f10b44fa75c7c407f12e2d8d6680)) - Ku6epXBOCTuK
- add layout groupt to tools - ([e662d1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e662d1df248469f4e554b39a4e42d25a3296192d)) - Ku6epXBOCTuK
- add tool schema layout - ([d48262e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d48262e48269785c7edd414868fd952ff6d85f5f)) - Ku6epXBOCTuK
- add gradient control component - ([cad9bf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cad9bf6cfd48aef22d27610d735a3e76bd4baaab)) - Ku6epXBOCTuK
- add plate (under text) field component - ([b99ccf5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b99ccf5d7808bdd3bca7349b10e1b3858c7e56c3)) - Ku6epXBOCTuK
- add font style field component - ([0a7c4a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0a7c4a84c9009ab907579dc1750d8bd203d34f67)) - Ku6epXBOCTuK
- add position9 field component - ([f24ccf9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f24ccf924031c7384b22c83087401fbdfbfc4125)) - Ku6epXBOCTuK
- add offset field component - ([0c28925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c28925673facc40500c3c5e8e63d94db617f94b)) - Ku6epXBOCTuK
- add two colors field control - ([19f2e13](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/19f2e13b8874b60bb0d34d2d0ce8a575bfd9fe5c)) - Ku6epXBOCTuK
- update use dimension tools - ([f286044](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f286044ab92773af36b712d847b4eccc1f31d81b)) - Ku6epXBOCTuK
- add dimension tools for new flow - ([2601aa5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2601aa5b5c9e6d5c20a4df6d1eab2a0a68126f60)) - Ku6epXBOCTuK
- add registry tools for new preview flow - ([1e9b354](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e9b35406d8e3fd4071ba72d1dc6a043a0bf318f)) - Ku6epXBOCTuK
- double executor to use with preview - ([520a1e1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/520a1e1d37f4b63692ca906c06bdf694e297f0ca)) - Ku6epXBOCTuK
- add new registry for preview design - ([a19d3db](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a19d3db929f5d840900f1190c9efd10bb0595548)) - Ku6epXBOCTuK
- add single tool schema rendering - ([1c74629](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1c746299c9780a237cff2197c8b6b46310bc49d1)) - Ku6epXBOCTuK
- add new registry schema for new ui - ([1ae4b36](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ae4b36c9c2b0268a39486c935220cad9290cfb7)) - Ku6epXBOCTuK
- css audit fixes - ([dbeffac](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dbeffac082f56990fa1d08089b00e79da28b276c)) - Ku6epXBOCTuK
- WIP: add css fixes - ([2ef4709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2ef47090387fc47bd6c93365c7471532402e514d)) - Ku6epXBOCTuK
- add refs css audit - ([1831c3d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1831c3d10051510842fbf1a591c95d39cabf3ce4)) - Ku6epXBOCTuK
- fix tools pages, dom audit fix - ([9ef6ae0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ef6ae03c69a8b343bc8f05dac832876b9ea184e)) - Ku6epXBOCTuK
- add preview catalog and catalog group and header - ([b695f5d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b695f5d58c2e78f7d076d175ae819811b874cfe1)) - Ku6epXBOCTuK
- fix list tools page to correspond refs - ([7578a18](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7578a185b64e48c970e27c18c580b4055ba5c23b)) - Ku6epXBOCTuK
- fix demo page to correspond refs - ([d29ec73](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d29ec73322d6caf3d7ebff3305b2cf20cb0f0fbb)) - Ku6epXBOCTuK
- add new simple components - ([c0f48ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0f48ecd9762f0c507933409525f5e22bf759a76)) - Ku6epXBOCTuK
- some refactor, fix some design - ([65f12c9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/65f12c931baec26732a6fc3f39284bd423a3af0b)) - Ku6epXBOCTuK
- add link to preview design - ([40eee98](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/40eee984daeb2d82af375ff322c66a6c0e7ca869)) - Ku6epXBOCTuK
- update tool page - add step card steps - ([d373cc0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d373cc0daf8672c1febb3c3a844cb62714ff1dc7)) - Ku6epXBOCTuK
- add dropzone, update tool page - ([a16a7b9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a16a7b92c737273b27fd2611a77c65b746d63231)) - Ku6epXBOCTuK
- add single tool pages, wiring with toolcard - ([f990ad6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f990ad67671662637acc6b890145a2d2b0109bf0)) - Ku6epXBOCTuK
- separate topbar, fix important styles - ([f4216e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f4216e6868f975e511395c729359109a9a001a5c)) - Ku6epXBOCTuK
- add demo page - preselected pipeline - ([adcc255](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adcc2551073d0a57517b655239100d807e643fe7)) - Ku6epXBOCTuK
- add list tools page - ([73118ea](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/73118ea393c60d69108be3a08d72608c937465b5)) - Ku6epXBOCTuK
- add main page - ([e9578fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9578fc15e179b9ae5df3c0f9e1690f95b113a26)) - Ku6epXBOCTuK
- add ui kit page - ([06d185a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/06d185add66864662b0a173183f69c22078dc098)) - Ku6epXBOCTuK
- add ui components - ([8437c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8437c95a28343d6b24971d41776b8fd65e831810)) - Ku6epXBOCTuK
- add ui components - ([ac75665](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ac75665deb78d9b9459e4c34f4a9f9719a027c63)) - Ku6epXBOCTuK
- add new design primitives - ([0acfa3a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0acfa3aeb923fe7cbe95846055d2e199a62257e4)) - Ku6epXBOCTuK
- add new design2.css, add fonts - ([418a091](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/418a091505d80e4b2798a80740906d2d215c2488)) - Ku6epXBOCTuK
- add compression tools - ([83d3ce1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/83d3ce11986c908ea64c54c23bbfba0395cc838d)) - Ku6epXBOCTuK
- add quantize tools - ([501d2dd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/501d2dda4e186b2f7ece3f1be41b552393ca18a3)) - Ku6epXBOCTuK
- add text and generation tools - ([2b67414](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2b67414e79ef646b78bf6372d35e2262099d65a0)) - Ku6epXBOCTuK
- add pixel effects core tools - ([56ebe5b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/56ebe5b53be2bd797911faf285b5fe7b414358a1)) - Ku6epXBOCTuK
- add geometry masks - ([84cc56c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/84cc56cc6cf6627382d4f76f464c787a90181308)) - Ku6epXBOCTuK
- add shape masks - ([d65adcd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d65adcd16cb9bbd888b356784b46489e611f6ace)) - Ku6epXBOCTuK
- add masks core tools - ([553601a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/553601acc1ec4bb5a5fd74b50e60afb09070dd5d)) - Ku6epXBOCTuK
- add channel tools - ([5c37eb2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c37eb2e7b6bc41a219550e8be237697266ce5a1)) - Ku6epXBOCTuK
- add pallette tools - ([db02cbd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/db02cbd16558a367d328184b96f570ec692e5d67)) - Ku6epXBOCTuK
- add watermarks and text tools - ([116d7c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/116d7c72aac7ba2463f249c962251ee9d4a56744)) - Ku6epXBOCTuK
- add text and date draw tools - ([54af762](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/54af7626d6615c379e841e38e528fc9572d11196)) - Ku6epXBOCTuK
- add text draw core - ([38e7cd2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/38e7cd20d28e7fc26d3cbfb3ec960a2b136d1202)) - Ku6epXBOCTuK
- add demo page - ([6e0647d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6e0647dfbf9eb68d980ae8d76d6affdc9f1965d6)) - Ku6epXBOCTuK
- inline tool page - parameters beetween source and result images - ([148e4b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/148e4b195739774c5e892dbfd16f5a83617b93dc)) - Ku6epXBOCTuK
- add theme switcher, dark theme - ([f1f5bd4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f1f5bd4409797cb6c28fdee55e3dbd73ac6ce5f0)) - Ku6epXBOCTuK
- add base path for deploy - ([6d770dc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6d770dc642cd6110bb4ab3d7af9c9364bb29d0bb)) - Ku6epXBOCTuK
- search use both languages and remove diacrites - ([c04a1fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c04a1fc1dc4d266f47164e735668a53e14c2d61b)) - Ku6epXBOCTuK
- use i18n in components - ([3960368](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/396036802c1ee1e8fe52877826b22b9a3a427bee)) - Ku6epXBOCTuK
- base english language, also fallback to english - ([74a18d9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/74a18d91a77f3a049683f73122279eb9392a61df)) - Ku6epXBOCTuK
- move all common messages to i18n (ru\en) - ([2301b0e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2301b0e1654874744d2ddf72b878997aa461620b)) - Ku6epXBOCTuK
- add i18n - dict, en\ru, state - ([8255d33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8255d330988f9f6f98c51407e6e98613f7306150)) - Ku6epXBOCTuK
- add tool registry, icons - ([8745812](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8745812a5f077f715b003ca4022d20a72eb783ba)) - Ku6epXBOCTuK
- add io extensions - svg, jpeg - ([0909e14](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0909e14cde8ba6ab7249e54bc84bdc4a095cbf8d)) - Ku6epXBOCTuK
- add core tools - transfor, light, effects - ([7ef4b90](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7ef4b908e2f314e776ffd10f904085ac2eb4f3ea)) - Ku6epXBOCTuK
- add tools into registry, add icons - ([a2dd288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a2dd288851572b9634664f647e5c76a7f8184869)) - Ku6epXBOCTuK
- add alpha and morphology core tools - ([cb5ccf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cb5ccf6da8d3f21590e481a617507a9f899090e8)) - Ku6epXBOCTuK
- add tool search cards - ([5e44a45](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e44a454571e99a33d019f5eacfaed840a00c8c5)) - Ku6epXBOCTuK
- add tool popularity and icons - ([76f1770](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/76f1770286258097000ee18a9d11adc6b55ae08c)) - Ku6epXBOCTuK
- combine tool in single card - ([702b962](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/702b96261d0266fd904fa740f5a4bdab12ffa24e)) - Ku6epXBOCTuK
- move tool executor to web worker - ([9fbb90b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9fbb90b6418641154c40017ca03d7fd3bcec1452)) - Ku6epXBOCTuK
- add registry - background, blur, sharpen - ([c143011](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c14301130f23e9a4787f13d26b0bbf1a14bd084e)) - Ku6epXBOCTuK
- add background and convolution core tools - ([fd5236c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fd5236c19b5a6daba78d8780d3c217e92d33f74f)) - Ku6epXBOCTuK
- restore old toolchain button - ([91b3492](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91b3492231974d3b67b694a313c1f91dcf5b545d)) - Ku6epXBOCTuK
- add chain tools workflow - ([306c26a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/306c26a3602d64a6a24ed938f20c04882f4637c4)) - Ku6epXBOCTuK
- add tool search component, add chainable flag - ([c93a8e4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c93a8e493ef8464d1b1ee96869b6f99ddc0a583a)) - Ku6epXBOCTuK
- add step picker\list\editor - ([8532eb9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8532eb98c3f985ed4ee9ba0c7a51eabe0f86dc3c)) - Ku6epXBOCTuK
- add pipeline for multi instrument flow - ([23c985b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/23c985bde90cdbc24af5f1c137c0153e98658408)) - Ku6epXBOCTuK
- add registry - ([6fae98b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6fae98b25c9db23f27effdd6c73914cad5a9a647)) - Ku6epXBOCTuK
- add core operations - ([f9a4973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f9a4973b471b19f2aeaa8b71bf56bff2fd4de18d)) - Ku6epXBOCTuK
- add core operations - ([7fa2c52](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7fa2c52bb83538898398460de5ed520a26372c02)) - Ku6epXBOCTuK
- add text\hex conver tools - ([17b0c68](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/17b0c68eb52cf8e4e495bd01754ba8e7d3130202)) - Ku6epXBOCTuK
- add color picker glass - ([96c37aa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96c37aabc568896f7a279069d0c65e6ff4f794bb)) - Ku6epXBOCTuK
- add color picker - ([7b79996](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b799969c4bc0229de490921f29a15b84387ee21)) - Ku6epXBOCTuK
- add slider field - ([3701fc8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3701fc80451f94989d412a954715b1ffe9333836)) - Ku6epXBOCTuK
- add show mask workflow - ([aa5372b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/aa5372b771ef0834c2221f045c27cdd4570a1c41)) - Ku6epXBOCTuK
- reactive tool flow, with debounce - ([d0ee4ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d0ee4ec77f7999655e55c79df50b98a68d3fab9a)) - Ku6epXBOCTuK
- update drop file flow - ([caf71ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/caf71ec8f0418ebe43d9e04b007e9c3379864afd)) - Ku6epXBOCTuK
- add ctrl+v flow - ([f576701](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f576701baabbd0172550efe18c422650e0d15ee1)) - Ku6epXBOCTuK
- update - use design system components - ([c7b56bb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c7b56bbda79b67d4e13c5062c55266c7b6de2c5c)) - Ku6epXBOCTuK
- add design system components - ([15e2e01](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/15e2e010ec3227dae0786ebf81afe71ed0c8b011)) - Ku6epXBOCTuK
- add design system tokens - ([3620c5f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3620c5fc309e840eea5ac6927534b50c2957928f)) - Ku6epXBOCTuK
- add route pages - ([3ec1199](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3ec11999d82c118b72416d11fd0aa6b2698359f6)) - Ku6epXBOCTuK
- add svelte components - ([c022555](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0225552e4d5970b81a3dba51e357821da38977b)) - Ku6epXBOCTuK
- add instruments registry - ([719bda2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/719bda2e225331d04da35b7a35fb980b2af63f00)) - Ku6epXBOCTuK
- core instruments - ([9ccc9f9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ccc9f94acb5cc9d6f5ab1c13a7b3641160e3edd)) - Ku6epXBOCTuK
- core types and io - ([4ec2b41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4ec2b418fd001bf0c0d95534cd38988b6a44cd1d)) - Ku6epXBOCTuK
- initial sveltekit - ([85f595f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85f595f14b9cd059723d4ae84265b6a6e1633a67)) - Ku6epXBOCTuK
#### Bug Fixes
- show version in footer from manifest file - ([f89811c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f89811c116fbff42d0323ed811104bdce06dc68d)) - Ku6epXBOCTuK
- update crop tool max sizes - 2000x1200 - ([becf03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/becf03f4165e744e05b4e39d906b72a4804f6094)) - Ku6epXBOCTuK
- use base path for favicon - ([44cfca9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/44cfca92ce2eb2cef5119fb3701719f78d97f579)) - Ku6epXBOCTuK
- worker use only i18n keys instead of full i18n - ([c2f3838](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c2f3838485a78b12246a480cda564feb5a19255e)) - Ku6epXBOCTuK
- update i18n - update registry for tools - ([371387c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/371387ce81569921f5e143daec81275b63a1bab4)) - Ku6epXBOCTuK
- update i18n - update schema fields descriptions - ([08c9fbc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/08c9fbce3b21a7530bba12d8cdaa77f4286e69ae)) - Ku6epXBOCTuK
- update i18n - tool group descriptions - ([4de42b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4de42b1e402a17f43992b132d8d3d97b869fc0d9)) - Ku6epXBOCTuK
- update i18n - text verdicts from analyze tools - ([372babc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/372babc3b741e0fd5e10709b4b6e3e70589b6074)) - Ku6epXBOCTuK
- update i18n - fields components - ([4b44bc4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b44bc439ce2a36baf1c3f68fde8776d31ccb3f1)) - Ku6epXBOCTuK
- update i18n - schema components - ([6c88a23](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c88a23e4882bebd20e9bd7cc45c70f15818d5cb)) - Ku6epXBOCTuK
- update i18n - catalog, nav, common components - ([a4c85c0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a4c85c08140bd802bfb0dc2952ea475d4dca48f5)) - Ku6epXBOCTuK
- update i18n text in pages and layout - ([e069709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e06970997e5a402139d2f2b9c78cab8310cad293)) - Ku6epXBOCTuK
- update lang toggle component - ([1097186](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/10971864872fb305bbcad4e6470b7afac8fa1312)) - Ku6epXBOCTuK
- restart tool after change source - ([5cd2a58](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5cd2a586ce70116730cc9ce11653943194992454)) - Ku6epXBOCTuK
- update design - colors, remove texts - ([f2829de](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f2829de216f48f0d2bc5b35c0a1bf34a51ad10e8)) - Ku6epXBOCTuK
- disable download button while running jobs, auto run generator jobs - ([0c2de3f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c2de3f191cb9cfd10c4186cd16db7db2f8545bf)) - Ku6epXBOCTuK
- separate preview tile component - ([35df076](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/35df07673211f2734c4f52ab4e232580006efc9c)) - Ku6epXBOCTuK
- source image checker background - ([0b107c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b107c7d93a8dd27b8ad40c2c73a70518a45056f)) - Ku6epXBOCTuK
- update topbar links - ([f7623ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f7623ab3bbc3d3998938e918e963219039f97f88)) - Ku6epXBOCTuK
- v1 layout - ([85effce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85effce7d9ae940858fda6ed3f4b8336799ac93c)) - Ku6epXBOCTuK
- css fixes - ([420d03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/420d03f58c3ed26f0dd5658cdc9391f881847ba1)) - Ku6epXBOCTuK
- lint errors - ([6f1771c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6f1771cb07da8b43b3ef79c8ebb4033f429b491c)) - Ku6epXBOCTuK
- update tool card component - route resolve issue - ([987f453](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/987f4535c8609e25ae27db192ba598683604803f)) - Ku6epXBOCTuK
- resolve always text input issue, separate to simple components - ([a1438e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a1438e6f7f227f7489465070c02abdbb495a9dd1)) - Ku6epXBOCTuK
- add compress\decrease colors presets - ([37a4a05](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/37a4a0576c505ee3a87c0886e92faa9521092097)) - Ku6epXBOCTuK
- update tools list - show all tools - ([d1a386f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d1a386f92f2e9e7fa859cef950ca68d5f3729194)) - Ku6epXBOCTuK
- update segmented component styles - ([a24a182](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a24a182d1813f9bc882d3dff2cbf58a0396c799c)) - Ku6epXBOCTuK
- increase font size - ([1e63eaa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e63eaa03bc7d4c6ee21eb88cd0dff4cb54b4922)) - Ku6epXBOCTuK
- update field component - ([3d0f8eb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3d0f8ebb100294b970e9b45a854126ca031df9d7)) - Ku6epXBOCTuK
- update download button style - ([ef74cab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ef74cabf1e8bea77e88e0e29c802e02ee57eaf3d)) - Ku6epXBOCTuK
- update button component styles - ([c590bdf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c590bdf91a896fab0fd8a3e347a67db639be26c7)) - Ku6epXBOCTuK
- update css variables, update badge component - ([4794523](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/47945230846bacc4063bf8fc21620fefe57f3099)) - Ku6epXBOCTuK
- update some styles - ([191ca71](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/191ca71553187b45efb6bc26fb278a1e10b93aeb)) - Ku6epXBOCTuK
- change style, demo page visual looks as done - ([749b97c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/749b97cb4f55b145067bc169da8d4d08848ee4e3)) - Ku6epXBOCTuK
- resolve new audit styles - ([80def1f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/80def1f38043b3fdd081fa8c8b3558c6c6f01ce5)) - Ku6epXBOCTuK
- update font styles - ([0b13d4c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b13d4c9e829e5a0c1d2e8be210ae9ba659bbe39)) - Ku6epXBOCTuK
- update style, resolve old css leaks issue - ([586c66a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/586c66a159a9a4e30ae414070ffa83f06d9209ed)) - Ku6epXBOCTuK
- resolve style issues - ([bbd5532](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bbd5532529257262faa133d4cd5e7dee0f943927)) - Ku6epXBOCTuK
- step fields styles - ([8eff9a9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8eff9a94836fa2a6219888a5599a3fa011699d9b)) - Ku6epXBOCTuK
- update styles - ([2c70c44](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2c70c445d5ec601945b842663cfbc81222fdc91c)) - Ku6epXBOCTuK
- update preview styles - ([6c8f7b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c8f7b2d8b1eaca7f0cee708f0f1e81d0d59e25e)) - Ku6epXBOCTuK
- update top bar styles - ([86b1c81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86b1c813e72bc07f7f07e534b54faa4f6d69e142)) - Ku6epXBOCTuK
- resolve design issues from refs audit - ([0d95acb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0d95acba8933280b32e54a3f13d014aa464a2e71)) - Ku6epXBOCTuK
- remove tile label doubling - ([6959abb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6959abb15926edab7817006a42cfabfa436c04e8)) - Ku6epXBOCTuK
- update design with references - ([5093b50](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5093b50f5cfea72f6298e9dd5a2ae934e741b75a)) - Ku6epXBOCTuK
- update search flow - first tool search - ([3c1c5b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c1c5b2b3ae6fe8bdf576a70acb6b449edaf0197)) - Ku6epXBOCTuK
- some small css fixes - ([5e512f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e512f0bdd34f6329b54f111f81950acbebb2569)) - Ku6epXBOCTuK
- get names from categories - ([96b8c00](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96b8c0012988e4586718a11a77419b7752edeb80)) - Ku6epXBOCTuK
- base en - ([6787201](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6787201425b87256369003b97df299704ed75975)) - Ku6epXBOCTuK
- resolve transform core tools, add tests - ([ebc3c33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ebc3c33c12b58be7413378ff6e44677cf76f1a64)) - Ku6epXBOCTuK
- use toolcard as links at list page - ([200e971](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/200e971df503e6ba6a460bdd24f47cd617dbbc7c)) - Ku6epXBOCTuK
- update layout - ([4c5b52a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4c5b52a9ad13aadd425e9c5571120f356bf631be)) - Ku6epXBOCTuK
- update user input flow (keep aspect ratio) - ([da55328](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/da5532849198d3040a56f71a69fe6af6ae5b988f)) - Ku6epXBOCTuK
- resolve loading image error - ([4b70866](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b7086693cfd06ab88d47f22314913aa9326a470)) - Ku6epXBOCTuK
#### Refactoring
- separate components - ([c4eab38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c4eab388662b927aa5a6527be75350686e903865)) - Ku6epXBOCTuK
- add slug to tools - ([931ee27](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/931ee27b056a641b0adec58d0016347cd6afbc18)) - Ku6epXBOCTuK
- separate schema preview logic - ([6ab5989](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ab59897d10dd391c549a3c1e6d9df6fc7623288)) - Ku6epXBOCTuK
- separate schema fields logic - ([cdb23fe](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cdb23fe1c113f5bcc41228b9508e68a65a2746b6)) - Ku6epXBOCTuK
- executor protocol, fallback, debounce - ([0933f1c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0933f1cdeab3998de628eb1a5443399304e088ae)) - Ku6epXBOCTuK
- change v1 to import own copy of i18n - ([939038f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/939038ffc02fc618c5e53a8589a803417149f327)) - Ku6epXBOCTuK
- update path registry-new to registry - ([75f6891](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/75f6891e7c4285b4a63515b426f88f3fe87bb6ab)) - Ku6epXBOCTuK
- set root layout, rename css, fix links in lint rules \ etc - ([e970b47](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e970b477205ea02dc96c67dac19bbc36660aab85)) - Ku6epXBOCTuK
- move old libs and components to old folder - ([3834dad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3834dadd03f2946fa679e4cfb737d7cb486e588b)) - Ku6epXBOCTuK
- update css vars lint rules and fix them - ([e3d50f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e3d50f2128e5f27c834455f5c11de0dd6364d9fe)) - Ku6epXBOCTuK
- fix lint css vars rules - ([2d24f51](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2d24f51fd5bf94408f1ec515575fc700bfd74ae3)) - Ku6epXBOCTuK
- isolate old and preview versions, add lint rule - ([e57fadd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e57faddfdef9e75312b5a81df56e6450fb82bff4)) - Ku6epXBOCTuK
- update field schema types - ([743ae81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/743ae817ee47b8bf2826ee9876e046babd7d811f)) - Ku6epXBOCTuK
- separate schema fields components - ([4a13bfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4a13bfbd1684d095d10f9403bde88359174456c7)) - Ku6epXBOCTuK
- separate registry file - ([58e75f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/58e75f23345b956d4068ff16ef90805031eae1c8)) - Ku6epXBOCTuK
- move form fields to separate folder - ([6ec3e75](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ec3e75df4dc22728a234dab77c4bf69316ce9c1)) - Ku6epXBOCTuK
- move components - ([e9de7a4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9de7a4bf8244a278bb44e8eb84c35db7ee38965)) - Ku6epXBOCTuK
- update panel component - ([7e94205](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7e94205866f8c9d25ebb548f3c3fa87f42557e25)) - Ku6epXBOCTuK
- update panel component - ([2f758a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f758a88af234fc2ceefca024d6abfdd9ee14608)) - Ku6epXBOCTuK
- separate top bar component - ([991fe69](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/991fe69381e7018bb977991fb6ce2ba99c87c53d)) - Ku6epXBOCTuK
- update footer component - ([11c4cce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/11c4ccece436aae2081c426d3e31fb272a464904)) - Ku6epXBOCTuK
- icon and download button use button component - ([8a6f879](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8a6f87938d9179728e55642119ab575ff9891b93)) - Ku6epXBOCTuK
- update pages and refs audit - ([86e7b72](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86e7b7250d5a35eabdd9c9a463ec7999fb7f641a)) - Ku6epXBOCTuK
- move old design to separate layout tree - ([f08af41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f08af41568f9694543703fdcc936118d8f20e342)) - Ku6epXBOCTuK
- separate tool page for A\B testing - ([d70cbad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d70cbad0d2d804386b2d5d53673553bf65ca323c)) - Ku6epXBOCTuK
- add error messages i18n - ([486b99d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/486b99dbb8ec9383de36d2e6cd88c9fec08c7434)) - Ku6epXBOCTuK
- use standalone tool run executor - ([1b3bad4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1b3bad46d1be4a04311f46400c078542026315ff)) - Ku6epXBOCTuK
- change show mask flow - checkbox in params - ([3a5acfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3a5acfbc452a2a2fc350dc4e803b0846473779c2)) - Ku6epXBOCTuK
- separate tool page - ([5560435](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5560435b55cb232b978f4c20a813f3d964e66b79)) - Ku6epXBOCTuK
- dropzone update - ([7afd07e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7afd07e8c148a317456ee864685258e40cab484d)) - Ku6epXBOCTuK
- remove unused styles - ([a9234d2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a9234d27de7d19f3a0489ebe1da1d310371a4287)) - Ku6epXBOCTuK
#### Style
- fix formatting - ([3c10147](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c10147ad85650ed9e9f63850fc9581400e2d427)) - Ku6epXBOCTuK
- fix prettier ignore\config - ([9dccdd9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9dccdd99a0f7b6ef187627cf8ec4811b648bdd22)) - Ku6epXBOCTuK
- format docs markdown - ([64e35f4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/64e35f4fdfedf331e79309c49ddab936b2a263da)) - Ku6epXBOCTuK
- fix formatting via prettier - ([ada96b3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ada96b3534a20a2c5c671cef3ca0146ada743afa)) - Ku6epXBOCTuK
- - -
Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto).
-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 -97
View File
@@ -1,100 +1,7 @@
# easy-image-tools # Project renamed
Browser-based image utility toolkit — no server, no data upload, everything runs "Easy png tools" now named "Easy image tools" and moved to a new domain.
locally in your browser.
Inspired by online services like onlinepngtools.com, but fully offline-capable [New repository link](https://github.com/Ku6epXBOCTuK/easy-image-tools)
and open source.
## Features [New website link](https://easy-image-tools.com)
- **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)
- **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; the image never leaves your browser. The only
third-party request is a Google Analytics page-view counter — it never sees
the file or its contents; the boundary and its limits are in
`docs/plan-seo.md` §0
## 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/
```
For the full command list and the "which checks to run for which change" matrix,
see [`AGENTS.md`](AGENTS.md) and
[`docs/quality-gates.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 and forms 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)
-19
View File
@@ -1,19 +0,0 @@
branch_whitelist = ["main"]
ignore_merge_commits = true
tag_prefix = "v"
pre_bump_hooks = ["node scripts/sync-version.mjs {{version}}"]
[changelog]
path = "CHANGELOG.md"
template = "remote"
remote = "github.com"
repository = "easy-image-tools"
owner = "Ku6epXBOCTuK"
[commit_types]
build = { omit_from_changelog = true }
chore = { omit_from_changelog = true }
ci = { omit_from_changelog = true }
docs = { omit_from_changelog = true }
test = { omit_from_changelog = true }
-63
View File
@@ -1,63 +0,0 @@
# Документация проекта
> Статус: **active**. Индекс документов. Обязательный краткий контракт — в
> `AGENTS.md`, подробные правила — в самих документах.
## Порядок чтения
1. `AGENTS.md` — команды, инварианты и карта «что читать под задачу».
2. Этот файл — выбрать актуальный документ по теме.
3. Соответствующий план или checklist темы.
4. Исходники, конфигурация и тесты рядом с изменяемым кодом.
## Активные документы
| Документ | Когда читать | Статус |
| ---------------------------------- | -------------------------------------------------- | ------ |
| `AGENTS.md` | Всегда: контракт агента | active |
| `docs/README.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/checklist-manual-testing.md` | Ручная приёмка production UI | active |
| `web/eslint-plugins/README.md` | Правка lint-правил, токенов и их конфигов | active |
| `docs/archive/README.md` | Архивирование и удаление старых документов | active |
## Правила
- У каждой темы ровно один владелец: остальные документы ссылаются на него, а не
пересказывают. Новый документ создаётся, только если темы нет в существующем.
- У документа есть статус (`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/`: там вынесены завершённые планы и
исследования, поэтому grep по `docs/` даёт больше шума, чем пользы. Для правок
production-кода архив не нужен: grep по `docs/*.md` без `archive/`.
- Проверка ссылок в документации не автоматизирована; любая неразрешимая ссылка
— ошибка.
- Протокол «остановиться и показать изменения» — `docs/agent-workflow.md`,
раздел 6.
-92
View File
@@ -1,92 +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. Не добавлять исключения линтера ради отдельных модулей: осознанные
отступления живут в конфиге правила (`allowPaths`, `allowWords`,
`ignoreMissingPatterns`, `allowDefaultProject` —
`web/eslint-plugins/README.md`), а не в самих проверяемых файлах.
## 2. Порядок изменения
- Сначала исправляются тесты или контракт, который фиксирует неверное поведение,
затем production-код — если это не отдельная продуктовая задача.
- Не начинать следующий этап плана в том же шаге: изменение атомарное, ревью
идёт между шагами (раздел 6).
## 3. Проверки
Матрица «изменение → минимальный набор проверок», состав `pnpm verify`, политика
warnings и чтение CI — в `docs/quality-gates.md`. Здесь только то, что относится
к работе агента.
`lint:all` роняет gate на любых ESLint warnings (`--max-warnings=0`).
Единственное, что он пропускает, — unused-token сообщения (неблокирующие).
Поэтому «команда прошла» не равно «нечего чинить»: нужно отдельно показать число
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.
-293
View File
@@ -1,293 +0,0 @@
# Архитектура
> Статус: **active**. Описание текущего кода: границы модулей, поток данных от
> реестра до скачивания, точки расширения. Канонический источник истины — сам
> код; этот документ объясняет, **куда смотреть и что с чем связано**, и не
> дублирует сигнатуры.
## 1. Слои
```txt
web/src/lib/
registry/ # источник истины: инструменты и страницы
registry/tools/ # Tool: реализации (id), без адресов и текстов
registry/pages/ # Page: адрес, тексты, категория, список шагов
registry-schema/ # декларативная схема параметров: specs (типы полей),
# field (builder DSL), layout (группы), aspect (aspect-lock
# и кэпы от source), values (дефолты и санитизация);
# index.ts — баррель, импорты идут по `registry-schema`
catalog.ts # группировка страниц и счётчики для /list-tools
categories.ts # id/названия категорий
tool-icons.ts # slug страницы → иконка
core/ # операции над ImageData; без DOM, кроме io.ts/domText.ts
executor/ # единственная точка исполнения инструментов
i18n/ # словари ru/en, t(), состояние локали
components/ # UI по слоям: ui/ примитивы, layout/ каркас,
# catalog/ каталог, schema/ view инструмента,
# fields/ поля (+ fields/schema/ контролы схемы),
# display/ типографика и карточки, feedback/ состояния
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/**` не знает ни о ком; `registry/**` описывает
данные и знает только `core/**` и `registry-schema.ts`. Нарушение этого порядка
— повод не заводить импорт, а инвертировать зависимость.
Исключение из «core без DOM» — `core/io.ts` (декодирование файла, кодирование,
скачивание) и `core/domText.ts` (canvas-рендер текста и эмодзи). Они трогают
`document`, `createImageBitmap` и `HTMLCanvasElement`, поэтому не работают в
node-Vitest и исключены из coverage-гейта (`docs/quality-gates.md`, раздел
Coverage). Всё остальное в `core/**` — чистые функции над `ImageData`.
### Чего в коде нет
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
скачала результат. Отсутствуют:
- **Пайплайн / цепочки шагов** — реализованы: модуль шагов
`web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps),
именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage
`chains:named`). Страница инструмента всегда открывается чистой; при первом
структурном изменении (добавлен шаг, заменён первый инструмент) цепочка
автоматически становится именованной (имя — дата/время) и маршрут бесшовно
меняется на `/pipeline?id=<id>` (передача исходника через handoff-стор,
replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/
создать). Легаси-автосейв `pipeline:<slug>` снят без миграции (ключи просто
перестают читаться). Экспортный JSON-формат не устоялся и может меняться до
фазы 11 `docs/roadmap.md`.
- **Разделения страниц, которые делят один инструмент** — сводит все
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
на практике не ссылается ни одна.
Задачи по обоим пунктам — в `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(input) → { sourceValue, resultValue, formatValue, 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` только рисует её вывод.
- **Цепочка работает над набором файлов (fan-out).** `run-chain.ts`: «ток» между
шагами — массив именованных изображений (одиночная загрузка = набор из
одного); image-шаг применяется к каждому файлу, files-шаг (split) склеивает
имена частей (`<вход>-<часть>`), analyze-шаг (text/verdict) при batch-входе
видит первый файл. Шаг роняет цепочку, только если упали все файлы
(`ChainStepError`), частичные сбои — warnings. Мультивыбор файлов в drop-зоне;
набор на выходе скачивается zip через `zip.ts`.
## 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 для скачивания (не у всех) |
| `runMask`? | ч/б превью «что изменится»; тогл на тайле результата, в цепочку не идёт |
| `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`.
Правило имени `id`: снять суффикс `-png` и в середине имени (`png-to-base64` →
`to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`). Правило
относится только к `id`: у `slug` своя история и SEO-значение, поэтому
переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя
файла, ключ `pages.*`, иконку и запись в `docs/tools-map.md`. Слаги, сложившиеся
до разделения реестра, правилу не соответствуют (`convert-png-to-jpg`,
`change-png-opacity`, `png-is-grayscale`, `verify-is-png` и другие) — это не
ошибка, менять их без SEO-решения нельзя. Форму slug проверяет
`registry.test.ts` (только форма, не смысл).
Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых
случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS`
и `PAGES` собираются в `registry/index.ts` из восьми групп; `getTool(id)` и
`getPageBySlug(slug)` — способы достать запись, `registry.test.ts` проверяет
уникальность обоих имён, существование инструмента под каждым шагом и то, что
шаг ровно один.
## 4. Схема параметров
`registry-schema/` — единственное место, где описаны виды полей. Модули:
`specs.ts` (типы полей и `ToolSchema`), `field.ts` (фабрика `field.*` и
`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`,
`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (`defaultSchemaParams`,
`applySourceDefaults`, `sanitizeSchemaParams`).
- **Виды поля** (`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({ workerFactory? })` плюс
готовый экспорт `execute`. Контракт асинхронный (`Promise`), параметры
сериализуются по `executor/protocol.ts`, исполнение уходит в
`executor.worker.ts`; `worker-handler.ts` разбирает сообщение и зовёт `run`.
Если worker недоступен — прямой вызов. UI импортирует `execute` из
`executor/index.ts`.
Отсюда следует: подключение wasm позже означает замену реализации **внутри**
исполнителя, а не правку компонентов.
## 6. i18n
Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с
`LOCALES`/`BASE_LOCALE`, `t.ts` с `t()`, `locale.svelte.ts` с состоянием
локали). Пользовательский текст живёт только в словарях и читается через `t()`.
Списки ключей и допустимых атрибутов для i18n-правил линтера лежат не здесь, а в
`web/eslint-plugins/i18n/lists.js`. Секции разделены по слоям:
`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.*` в словарях + `docs/tools-map.md` + хардкод slug в тестах и e2e; иконки править не нужно — `TOOL_ICONS` типизирован `Record<PageSlug, _>` и ломает компиляцию; меняется адрес страницы — операция с 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` §4, 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 без страницы и страница
без записи в карте роняют тест; разделы «Можно добавить» и «Идеи»
проверяются в обратную сторону — реализованный slug в плане тоже роняет
тест, поэтому при переезде инструмента из плана в реестр его вынимают
оттуда. Обоснование и границы автоматизации — `docs/decisions.md`, раздел 4.
Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для
инструмента, меняющего пользовательский поток, — e2e (см. раздел 3
`docs/quality-gates.md`).
## 9. Идиома: вынос логики компонента в `*.svelte.ts`
Крупный компонент-оркестратор (образец — `SchemaToolView.svelte`) держит в себе
только разметку, эффекты и пропсы; исполняемая логика живёт в модуле рядом
(`schema-tool-runner.svelte.ts`). Форма модуля продиктована лимитом
`max-lines-per-function` (100 строк, `web/eslint.config.js`): фабрика
`create*(ctx)` не вмещает все действия замыканиями, поэтому:
- состояние — один `$state`-объект (`RunnerState`), созданный внутри фабрики (не
модульный уровень — состояние на экземпляр компонента);
- действия — модульные функции `action(state, ctx)`; фабрика только создаёт
состояние и возвращает геттеры + привязанные вызовы;
- зависимости от компонента — `ctx` с ленивыми геттерами (`steps: () => steps`),
чтобы не копировать реактивные значения;
- компонент читает состояние через `$derived`-алиасы, пишет — только методами
фабрики.
Побочный плюс: действия тестируются как чистые функции над `(state, ctx)` без
монтирования компонента.
-57
View File
@@ -1,57 +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 ломает их, и агент без этого шага напишет в новом документе ссылку на
удалённый файл.
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` = один компонент с разными подписями/дефолтами). Не плодить
дублирующий код.
-215
View File
@@ -1,215 +0,0 @@
# Аудит архитектуры (2026-10)
> Статус: archived Архивирован: 2026-10-10 Удалить после: 2026-11-09 Причина:
> все находки разобраны (сделаны или закрыты с обоснованием) Использование:
> только историческая справка
>
> Примечание: `$effect` с `goto()` в `SchemaToolView.svelte:529-546` намеренно
> исключён — бесшовная смена маршрута при структурном изменении цепочки
> (`docs/architecture.md`, раздел 1) является требованием, не дефектом.
Проверено и чисто (не является находкой):
- Слоистость соблюдена: обратных импортов `core→registry/components`,
`registry→components/routes/executor` нет.
- Прямых вызовов `tool.run` в обход `executor.execute` нет.
- DOM-API в `core/**` только в санкционированных `io.ts`/`domText.ts`.
- Устаревшего Svelte 4 синтаксиса нет (`export let`, `$:`, `on:`, `<slot>`,
`$store`, `createEventDispatcher` — 0 совпадений).
- `console.error` в lib/routes отсутствует; worker-протокол кодирует ошибки;
скачивание/encode — единый источник `core/io.ts`.
## High
### H1. `SchemaToolView.svelte` — god-component — СДЕЛАНО (2026-10)
Компонент уменьшен со 1115 до ~660 строк (оркестратор: derived-связки, init,
handleFiles, эффекты, workspace-разметка). Декомпозиция:
- **H1a:** aligned-раскладка вынесена в `SchemaAlignedLayout.svelte` (view-only:
разметка + scoped-стили, сниппет `stepCard` передаётся пропсом).
- **H1b:** runner вынесен в `schema-tool-runner.svelte.ts`
(`createSchemaToolRunner(ctx)`); идиома «`$state`-объект + модульные функции»
зафиксирована в `docs/architecture.md`, раздел 9.
- **H1c:** стейт шагов вынесен в `schema-tool-state.svelte.ts`
(`createSchemaToolState(ctx)`: steps, touchedByStep/lastAxis, все мутации,
DnD; `toolSchemaOf` — модульный экспорт).
### H2. Race condition превью — СДЕЛАНО (2026-10)
`SchemaToolView.svelte`: добавлен generation-counter `runGen`; устаревший прогон
`runChain` после `await` выходит без записи результатов, ошибок и сброса
`running` (`finally` сбрасывает только актуальный); `init()` инкрементом
инвалидирует прогоны прошлого хоста.
### H3. `registry-schema.ts` — 5 ответственностей — СДЕЛАНО (2026-10)
Файл (769 строк) разрезан на директорию `web/src/lib/registry-schema/`:
`specs.ts` (типы полей, `fieldSpecs`, `ToolSchema`), `field.ts` (builder DSL +
`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`,
`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (дефолты и санитизация);
`index.ts` — баррель, пути импортов не менялись. `docs/architecture.md` (разделы
1, 4) обновлён. JSDoc в `specs.ts` сжат до однострочников — лимит плотности
комментариев (`conventions/comment-format`, 15%).
## Med
### M1. Маски через `$effect` вместо `$derived.by` — СДЕЛАНО (2026-10)
`SchemaToolView.svelte`: эффект вычисления `maskResults` заменён на
`$derived.by`; ручной сброс `maskResults = {}` в `init()` удалён — derived
пересчитывается сам по смене `steps`/`source`/`maskOnKeys`.
### M2. `init()` не сбрасывал часть состояния — СДЕЛАНО (2026-10)
`SchemaToolView.svelte`: `init()` теперь очищает `touchedByStep` и `lastAxis` —
метки от ключей шагов прошлого хоста больше не протекают в новую цепочку.
Переработка самого паттерна `$effect(() => init())` — часть H1, отдельно не
делалась.
### M3. `SchemaPreview.svelte` — интерфейс из ~40 пропсов — СДЕЛАНО (2026-10)
Пропсы сгруппированы в 4 view-model объекта по внутреннему потребителю: `head`
(SchemaActions), `source` (SchemaSourceTile), `steps` (промежуточные тайлы),
`result` (SchemaResultTile). Интерфейсы — в `schema-preview-model.ts`
(`PreviewHeadModel` и др.); имена полей зеркалят пропсы потребителей, поэтому
внутри работает спред (`<SchemaActions {...head}>`). Плоскими остались 7
идентифицирующих пропсов (inputMode, resultKind, toolId, running, error,
aligned, ontogglealign).
### M4. `runMask` обходил санитизацию параметров — СДЕЛАНО (2026-10)
`SchemaToolView.svelte` (derived `maskResults`): параметры шага теперь проходят
`sanitizeSchemaParams(stepSchema, step.params, { source: input })` — тот же
контракт, что в `executor/executor.ts` (вход шага как source-контекст).
### M5. UI-метаданные дропдауна скачивания в core-слое — СДЕЛАНО (2026-10)
`OutputFormatOption`/`OUTPUT_FORMATS`/`outputFormatByMime` вынесены из
`core/io.ts` в `web/src/lib/output-formats.ts` (UI-слой: label и
`settings.quality` дропдауна кнопки Download). В core остался только
mime-контракт `OutputMime`; сам `io.ts` таблицу не использовал. Потребители (4
файла) переведены на новый путь.
### M6. Доменная политика сжатия в IO-модуле — СДЕЛАНО (2026-10)
`fitWithinBytes` перенесён из `core/io.ts` в `core/compress.ts` (дом стратегий
сжатия). Сигнатура с инъекцией:
`fitWithinBytes(img, mime, targetBytes, quality, encodeFn)` — вызывающий
(`schema-tool-runner`) передаёт `io.encode`, поэтому `compress.ts` остаётся
DOM-free и node-тестируемым (попутно `fitWithinBytes` теперь можно
юнит-тестировать с фейковым encode). `io.ts` вернулся к чистому IO.
### M7. `clamp` продублирован 5 раз — СДЕЛАНО (2026-10)
Создан `web/src/lib/core/math.ts` (`clamp`, `clampInt`, `clamp01`, `clampByte` —
round-семантика `clampByte` и trunc-семантика `clampInt` сохранены раздельно);
локальные копии удалены из `core/alpha.ts`, `core/background.ts`,
`core/color.ts`, `core/convolution.ts`, `core/geometry.ts`, `core/palette.ts`,
`core/pixel-fx.ts`. Вариант с опциональными границами в `registry-schema.ts`
переведён на общий `clamp` через `?? -Infinity / ?? Infinity`. Копия в
`palette.test.ts` оставлена — тесты независимы.
### M8. Два параллельных hex-парсера — СДЕЛАНО (2026-10)
Единый парсер — `parseHexColor(hex): Rgb` в `core/palette.ts` (терпимый: `#?`,
3/6 цифр, бросает `ToolError`). `hexToRgb` удалён; `parseHex` из `core/alpha.ts`
удалён, потребители (`affine`, `background`, `geometry`, `morphology`, `alpha`)
переведены на объектную деструктуризацию и импорт из `palette`. Тест `parseHex`
переехал из `alpha.test.ts` в `palette.test.ts`. Поведенческое послабление:
бывшие потребители `hexToRgb` теперь принимают и 3-значный hex — вход всё равно
проходит санитизацию схемы.
### M9. Гард `localStorage` продублирован 4 раза — СДЕЛАНО (2026-10)
Создан `web/src/lib/storage.ts` (`storage()` + `safeSetItem`); локальные гарды
удалены из `chains.svelte.ts`, `favorites.svelte.ts`, `i18n/locale.svelte.ts`,
`theme.svelte.ts`.
### M10. Логика темы дублируется между `app.html` и `theme.svelte.ts` — ОТКЛОНЕНО
`web/src/app.html:10-16` повторяет `systemTheme()` (`theme.svelte.ts:11-16`) и
захардкоживает ключ `"theme"` (`theme.svelte.ts:3`).
Решение (2026-10): оставить как есть. Inline-скрипт обязан выполниться синхронно
до парсинга контента, чтобы тема не мигала, — импортировать `theme.svelte.ts` из
него нельзя в принципе (модули async, `app.html` не проходит сборку TS).
Дублирование неизбежно.
Остаточный риск drift'а (принят, без фикса): ключ `"theme"`, значения
`"dark"/"light"` и атрибут `dataset.theme` продублированы; при exception inline
ставит `"light"`, а `initTheme()` при невалидном attr берёт `systemTheme()` (на
практике безразлично — inline всегда выставляет attr). Если риск когда-то
материализуется, дешёвая защита — unit-тест (~20 строк), читающий `app.html` и
пиннющий контракт: ключ `"theme"`, `dataset.theme`, значения `dark`/`light`.
## Low
### L1. Флаг `running` общий для `run()` и `download()` — СДЕЛАНО (2026-10)
В `schema-tool-runner.svelte.ts` флаги разделены: `running` (run) и
`downloading` (download); `download()` больше не затирает индикацию
параллельного auto-run. Компонент передаёт в превью
`busy = running || downloading`.
### L2. Дублирование dismiss-логики поповеров — СДЕЛАНО (2026-10)
Общий хелпер `onDismiss(root, close)` в `web/src/lib/components/ui/dismiss.ts`
(pointerdown-outside + Escape + отписка); `SchemaDownload.svelte` и
`ToolPickerButton.svelte` переведены на него — по ~15 одинаковых строк удалено
из каждого.
### L3. Несогласованный API `Toggle` — СДЕЛАНО (2026-10)
Единый паттерн — `bind:checked` (задокументирован в `Toggle.svelte`; `onchange`
оставлен только для side-эффектов). `SchemaPreview.svelte`: `aligned` стал
`$bindable`, пропс `ontogglealign` удалён; `SchemaToolView` передаёт
`bind:aligned`.
### L4. Непроверяемые `as`-касты схемы — СДЕЛАНО (2026-10)
- 14 field-контролов: `spec: FieldSpec` + `spec as XSpec` → точный тип спеки в
пропсах (`spec: ColorSpec` и т.д.); каст собран в одном месте — типизированный
хелпер `controlFor<S>()` в `SchemaFields.svelte` (ключ kind в `FIELDS`
гарантирует подтип спеки по `fieldSpecs`).
- `SchemaToolView`/`toolSchemaOf`: касты
`as ToolSchema<Record<string, unknown>>` удалены — дефолт дженерика
`Tool<P = Record<string, unknown>>` уже даёт нужный тип; `undefined`
обрабатывается гардами (`{#if stepSchema}` в разметке, `dims && stepSchema` в
`setStepValue`).
- `executor.ts` `as unknown as WorkerLike` оставлен намеренно: несовместимость
`onerror` (ErrorEvent vs Event) в типах lib.dom; добавлен комментарий-причина.
### L5. Хардкод slug-ов вне registry — СДЕЛАНО (2026-10)
`Page` стал readonly, группы `registry/pages/*` —
`as const satisfies readonly Page[]`; в `registry/index.ts` добавлен union
`PageSlug`. `TOOL_ICONS` типизирован `Record<PageSlug, IconComponent>` —
переименование или удаление страницы теперь ломает компиляцию, а не падает
молча. Чтение с внешней строкой — через аксессор `toolIcon(slug)`. Попутно
найдены и удалены 9 мёртвых ключей иконок (страницы уже не существуют:
`png-info`, `jpg-to-png`, `webp-to-png`, `gif-to-png`, `bmp-to-png`,
`ico-to-png`, `compress-png`, `reduce-to-size-png`, `watermark-image-png`).
Добавлен runtime-тест `web/src/lib/tool-icons.test.ts` (оба направления: сироты
и страницы без иконки). Хардкод в `kit/+page.svelte` оставлен — витрина, не
production.
### L6. Дублирование CSS-значения превью-тайла — СДЕЛАНО (2026-10)
Новый токен `--size-tile-canvas-min: clamp(var(--space-brand), 30vh, 60vh)` в
`app.css`; `PreviewTile.svelte` и `SchemaResultTile.svelte` используют
`var(--size-tile-canvas-min)`.
### L7. `localStorage.setItem` без try/catch вне app.html — СДЕЛАНО (2026-10)
Все `setItem` в `theme.svelte.ts`, `chains.svelte.ts`, `favorites.svelte.ts`,
`locale.svelte.ts` переведены на `safeSetItem` из `web/src/lib/storage.ts`
(best-effort persistence: бросок в privacy-режиме проглатывается).
### L8. Переросшие файлы групп реестра и core — НЕ ДЕФЕКТ, закрыто (2026-10)
Размер `registry/tools/*.ts` — от количества инструментов (в `generate.ts` 21
независимая запись `Tool`), конвенция «один файл на категорию» осознанная; SRP
не нарушен, записи независимы. `core/geometry.ts`/`core/palette.ts` —
родственные поддомены в одном модуле, допустимо. Если навигация станет проблемой
— резать на подгруппы по мере роста, отдельной задачи не нужно.
-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 млн) запросов на чтение в сутки.
- Для пет-проекта этого хватит навсегда, даже если вы будете делать запросы
напрямую с фронтенда без кеширования.
-275
View File
@@ -1,275 +0,0 @@
# Backlog
> Статус: **active**. Открытые задачи. Подробные планы, разбор решений и сырые
> находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
> `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
>
> Все пункты — чекбоксы; нумерация «Идея N» в тексте сквозная по документу и
> сохраняется ради перекрёстных ссылок («идея 4 зависит от идеи 3»). Оценки
> (S/M/L) — оценка труда, не приоритет.
## Тесты и lint
- [ ] правило - только английские комментарии не работает на svelte файлы
- [ ] правило - ограницить комментарии вроде уменьшило простыни из комментариев,
но всё равно они есть и часто пишет очевидные вещи. надо более строгое
правило. может такой вариант: разрешено писать комментарии в коде, но
только внутри функций, название которых начинается с `complex_` или
`tricky_` (или помеченных специальным декоратором/аннотацией).
- [ ] линтер на deprecated иконки возможен? 'FilePlus2' is deprecated.
- [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
core, а не `typescript-eslint`) — `web/eslint.config.js`.
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого действует pattern-based policy-исключение
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
(`docs/testing-strategy.md`, раздел 5)
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
привязанные к разметке или svelte-классам (сейчас остались:
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
правило такое (запретить toContain без i18n)
- [ ] посмотреть ошибки e2e, разрешить запускать е2е без ручного подтверждения,
и рассмотреть - может уже пора включить их в ci\cd гейт
## Pipeline
- [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
но он работает с любыми картинками, наверное надо убрать; в заголовке
страницы — возможно, надо указать все форматы (связано с SEO)
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
поиском, фильтром возможных инструментов и т.д.
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
режима aligned\normal
- [ ] переделать визуал toggle компонента (которым режим переключается)
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Fan-out и
мультивыбор уже есть (идея 15: `web/src/lib/run-chain.ts`, zip-выгрузка
набора). Осталось: zip на вход (распаковка в браузере, порядок файлов,
поведение many → 1 → many), прогресс и отмена пофайлово, запуск по
сохранённой цепочке из `/chains`.
- [ ] **Идея 15. Остатки fan-out** — сделано: 1 → many не терминален,
мультизагрузка, склейка имён, per-file ошибки с warning. Отложено:
parts-grid на промежуточных тайлах (сейчас первая картинка набора),
прогресс/отмена, analyze-шаги пофайлово (сейчас firstOnly-warning).
## Инструменты
- [ ] split-to-parts, загружаем картинку, после этого переключаемся на режим by
tile - интерфейс начинает тормозить. уже пора переходить на worker\wasm?
- [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с
такими делать вообще? оставить как seo? можно ли под капотом использовать
другой инструмент с хардкодед настройками?
- [ ] qr code to text, text to qr code
- [ ] bar code to text, text to bar code
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
какой размер - 112\56\28px или 72\36\18)
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей)
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
инструмента), а сама модель: `output` с mime/ext — свойство `Tool`, а
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
вывода
- [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке
Download** — оценка M/L. Решение и продуктовое обоснование:
`docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет
существующие convert-страницы — `docs/plan-seo.md` §5. Селектор формата и
качества в Download уже есть; «сжатие до целевого КБ» реализовано
(`fitWithinBytes` в `web/src/lib/core/io.ts`: бинарный поиск по quality
для lossy, по k квантования для PNG; для BMP — предупреждение, что лимит
не применим). Что осталось: нужна ли отдельная страница под интент «сжать
до N КБ» — открытый вопрос с вордстатом, имя не выбрано
(`docs/plan-seo.md` §5). Проработать вместе с достройкой download-флоу.
- [ ] **S1g под вопросом** — исходная формулировка (`docs/plan-seo.md` §4): на
convert-страницах блокировать в меню Download формат исходника, чтобы
`convert-png-to-jpg` не сохранял в png. Против: пользователь может
захотеть сохранить обработанный PNG или JPEG с другим качеством — блок
мешает легальному сценарию. Решение не принято; перед реализацией
переобдумать.
- [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не
виден, пока меню Download закрыто: пользователь может забыть про него и
случайно сжать следующие файлы. Нужен постоянный индикатор (значок/
подпись на кнопке или рядом). Связанный вопрос персистентности: лимит —
`$state` в `SchemaToolView.svelte`, при переходе на другой инструмент
сбрасывается в `init()` и в localStorage не пишется (в отличие от шагов
цепочки). Это безопасное поведение по умолчанию, но стоит осознанно
решить: оставить сброс при смене страницы или сохранять лимит (per-tool?
глобально?) вместе с цепочкой.
- [ ] **Лимит КБ/качество: превью итогового результата** — обдумать UI/UX: при
сжатии по размеру или низкому качеству дать возможность посмотреть
итоговый файл до скачивания — при сильном сжатии (низкий quality, грубое
квантование в PNG) искажения могут быть настолько заметны, что
пользователь откажется от варианта и выберет другой формат/лимит. Сейчас
превью показывает результат инструмента до re-encode, а сжатая версия
существует только внутри `fitWithinBytes`/`encode` в момент скачивания.
Открытые вопросы: превью на лету (дорого — encode на каждый ввод) или по
кнопке «предпросмотр»; где показывать (оверлей/замена тайла результата);
связь с идеей 14 (отображение маски) и пунктом про зум превью выше.
- [ ] **Идея 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).
## UI и качество
- [ ] **обдумать агрегацию предупреждений на тайле** — сейчас разрешено два
бейджа рядом (derived `resultNote` + runtime-warnings); после
использования решить, нужен ли один сводный бейдж со счётчиком и списком
- [ ] **лимит КБ/качество как авто-шаг пайплайна** — вынести size limit/quality
из меню Download в автоматический инструмент цепочки, который применяет
выбранные настройки (сжатие видно в превью и в цепочке, ошибки
недостижимости ложатся на тайл шага). Нужен флоу передачи индикатора из
такого шага в кнопку Download (ошибка/предупреждение/итоговый размер).
Связано с пунктами про индикацию активного сжатия и превью итогового
результата
- [ ] кнопку редактирования названия пайплайна на этой же странице
- [ ] при загрузке нескольких файлов, показывает source не той же высоты, как
result
- [ ] split to parts - посмотреть вариант с toggle component - может будет
удобнее чем sectioned buttons
- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать
заменить на Toggle-компонент, когда тот будет доделан (см. пункт про
визуал toggle в «Pipeline»): возможно, будет удобнее и понятнее
- [ ] маска подгружается с задержкой при нажатии на чип «Маска» — сейчас
`runMask` считается только для включённых шагов (эффект в
`SchemaToolView.svelte`). Альтернатива: считать маску всегда вместе с
результатом, по нажатию просто показывать другую картинку. Оценить
достоинства/недостатки: отклик мгновенный, но лишний проход по пикселям на
каждый ран каждого шага с `runMask` (на больших изображениях — заметная
цена за функцию, которую могут не открыть)
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
мгновенно пересортировываются и под мышкой оказывается другой элемент.
Нужна или анимация, или механизм отложенного обновления позиций
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
черная\белая\цветные\серая для проверки прозрачных картинок
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
единую структуру, позволит упростить некоторые вещи - например
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] resize keep aspect - работает, но немного не очевидное поведение при
прямоугольной картинке и выборе размера через быстрые кнопки (например
256х256). Воспроизведение поведения: keep aspect включено, загружаем
прямоугольную картинку 832 × 1232, картинка рескейлится до 173 × 256,
выключаем keep aspect, картинка рескейлится до 256 × 256 (с искажением
пропорций), включаем keep aspect - картинка рескейлится до размера 256 ×
379
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] обновить страницу kit или сделать storybook
- [ ] **проверить `cachedDataUrl` (WeakMap-кэш превью) в реальной работе** — GC
срабатывает недетерминированно, поэтому освобождение base64-строк (≈1.3×
бинарного размера картинки на каждый тайл) не гарантировано своевременно.
Проверить на длинной сессии с большими картинками (несколько ранов подряд,
смена инструментов): растёт ли heap в DevTools → Memory после повторных
ранов и уходит ли после принудительной сборки. Если записи копятся —
заменить на ограниченный LRU или явную инвалидацию при
`resetForHost`/новом ране (`web/src/lib/core/io.ts`, `cachedDataUrl`)
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
поэтому она не следует за пропорциями результата — широкий результат
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
чтобы соотношение сторон выбиралось по результату в диапазоне 16:9…9:16
(`web/src/lib/components/schema/PreviewTile.svelte`).
- [ ] Радиусы бордеров: `--radius-m` ненулевой и применён ко всем бордерам
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
в дизайне, а где должен быть 0
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
для обычного текста, а `--color-danger` в light-теме на границе: после
завершения редизайна вернуть порог 4.5 и перепроверить оба токена.
- [ ] **Идея 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, поэтому расхождение
ловится на ревью.
## SEO/GEO
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
вордстату с уникальным контентом; редиректы только для опечаток и
переименований уже опубликованных страниц; клонов с canonical не делать
(`docs/plan-seo.md` §5)
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
- [ ] Seo.svelte: per-tool OG-картинки (S1e) — у каждой страницы инструмента
своя превью-карточка 1200×630 для соцсетей/мессенджеров: имя инструмента,
бренд, иконка. Сейчас все страницы ссылаются на общую
`static/og/default.png` (генерирует `pnpm --dir web gen:og` —
Playwright-скриншот HTML-карточки в `web/scripts/gen-og.mjs`). Что делать:
расширить gen-og.mjs — проход по реестру страниц, карточка с названием
инструмента в `static/og/[slug].png`, в `Seo.svelte` подставлять og-URL по
slug страницы. Критерий приёмки: у `/tools/flip-png` в og:image своя
картинка с названием инструмента, файл лежит в `static/og/`
- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
- [ ] GSC: подключить домен, отправить sitemap, мониторинг индексации (S4a)
- [ ] GA4: cookieless-конфигурация, отключить рекламные audiences и signals,
`anonymize_ip` — сейчас в `web/src/routes/+layout.svelte` голый
`gtag('config')` (S4b)
## Платформа (API/PWA)
- [ ] Vercel: переезд хостинга с GitHub Pages + глобальная статистика
популярности инструментов через Vercel KV (serverless-функция: INCR при
открытии инструмента, чтение топа для скоринга поиска).
- [ ] 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)
## Переезд
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
plurals есть все формы, для которых нужен перевод, `other` никогда не
пустой, число подставляется в текст без склейки
-107
View File
@@ -1,107 +0,0 @@
# Чек-лист ручного тестирования production UI
> Статус: **active**. Вспомогательный документ к 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 файл — понятная ошибка, нет краша (авто-чека в
`tool-flows.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-png`,
`dithering-png`, `quantize-png` на 4К) — UI не фризит, канселяция/дебаунс
срабатывает.
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
залипаний, панели не прыгают.
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
(утечек blob-URL/bitmap быть не должно).
## E. Визуальный слой (доделываем по ходу)
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
desktop) на всех маршрутах: `/`, `/list-tools`, `/kit`, любая `/tools/*`.
- [ ] Тема light/dark: контраст вердиктов (Yes/No), подпись панели превью
(«Предпросмотр»), мета-инфо бликов не даёт.
- [ ] Иконки в каталоге на всех категориях — не «бьются» (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`.
-209
View File
@@ -1,209 +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», цитата самого правила).
- Правило всё равно неполно: ловит «M инструментов», но не «инструментов — M»,
не «более 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`; новых гейтов и конфигов не добавляет.
Граница автоматизации: парсер читает разделы «Реализовано», «Можно добавить» и
«Идеи», но проверяет их в разных направлениях. Раздел 1 сверяется с реестром
обеими сторонами (slug без страницы и страница без записи). Разделы 2 и 3
сверяются только в одну сторону: там slug'и намеренно несуществующие, поэтому
проверяется, что **ни один из них не является реальной страницей** —
реализованный инструмент, оставшийся в плане, роняет тест. Проверять «у плана
есть запись в реестре» нельзя по построению: список плановый, а не фактический.
Плановые разделы устроены прозой, поэтому их разбор другой: берутся все токены
нужной формы из всего буллита, а не только из «головы» до тире. Следствие для
авторов карты: реализованные страницы в планах называются прозой между
буллитами, а не внутри них.
## 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 и есть то, что делает правило точным.
## 12. Бамп версии и changelog — при пуше в main, а не в PR
Версионирование и changelog генерирует бот (`bump.yml`, cocogitto) на push в
`main`, а не на pull request.
- При PR-боте параллельные PR бампили бы одну и ту же версию и конфликтовали по
`CHANGELOG.md`; коммиты бота внутри чужого PR — шум, а версия росла бы за ещё
не влитый код. Репозиторий trunk-based: коммиты идут прямо в `main`, деплой и
так запускается на push.
- Цикл: push в `main` → `verify.yml` (переиспользуемый gate) → `cog bump --auto`
бампит версии в обоих `package.json` (футер сайта читает `web/package.json`),
обновляет `CHANGELOG.md`, ставит тег и пушит без `--skip-ci`, чтобы версионный
коммит заново запустил деплой и сайт показал актуальную версию. Зацикливание
исключено dry-run проверкой: после версионного коммита conventional-коммитов
после тега нет, бот выходит.
- Пуш идёт токеном GitHub App (`XBOCT_BOT_ID`/`XBOCT_BOT_PRIVATE_KEY`), а не
`GITHUB_TOKEN`: пуш стандартным токеном не запускает workflow, и деплой с
новой версией не случился бы.
- `concurrency: bump` без `cancel-in-progress`: два бампа параллельно запушили
бы конфликтующие версии.
- Пересмотреть, если появятся внешние контрибьюторы и поток пойдёт через PR —
тогда боту понадобится режим «черновик релиза в PR» или squash-стратегия с
conventional-заголовком PR.
-429
View File
@@ -1,429 +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`).
>
> **В этом документе инструменты названы по `id`** (`registry/tools/`), а не по
> адресу страницы: поля и раскладка принадлежат схеме инструмента, а страница
> только ссылается на список шагов (`Page.steps`), где сейчас ровно один
> инструмент, а в будущем может быть несколько. Поэтому «у страницы `crop-png`
> есть поля x/y» неверно — поля есть у инструмента `crop`, а страница лишь
> показывает его. `id` не несёт png-интента: страница `crop-png` вызывает
> `id: "crop"`, страница `png-to-hsl` — `id: "to-hsl"` (модель —
> `docs/architecture.md`, раздел 3).
>
> **Как переносить пункты аудита.** `docs/tools-audit.md` назван по страницам,
> потому что это то, что видит пользователь. При переносе пункта в шаг мапить
> slug → `id` через `page.steps[0].id` в `registry/pages/`. Названия аудита вида
> `color-wheel-generator` и `webp-to-png` в реестре отсутствуют (реальная
> страница — `color-wheel-png`, инструмент — `color-wheel`).
## 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/tools/filters.ts` — `randomizePixels`, `addNoiseTool`:
поле seed → `{ ...field.number(...), spec: { ...spec, variant: "seed" } }`
(или пересоздать через `field.seed()`)
---
## 2. Волна B — select → buttons (по файлам)
Каждый шаг — один коммит, < 500 строк. Меняем `field.select()` →
`field.buttons()` в перечисленных ниже схемах.
### FA-B1. geometry.ts
| Инструмент | Поле | Опции |
| ------------------- | -------- | ----------------------------------------- |
| rotate | angle | 90° / 180° / 270° |
| flip | axis | horizontal / vertical |
| swap-orientation | target | portrait / landscape |
| symmetric-copy | axis | vertical / horizontal |
| symmetric-copy | keepSide | left / right / top / bottom |
| change-aspect-ratio | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 |
| change-aspect-ratio | mode | crop / pad |
**Исключение:** `change-canvas-size` (anchor, 9 опций) — оставить
`field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже
отдельный компонент `PositionControl`).
### FA-B2. color.ts
| Инструмент | Поле | Опции |
| -------------------- | --------- | ---------------------------------------------------- |
| dithering | pattern | floyd-steinberg / bayer |
| extract-channel | channel | red / green / blue |
| swap-channels | pair | r-g / r-b / g-b |
| decrease-color-count | maxColors | 2 / 4 / 8 / 16 / 32 / 44 / 64 / 96 / 128 / 192 / 256 |
**Исключение:** инструменты разложения каналов (`to-hsl`, `to-hsv`, `to-hsi`,
`to-cmyk`, `to-ycbcr`, `to-lab`, поле `component`) — 3-6 опций, но это
динамические инструменты, оставить `select`.
### FA-B3. analyze.ts
| Инструмент | Поле | Опции |
| --------------------- | ---- | ------------------ |
| show-transparent | mode | binary / highlight |
| show-grayscale-pixels | mode | (тот же) |
| show-color-pixels | mode | (тот же) |
| light-pixel-mask | mode | (тот же) |
| dark-pixel-mask | mode | (тот же) |
| unique-color-mask | mode | (тот же) |
Пять инструментов из шести берут `mode` из общего `maskBaseFields` — правка в
одном месте. Исключение — `show-transparent`: он переопределяет `mode` своими
опциями и дефолтом `highlight`, поэтому его `mode` правится отдельно.
### FA-B4. filters.ts
| Инструмент | Поле | Опции |
| ---------- | ---- | ------------ |
| add-noise | mode | mono / color |
### FA-B5. generate.ts
| Инструмент | Поле | Опции |
| -------------- | ------------ | --------------------- |
| color-spectrum | direction | horizontal / vertical |
| step-colors | layout | grid / strip |
| complementary | layout | grid / strip |
| triadic | layout | grid / strip |
| tetradic | layout | grid / strip |
| analogous | layout | grid / strip |
| monochromatic | layout | grid / strip |
| shades | layout | grid / strip |
| sort-colors | layout | grid / strip |
| mix-colors | (нет select) | — |
---
## 3. Волна C — обязательные фиксы (баги + дефолты)
### FA-C1. Source-aware defaults для dimension-полей — реализовано
Source-aware defaults реализованы: общий resolver в `registry-schema`
(`applySourceDefaults`). Для `resize` и `crop` дефолт после загрузки равен
размерам текущего source; до загрузки используется положительный fallback `1×1`.
Один-sided `0` у resize сохраняет режим auto. Проверено тестом
`registry.test.ts` («resize и crop получают размеры текущего source»).
### FA-C2. symmetric-copy: "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: неверное название — решение принято, шаг не выполнен
Находка про **страницу**, а не про инструмент: страница `verify-is-png` обещает
проверку PNG-файла, а проверяет текст (base64 / data-uri). Сам инструмент назван
честно — `id: "verify-png"`, png-интент в нём означает входные данные. Адрес
страницы — `slug` `verify-is-png` (`docs/architecture.md`, раздел 3), поэтому
«неверное название» относится к `slug`, а не к `id`.
Варианты:
- `title` → «Verify PNG data», `description` → уточнить; `slug` и `id` не
трогать
- либо переименовать `slug` → `verify-png-data` с редиректом со старого URL
(нужен вордстат, иначе страница выпадает из индекса)
**Решение:** поменять title и description, имя страницы не трогать.
Переименование `slug` меняет адрес, имя файла, ключи `pages.*` и иконку, то есть
это операция с SEO-последствиями, а не строчка в реестре — она не делается в
одиночку, вместе с решением по `docs/plan-seo.md` §5.
---
## 4. Волна D — обязательные фиксы (новые параметры)
### FA-D1. remove-alpha-channel: выбор цвета фона
Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`.
Файл: `alpha.ts`, схема `removeAlphaChannelSchema`.
### FA-D2. extract-alpha-mask: галочка «инвертировать»
Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В
`run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`.
Файл: `alpha.ts`.
### FA-D3. change-canvas-size: position9 вместо select
`anchor` сейчас `field.select()` с 9 опциями → `field.position9()`.
Файл: `geometry.ts`. Уже есть `PositionControl`.
### FA-D4. Generate: добавить height
Инструменты без `size`/`height`:
- blend-two — добавить
`height: field.slider({ min: 1, max: 1024, default: 512 })`
- step-colors — аналогично
- complementary, triadic, tetradic, analogous, monochromatic, shades — все
используют `paletteBaseSchema` (общий объект), добавить `height` туда
- `sort-colors` — своя схема, `paletteBaseSchema` не использует, `height`
добавлять вручную
- mix-colors — добавить height
Файл: `generate.ts`.
### FA-D5. add-border: прозрачность цвета
Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть
(прозрачность не поддерживается нативным color picker). Отметить в аудите как
blocked.
---
## 5. Волна E — полировка (некритично)
### FA-E1. Плейсхолдер "нет параметров"
Инструменты с пустой схемой (`EmptyParams`) выглядят странно. Добавить
`EmptyState` или подсказку "No parameters — click Run".
Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст.
### FA-E2. quantize: пресеты
`field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под
слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера.
### FA-E3. trim-empty-space: 0-254 → проценты
Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог
шага FA-A1 — `variant: "alpha-threshold"` в NumberSpec).
### FA-E4. add-text: plate offset
Plate снизу имеет большой отступ — добавить галочку "compact plate" или
уменьшить дефолтный padding.
### FA-E5. from-emoji: выбор эмодзи
Поле `emoji` → добавить группу часто используемых эмодзи как кнопки-пресеты над
полем ввода.
---
## 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-mask, square-mask, star-mask,
wavy-mask, round-corners, invert-alpha): нужен `field.checkbox("smoothing")` +
правка core (shapes.ts, alpha). Отложен — требует визуального тестирования.
- **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует
normative решения по UI.
- **Remove-background**: выбор точки удаления — либо параметр, либо отдельный
инструмент.
- **Метод масштабирования в `resize`**: поля в схеме нет (только `size` и
`keepAspect`), поэтому это уже не правка селекта, а новое поле — нужен ли
выбор алгоритма (bilinear/bicubic/nearest)?
- **Placeholders**: "нет параметров" — какой текст/компонент показывать.
### 7.2 Операционные (тест-кейсы, описания)
- find-contour: расширять на толщину линии (требует правки core)
- make-thicker: расширять картинку на толщину линии
- make-thinner: уменьшать от краев, fix выступа
- feather-edges: расширять картинку на толщину линии
- clean-edges: найти тест-кейс, возможно увеличить максимум
- despeckle-alpha: найти более заметный тест-кейс
- close-holes: проверить поведение на больших полупрозрачных областях
- auto-contrast: найти тестовый кейс
- sharpen: добавить тест-кейс
- center-by-alpha: найти тест-кейс
- is-grayscale: улучшить видимость результата
### 7.3 Дизайн-вопросы
- add-padding vs add-border: разница неочевидна, нужен review
- change-aspect-ratio: не видно что меняется — рамка результата
- watermark-tile: заполняет не полностью
- sepia: нужна ли настройка силы эффекта?
- posterize: настройка для каждого канала отдельно?
- pixelate: разные алгоритмы?
- swap-channels: обдумать flow через более простые инструменты
### 7.4 Аудит полей (компоненты)
- Range slider: поле ввода + кнопки +/- + кнопка сброса
- Color picker: пипетка с картинки, прозрачность
- Width/height с keep-aspect-ratio: единый компонент
- Position offset: отдельные числовые поля X/Y (уже сделано в
`fields/schema/OffsetControl.svelte`)
-144
View File
@@ -1,144 +0,0 @@
# План: платформа — общее ядро, API и PWA
> **Статус: active.** Этот план добавляет к `docs/roadmap.md` то, чего там нет:
> **API** (фаза 9, возможная монетизация) и **PWA** (фаза 10, оффлайн +
> установка), плюс шрифты (раздел 4 — частично относится к фазе 8, поэтому
> решения фазы 8 здесь дублируются). WASM-ядро, эталоны и CLI — фазы 2–8
> `docs/roadmap.md`, они здесь не пересказываются. Оба трека стартуют после
> wasm-фаз; PWA не ждёт API и может идти раньше.
## 0. Решения
- **Одно ядро — три потребителя.** Rust-крейт `core/` (roadmap фаза 4) даёт:
wasm-пакет для сайта (фаза 5), нативный CLI (фаза 6), серверный слой API (фаза
9). Единый контракт — JSON-пайплайн `{id, params}` (заложен в roadmap §0 и
совпадает с `Page.steps`, реализуется в фазе 11); эталонные файлы и harness
(фазы 2, 3, 7) гарантируют одинаковый результат во всех рантаймах.
- **API — нативный Rust на сервере, не wasm.** В браузере wasm нужен ради
доставки и песочницы; на сервере крейт линкуется напрямую — быстрее и проще.
Исключение — edge-воркеры (см. спайк ниже): там исполняется тот же
wasm-артефакт фазы 5.
- **PWA не трогает SEO-схему.** Сайт остаётся полностью prerendered-статикой;
service worker добавляет оффлайн-слой поверх и не отвечает за HTML ботовам.
- **Монетизация не ломает позиционирование.** Браузерные инструменты —
бесплатные, картинка обрабатывается в браузере и никуда не отправляется.
Счётчик посещений GA4 этому не противоречит (границы — `docs/plan-seo.md` §0).
API — отдельный продукт для автоматизации/бизнеса. Файлы в API обрабатываются
в памяти: без хранения и логирования содержимого.
## 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-аудитория
— источник бэклинков (`docs/plan-seo.md`, 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 (`docs/plan-seo.md`, S4c)
и для GEO-текстов (`llms.txt`, описания категорий).
## 4. Шрифты: лицензирование и DOM-free рендер текста
Текст — один из canvas-зависимых классов, которые roadmap (фаза 8) держит в TS
(перечень — там же); для текста выбран курс на реимплементацию, остальные классы
не переносятся. Для 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 пользователя,
лишняя зависимость от чужой инфраструктуры), — одинаковый результат у всех
пользователей и во всех рантаймах.
- [ ] **[F4]** Кириллица: приоритет семействам с полным покрытием — Liberation
Sans, PT Sans/PT Serif, Noto Sans, Inter, Roboto (OFL/Apache), DejaVu Sans
(пермиссивная лицензия Bitstream Vera: редистрибуция и коммерческое
использование разрешены) — закрывает и backlog-вопрос про «только с
кириллицей».
- **Стартовый набор** (все OFL/Apache, с кириллицей; состав не догма —
расширяется по запросам, каждый проходит паспорт F3). Сейчас в бандле сайта
лежат `@fontsource/ibm-plex-sans` и `@fontsource/ibm-plex-mono` (тоже OFL) — в
наборе ниже их нет, и при введении паспорта 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
визуально идентичный результат; в дистрибутиве нет ни одного файла шрифта без
паспорта лицензии.
-456
View File
@@ -1,456 +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 конкурента, а не наши страницы. Наши страницы — в `docs/tools-map.md`
> (карта перечисляет `slug`, не `id`); в тексте ниже названия инструментов
> приводятся по этим слагам, потому что для SEO важны адреса.
## 0. Решения
- **Бренд: `easy-image-tools`, домен `easy-image-tools.com`.** Переименование до
индексации выполнено (R1): хардкод убран, точка истины — константа `brand` в
i18n + README (хардкод в компонентах недопустим). Домен зарегистрирован
2026-10-11 (R2); остался перевод прода на него (DNS, HTTPS, снятие
`BASE_PATH`, `PUBLIC_SITE_ORIGIN`, 301 со старого GitHub Pages) — теперь это
блокер S1-domain (§4). Легенда честная: «started as PNG tools, now all
formats» — это контент-плюс, а не баг. Адреса страниц (`/tools/crop-png`) и
localStorage-ключи не переименовывать (localStorage-ключи бренда не содержат).
- **Форматы: формат = выбор в 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 и потерю позиций. Разделение инструмента и
страницы (решение выше) адресов не меняет.
- **Аналитика — GA4, в режиме «только агрегаты».** GA4 уже стоит в проде
(`web/src/app.html`, prerender-оболочка, значит на каждой странице). Решение —
не выпиливать, а ограничить: cookieless-конфигурация, без рекламных audiences,
signals и ремаркетинга. Граница приватности проходит по данным изображения: GA
не видит ни файла, ни его содержимого, ни результата инструмента — только факт
открытия страницы. **Рекламу и ремаркетинг не ставим** — они противоречат
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
- **Честные методы.** Никакого клонированного текста, дорвеев, 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` | генерируется при сборке (S1b) | — |
| `robots.txt` без `Sitemap:` | allow-all + `Sitemap:` (S1c) | — |
| `rel=canonical` | есть, `Seo.svelte` (S1d) | — |
| OG/Twitter-теги | есть; per-tool картинки — S1e | S1 |
| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, 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-страницы «про всё» не заводить — несколько сильных
материалов с ссылками на несколько связанных инструментов.
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`. Путь к картинке
строить через `resolve()` из `$app/paths` (как favicon в
`+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь
ломается. Домен для этого не нужен; при деплое на собственном домене
OG-картинки будут абсолютными и корректными.
- [x] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
для lossy) для всех инструментов — механика `Tool.output` уже есть
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
параллельно с текущими quality-задачами. Побочно снимает `quality` из схем
**конвертеров** — после этого convert-страницы сводятся на один инструмент
(`docs/backlog.md`). Не из всех схем: у `jpeg-artifacts` `quality` —
функциональное поле (сила артефактов), а `output` у инструмента нет, так
что селектор формата его не покрывает.
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в
png. **Под вопросом:** блок мешает легальному сценарию «сохранить
обработанный PNG / JPEG с другим качеством»; решение не принято, см.
`docs/backlog.md`. Существующие страницы не трогать: `convert-png-to-jpg`,
`convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами
со своим контентом (§5).
#### S1-domain: свой домен (подключён; §6 R2-cut)
- [x] **[S1a]** Перевести прод на собственный домен — **сделано 2026-10-11**:
`BASE_PATH` убран из `deploy.yml`, задан `PUBLIC_SITE_ORIGIN`, добавлен
`web/static/CNAME`. Редирект `https://<юзер>/<repo>/…` → корневые URL
(301) делает сам GitHub Pages при включённом кастомном домене. Делается
одновременно с S1d (canonical), чтобы Google проиндексировал сразу с
правильным доменом.
- [x] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs` — **сделано 2026-10-12**:
пишет `sitemap.xml` в `build/` после `vite build` (вызов добавлен в
`pnpm build`). Адреса берутся **из собранного HTML**, а не из импорта
`PAGES`: список prerender-страниц и есть фактический набор URL, а страницы
с `noindex` в HTML автоматически исключаются — sitemap не может разойтись
с тем, что реально задеплоено, и не рекламирует то, что мы просим
краулеров игнорировать. Проверено: в sitemap попадают `/`, `/list-tools`,
`/chains`, но не `/kit`, `/pipeline` и страницы инструментов (пока S1h).
Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME` (тот же домен, что
публикует GitHub Pages, без третьей копии литерала); опциональный
`BASE_PATH` учитывается как в `vite.config.ts`.
- [x] **[S1c]** `robots.txt` — **сделано 2026-10-12**: `Sitemap:` добавлен,
allow-all сохранён. **Служебный `/kit` закрыт не через `Disallow:`, а
через `noindex` в HTML** (как `/pipeline`): заблокированный краулером, но
не помеченный noindex URL Google помечает «проиндексирован, несмотря на
блокировку в robots.txt» и оставляет старый сниппет. Побочная выгода: тот
же признак автоматически выкидывает `/kit` из sitemap.
- [x] **[S1d]** Компонент `Seo.svelte` (kit) — **был сделан до S1b, отмечено
2026-10-12**: canonical (абсолютный URL от `PUBLIC_SITE_ORIGIN` +
`SITE_URL`, `web/src/lib/seo.ts`), OG (title/description/type/url/image) и
`twitter:card`. Подключён на `/`, `/list-tools`, `/chains`,
`/tools/[slug]`. Картинка пока общая `og/default.png` — per-tool остаётся
за S1e.
> **Порядок 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]** Первая волна контента — топ приоритетных инструментов ядра
(приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5.
- Гейты: на каждой странице инструмента — уникальный текст ≥ 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]** Довести GA4 до решения §0: cookieless-конфигурация, отключённые
рекламные audiences и signals, `anonymize_ip`, запрет на передачу
параметров с данными картинки. Мусорные попадания уже отсечены: тег
подключается только при `PUBLIC_GA_ENABLED=true` (выставлен в
deploy-workflow, `+layout.svelte`). 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). Слаги
ниже — **фактические `slug` страниц** из `docs/tools-map.md` §1; первая волна —
топ-интенты, где конкуренты ранжируются, а у нас уже есть инструмент и он не
хуже:
- прозрачность/фон: create-empty-png, remove-background-png, change-png-opacity,
set-alpha-channel-png, round-corners-png
- конвертация: convert-png-to-jpg, convert-png-to-webp, png-to-bmp, svg-to-png,
png-to-base64
- геометрия: crop-png, resize-png, rotate-png, flip-png, change-aspect-ratio-png
- цвет/фильтры: grayscale-png, invert-colors-png, pixelate-png
- текст: add-text-png, watermark-tile-png
- генерация: linear-gradient-png
> Группы здесь — по категории страницы в реестре (`registry/pages/`), а не по
> смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и
> `watermark-tile-png` — в `text`.
>
> **Открытый вопрос по интенту «сжать до N КБ».** Лимит размера теперь живёт в
> кнопке Download (`fitWithinBytes` в `web/src/lib/core/io.ts`); интенты вида
> «compress png to 500kb / 1mb» — high-volume и лимит-ориентированные, селектор
> в Download их не закрывает как посадочная. Решение (отдельная страница / приём
> в контенте существующих / ничего) требует вордстата; имя страницы не выбрано.
> Прорабатывать вместе с вопросом «из формата в 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. Переименование бренда и домен
Бренд — `easy-image-tools`, домен — `easy-image-tools.com`.
- [x] **[R1]** Переезд бренда выполнен (2026-10-11): хардкод в компонентах
убран, бренд — i18n-константа `t("brand")` (точка истины — словари
`brand` + README). Репозиторий переименован в `easy-image-tools`, remote
обновлён, привязанные к репо ссылки/пути переключены: `BASE_PATH` в
`deploy.yml`, GitHub-URL в `TopBar.svelte`, `repository` в `cog.toml`.
**НЕ трогать:** адреса страниц инструментов (`/tools/crop-png`),
localStorage-ключи (бренда не содержат), историю `docs/archive`.
- [x] **[R2]** Домен `easy-image-tools.com` зарегистрирован (2026-10-11);
`SITE_URL` в `web/src/lib/seo.ts` переключён на него.
- [x] **[R2-cut]** Прод переведён на домен без `BASE_PATH` (§0) — **сделано
2026-10-11**: DNS + HTTPS, `PUBLIC_SITE_ORIGIN` =
`https://easy-image-tools.com` в `deploy.yml`, `web/static/CNAME`. 301 со
старого GitHub Pages делает сам GitHub при включённом кастомном домене.
Canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
- **Порядок:** R1(код), покупка домена и cutover (R2-cut) сделаны. Осталось
S1-domain (canonical + sitemap: S1b/S1c/S1d), а S1-domain — блокер для S1h
(снятие `noindex`). Переименование делали до индексации (до S1h): после снятия
`noindex` это был бы переезд с 301 и потеря позиций — не делать.
## 7. Что осознанно НЕ делаем
- Клонирование всего каталога конкурента (низкокачественные дубли + нет ресурсов
на контент) — вместо этого ядро + обоснованные хвосты.
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
- Реклама, ремаркетинг, pixels сторонних сетей — противоречит позиционированию.
Счётчик посещений GA4 — не противоречит, условия ограничений — §0 и S4b.
- Дорвеи, программные зеркала, покупные ссылки.
- Массово-сгенерированные форматные клоны (`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 (общее ядро, оффлайн-режим).
-123
View File
@@ -1,123 +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` может завершиться успешно, напечатав unused-token сообщения: это
единственный неблокирующий сигнал в нём. ESLint warnings, наоборот, роняют gate,
поэтому «прошёл» ≠ «нечего чинить». Агент показывает число 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.
-188
View File
@@ -1,188 +0,0 @@
# План развития: easy-image-tools
> **Статус: active.** Фаза 1 выполнена — сайт живёт на типизированном реестре,
> production UI — корневые маршруты. Текущий фокус: SEO S1-free
> (`docs/plan-seo.md`) + открытые задачи (`docs/backlog.md`). Домен
> `easy-image-tools.com` зарегистрирован; остался cutover для индексации
> (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`): файл картинки не покидает машину. Счётчик посещений GA4
этому не противоречит — границы `docs/plan-seo.md`, §0.
- **Первый и главный приоритет — полностью рабочий сайт**: максимум простых
инструментов, UX/UI, пайплайны (несколько инструментов подряд), описания
инструментов.
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` —
эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки
Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
- **Пайплайн = JSON-список шагов** `{id, params}` — формат шага совпадает с
полем `Page.steps`. Формат зафиксирован этим решением и станет общим для
браузера, 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.
-145
View File
@@ -1,145 +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 | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
| `webkit` | WebKit | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
| `mobile-chromium` | Chromium | `navigation`, `catalog`, `generators`, `tool-flows`, 390×844, `hasTouch` |
- Название `tool-flows.spec.ts` описывает содержание: сквозной поток одного
инструмента (загрузка → правка параметра → результат → download) на наборе
представительных страниц. Пайплайна в коде нет (`docs/architecture.md`, раздел
1), поэтому спеку нельзя называть `pipeline` — при её переименовании поправить
`browserContractTests` и `mobileTests` в `web/playwright.config.ts` и
`allowDefaultProject` в `web/eslint.config.js`.
- Рядом живёт `tools-smoke.spec.ts` — дешёвый проход по выборке страниц («выдал
результат без ошибок»): по одной-двум на категорию, а не по каждому
инструменту. Остальные страницы закрыты точечными спеками (`generators`,
`text-and-verdicts`, `tool-flows`). `tool-flows` отвечает за более глубокие
проверки, `tools-smoke` — за дешёвый прогон подряд; дублировать их цели не
стоит.
- Новые 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.
-191
View File
@@ -1,191 +0,0 @@
# Аудит использования - UX\UI
> Статус: **active**. **Дата прохода: 2026-09-13** (инструменты и поля). Здесь
> только «что плохо» — наблюдения, без порядка работ и без критериев приёмки.
> Новый проход — новая дата сверху; закрытая находка не удаляется, а помечается
> ссылкой на задачу в `docs/backlog.md` или на уже сделанное в коде.
>
> **Слаги сверены с реестром** на дату прохода (`docs/tools-map.md` §1).
> Отдельно: `webp-to-png` и `color-wheel-generator` в коде отсутствуют (реальная
> страница — `color-wheel-png`), а `png-info` — ещё не заведённая страница
> (`docs/backlog.md`, «Идеи»). Такие пункты переименовываются по `PAGES`, а не
> resurrect'ятся. Перед работой по любому пункту — найти соответствующую
> страницу в `web/src/lib/registry/pages/` или `docs/tools-map.md`; ищи по
> смыслу: категория в заголовке раздела + ключевое слово в пункте.
>
> **Заголовки разделов ниже — это id категорий из `web/src/lib/categories.ts`**,
> по ним и ищи страницу в `registry/pages/<group>.ts`. Раздела `convert` в
> аудите нет: конвертация проверялась вместе с остальными инструментами,
> отдельного прохода по ней не было.
>
> Как исправлять — `docs/backlog.md` (задачи) и
> `docs/plan-fields-audit-fixes.md` (порядок правок полей). Находка в аудите и
> задача в бэклоге — не дубль: пока задачи нет, находка просто висит.
## везде
- возможность переключать пиксели\проценты
- инструменты без параметров выглядят странно, пересмотреть, может добавить
какую-то надпись "нет параметров"
- удаление\маски и т.д. - надо добавить галочку сглаживание
- производительность: почему flip-png заметно тормозит, но нет индикации работы
инструмента (также sharpen-png)
- производительность: исследовать, есть ли смысл на инструментах геометрии
выводить рамку результата до полноценной работы (например на skew \ rotate
будет удобнее)
## аудит инструментов
### alpha
(в UI раздел называется «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: проверить, как должен работать, картинки с большой
полупрозрачной областью - закрывает всё, по описанию должен закрывать только
небольшие дырки
### color
- 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 — сделано, дефолт берётся из source
(`defaultFromSource`), до загрузки 1×1. Выбор метода масштабирования — поля в
схеме нет вовсе, это не правка селекта, а новое поле
(`docs/plan-fields-audit-fixes.md` §7.1)
- 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?
### generate
(в UI раздел называется «Generate» / «Генерация»)
- 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: сделано — отдельные числовые поля X и Y в
`fields/schema/OffsetControl.svelte` (не range slider); задача на px/% вместо
них открыта, `docs/plan-fields-audit-fixes.md` §7.1
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
- поле seed (random) - range slider не нужен, поле для ввода и "randomize"
кнопка
- поле текста: нормальная высота по умолчанию (не одна строка), для svg —
форматирование или подсветка синтаксиса, для bin\hex — колонки, для
hex\bytes\rgba — пример в placeholder
-260
View File
@@ -1,260 +0,0 @@
# Карта инструментов: реализовано · добавить · идеи
> Статус: **active**. Навигационный список: что уже есть по адресам
> `/tools/<slug>`, что можно добавить, что отложено. Источник правды — реестр
> (`web/src/lib/registry/`), модель страницы и инструмента —
> `docs/architecture.md`, раздел 3; сверяться с реестром перед использованием.
> Количество инструментов здесь не фиксируется — оно выводится из раздела 1 и из
> `PAGES` (`AGENTS.md`).
>
> **Как читать строку.** `slug` — адрес страницы и имя скачиваемого файла;
> страница вызывает инструмент через `steps[0].id`, и у `id` нет png-интента
> (`crop-png` → `id: "crop"`, `png-to-hsl` → `id: "to-hsl"`). Имена полей — это
> id полей из `schema.fields` инструмента; составные поля (`size`, `pair`,
> `offset`, `style`, `plate`, `gradient`) в скобках раскрыты, но не выдуманы как
> отдельные поля. Сравнение с каталогом источника (объём, нишевые клоны серий
> Logo\*/Icon\*/Stamp\*) — замер в `docs/plan-seo.md` §2; здесь он не
> дублируется.
>
> **Формат строки (проверяется `web/src/lib/registry/tools-map-doc.test.ts`):**
> всё **до первого тире** `—` — это slug страниц через `/` или `,`; всё после
> тире — свободное описание параметров и не разбирается. Второй slug нельзя
> оставлять после тире, иначе он не попадёт в разбор и страница будет считаться
> неописанной.
---
## 1. Реализовано
Slugs приведены к фактическим в `web/src/lib/registry/pages/`. Разделы идут по
файлу реестра; поля — по схеме инструмента, который страница вызывает.
### Конвертация (`convert`)
Отдельных «из формата в PNG» инструментов **нет**: входной формат выбирается
загрузкой файла, любой инструмент принимает PNG/JPG/WebP/GIF/BMP. Все конвертеры
исходят из PNG.
- `convert-png-to-jpg` — background, quality
- `convert-png-to-webp` — quality
- `png-to-bmp` — без параметров (24-бит, фон вместо альфы)
- `svg-to-png` — width (ширина результата; вход — SVG-код)
- `png-to-base64`, `base64-to-png` — без параметров (строка)
- `png-to-data-uri`, `data-uri-to-png` — без параметров (строка)
- `png-to-hex`, `hex-to-png` — вход `width`; rrggbbaa по строкам / tokens
- `png-to-bytes`, `bytes-to-png` — вход `width`; десятичные RGBA-байты по
строкам / tokens
- `png-to-rgb-values`, `rgb-values-to-png` — вход `width`; rgba(r,g,b,a) по
пикселям / числа
### Прозрачность (`alpha`)
- `add-stroke-png` — color, thickness
- `find-contour-png` — color, thickness
- `remove-color-from-png` — targetColor, tolerance
- `remove-background-png` — color, tolerance, outerOnly, smooth
- `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
- `round-corners-png` — radius (% от половины меньшей стороны)
- `invert-alpha-png` — без параметров
- `extract-alpha-mask-png` — без параметров
- `set-alpha-channel-png` — percent
- `remove-alpha-channel-png` — без параметров (фон `#ffffff` захардкожен,
`docs/plan-fields-audit-fixes.md` FA-D1)
- `circle-mask-png` — size (диаметр, % меньшей стороны), offset (x/y)
- `square-mask-png` — widthPct, heightPct, offset (x/y)
- `star-mask-png` — points, innerRadius, size, rotation, offset (x/y)
- `wavy-mask-png` — size, amplitude, waves, phase, offset (x/y)
### Цвет (`color`)
- `grayscale-png`, `invert-colors-png`, `sepia-png`, `auto-contrast-png` — без
параметров
- `adjust-brightness-contrast-png` — brightness, contrast
- `change-png-opacity` — percent
- `change-png-hue` — degrees
- `temperature-png` — percent
- `gamma-png` — value
- `tint-png` — color, strength
- `two-colors-png` — pair (светлый/тёмный), threshold
- `black-and-white-png` — threshold
- `posterize-png` — levels
- `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)
- `extract-channel-png` — channel (r/g/b)
- `swap-channels-png` — pair (r-g/r-b/g-b)
### Разложение каналов (`color`)
- `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`)
- `resize-png` — size (по умолчанию размер source, 0 по одной стороне = авто),
keepAspect
- `crop-png` — x, y, size (по умолчанию вся область source)
- `rotate-png` — angle (90/180/270)
- `rotate-free-png` — angle
- `flip-png` — axis (h/v)
- `skew-png` — degX, degY
- `zoom-png` — scale
- `shift-png` — offsetX, offsetY, color
- `add-padding-png` — padding, transparent, color
- `add-border-png` — thickness, color
- `fit-on-background-png` — size, transparent, color
- `tile-png` — columns, rows
- `split-into-parts-png` — columns, rows (мультифайловый вывод, zip)
- `trim-empty-space-png` — threshold (по альфе)
- `change-canvas-size-png` — size, anchor (3×3)
- `change-aspect-ratio-png` — ratio (пресеты), mode (crop/pad)
- `swap-orientation-png` — target (portrait/landscape)
- `symmetric-copy-png` — axis, keepSide
- `center-by-alpha-png` — без параметров
### Фильтры (`filters`)
- `blur-png` — radius
- `sharpen-png` — strength
- `vignette-png` — strength
- `jpeg-artifacts-png` — quality (имитация пережатия jpg/webp; поле
функциональное, у инструмента нет `output` — `docs/plan-seo.md` S1f)
- `pixelate-png` — blockSize
- `randomize-pixels-png` — blockSize, seed
- `add-noise-png` — amount, mode (mono/color), seed
- `silhouette-png` — color, threshold
### Анализ и вердикты (`analyze`)
- `png-file-size` — без параметров (DOM-инструмент: кодирует в PNG и отдаёт
размер)
- `png-is-transparent`, `png-is-grayscale`, `png-orientation` — без параметров,
текстовый вердикт
- `verify-is-png` — без параметров (вход — текст: base64/data-uri), вердикт по
сигнатуре
### Маски по свойствам пикселей (`analyze`)
- `show-transparent-png` — mode, color, opacity
- `show-grayscale-pixels-png` — mode, tolerance, color, opacity
- `show-color-pixels-png` — mode, tolerance, color, opacity
- `light-pixel-mask-png` — mode, threshold, color, opacity
- `dark-pixel-mask-png` — mode, threshold, color, opacity
- `unique-color-mask-png` — mode, rarity (макс. повторов), color, opacity
- `extract-color-from-png` — color, tolerance
### Генерация (`generate`)
- `create-empty-png` — size, transparent, color
- `single-color-png` — size, color
- `random-noise-png` — size, seed
- `linear-gradient-png` — size, gradient (от/к, направление)
- `color-spectrum-png` — size, direction, saturation, lightness
- `random-colors-png` — size, blockSize, seed
- `draw-grid-png` — size, cols, rows, lineWidth, color, transparentBg
- `placeholder-png` — size, backgroundColor, color, showText
- `blend-two-png` — pair (от/к), width
- `step-colors-png` — pair (от/к), steps, width, layout (grid/strip)
- `color-wheel-png` — size, lightness
- `mix-colors-png` — colors, width
- `sort-colors-png` — colors, order (hue/brightness/saturation), width, layout
- `emoji-to-png` — emoji, size
- `text-to-png` — text, style (шрифт/размер/цвет/жирность), transparentBg,
backgroundColor, padding
- `complementary-png`, `triadic-png`, `tetradic-png`, `analogous-png`,
`monochromatic-png`, `shades-png` — baseColor, width, layout; у `analogous`
ещё spread и count, у `monochromatic` — count и range, у `shades` — count и
depth
### Текст (`text`)
- `add-text-png` — text, style (шрифт/размер/цвет/жирность), position (3×3),
margin, plate (плашка, её цвет и прозрачность)
- `date-stamp-png` — format, style, position, margin, plate
- `watermark-tile-png` — text, style, opacity, angle, stepX, stepY
---
## 2. Можно добавить — из onlinepngtools
Всё из этого списка **в реестре отсутствует** — это план, а не факт. Список
проверяется `web/src/lib/registry/tools-map-doc.test.ts` в обратную сторону:
реализованный slug, оставшийся здесь, роняет тест.
### Разложение каналов — остаток
- 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`. Сжатие до целевого размера — прежде всего
селектор формата в Download (S1f, `docs/plan-seo.md` §4): для lossy это
quality-ручка, а для PNG — квантование палитры. Нужен ли отдельный инструмент
под этот интент, решается по вордстату, имя страницы пока не выбрано. Пипетка
для выбора цвета уже есть в превью, отдельная страница не планируется.
Извлечение штрихкодов — HARD, вне планов.
---
## 3. Идеи на рассмотрение (нужна архитектура или спорная ценность)
Развёрнутые описания этих идей живут в `docs/backlog.md`, раздел «Идеи»; здесь
только список.
Буллиты этого раздела, как и раздела 2, перечисляют **несуществующие** страницы:
уже реализованные в них не называются, иначе тест решит, что slug пора
переносить в раздел 1. Реализованные примеры пишутся прозой между буллитами, как
ниже.
- **Region-инструменты** — нужен selection-компонент на превью: censor-region,
erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area.
Один раз делаем selection — получаем шесть инструментов.
- **Мультифайловый выход** — остальные потребители механизма «результат = набор
файлов»: gif-to-frames, separate-colors, multiply-grid-as-files — подключаются
по мере нужды.
Механизм уже реализован на `split-into-parts-png` (`docs/architecture.md`,
раздел 7), он в разделе 1.
- **Анимационные эффекты** — 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-последствия описаны в `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-image-tools",
"version": "0.9.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: {}
-16
View File
@@ -1,16 +0,0 @@
import { readFileSync, writeFileSync } from "node:fs";
const version = process.argv[2];
if (!version) {
console.error("usage: node scripts/sync-version.mjs <version>");
process.exit(1);
}
for (const file of ["package.json", "web/package.json"]) {
const raw = readFileSync(file, "utf8");
const manifest = JSON.parse(raw);
manifest.version = version;
const indent = raw.match(/(?<=\n)([^\S\n]+)"/)?.[1] ?? 2;
const eol = raw.endsWith("\n") ? "\n" : "";
writeFileSync(file, JSON.stringify(manifest, null, indent) + eol);
}
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`);
}
/** Builds a valid RGBA PNG at runtime (8-bit, filter 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}`;
/** Input row for bytes-to-png / rgb-values-to-png, 32 pixels per line by default. */
export function pixelRow(
count: number,
rgba: [number, number, number, number],
): string {
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
}
-8
View File
@@ -1,8 +0,0 @@
import { ru } from "../../src/lib/i18n/ru";
/** RU page title from the dictionary; RU text is never hardcoded in specs. */
export function ruPageTitle(slug: string): string {
const title = ru.pages?.[slug]?.title;
if (!title) throw new Error(`ru dictionary has no pages.${slug}.title`);
return title;
}
-160
View File
@@ -1,160 +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> {
// The page can have two input[type=file]: dropzone + Open image button;
// the canonical one is the first (the button in the panel header).
const input = page.locator('input[type="file"]').first();
await expect(input).toBeAttached();
await input.setInputFiles({
name: file.name,
mimeType: file.mimeType,
buffer: file.buffer,
});
}
export async function uploadViaDrop(
page: Page,
file: SourceFile,
): Promise<void> {
const dataTransfer = await page.evaluateHandle(
({
bytes,
name,
mimeType,
}: {
bytes: number[];
name: string;
mimeType: string;
}) => {
const dt = new DataTransfer();
dt.items.add(new File([new Uint8Array(bytes)], name, { type: mimeType }));
return dt;
},
{
bytes: Array.from(file.buffer),
name: file.name,
mimeType: file.mimeType,
},
);
await page
.getByRole("button", { name: /drop a png/i })
.dispatchEvent("drop", { dataTransfer });
}
export async function downloadResultFile(page: Page): Promise<Download> {
// Split button: the main part is "Download" (image) or "Download ZIP" (files).
const button = page.getByRole("button", { name: /^Download( ZIP)?$/ });
await expect(button).toBeVisible();
const [download] = await Promise.all([
page.waitForEvent("download"),
button.click(),
]);
return download;
}
export async function chooseDownloadFormat(
page: Page,
format: string,
): Promise<void> {
await page.getByRole("button", { name: "Choose download format" }).click();
await page.getByRole("menuitemradio", { name: format, exact: true }).click();
}
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, "no console/page errors").toEqual([]);
}
-54
View File
@@ -1,54 +0,0 @@
import { expect, test } from "playwright/test";
import { pixelRow } from "./helpers/fixtures";
import { ruPageTitle } from "./helpers/i18n";
import { openTool, toolLink, useEnglish } from "./helpers/page";
const flipTitleRu = ruPageTitle("flip-png");
test("catalog search finds a tool by a title from another locale", async ({
page,
}) => {
await useEnglish(page);
await page.goto("/list-tools");
const input = page.getByRole("textbox");
const flipCard = toolLink(page, "flip-png");
await input.fill(flipTitleRu.split(" ")[0].toLowerCase());
await expect(flipCard).toContainText("Flip PNG");
await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(flipCard).toContainText(flipTitleRu);
await input.fill("flip");
await expect(flipCard).toContainText(flipTitleRu);
});
test("RU/EN: switching the locale translates the tool heading", 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(ruPageTitle("resize-png"));
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
await enBtn.click();
await expect(heading).toHaveText("Resize PNG");
});
test("run error is localized and translated on language switch", 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",
);
});
-84
View File
@@ -1,84 +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: [] },
];
// On these files createImageBitmap in webkit never settles: no result and no
// error, so the "accept or reject" contract cannot be verified.
const webkitHangs = new Set([
"truncated-header.png",
"crc-bad-ancillary.png",
"palette-256.png",
]);
test.describe("PNG special fixtures", () => {
for (const { file, rejectedIn } of decodeContracts) {
test(`${file.name}: matches the engine decode contract`, async ({
browserName,
page,
}) => {
test.fixme(
browserName === "webkit" && webkitHangs.has(file.name),
"webkit createImageBitmap hangs on this file",
);
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);
});
-331
View File
@@ -1,331 +0,0 @@
import { unzipSync } from "fflate";
import { expect, test } from "playwright/test";
import {
corruptPng,
landscapePng,
largePng,
onePixelPng,
opaquePng,
transparentPng,
} from "./helpers/fixtures";
import {
chooseDownloadFormat,
downloadResultBytes,
errorAlert,
expectNoErrorAlert,
expectNoErrors,
openTool,
rangeInput,
resultImage,
sourceImage,
trackErrors,
uploadImage,
uploadViaDrop,
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("resize-png keeps user-set size when another image is uploaded", async ({
page,
}) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
const width = page.getByRole("spinbutton", { name: "Width" });
await width.fill("25");
// keepAspect is on by default: height is recomputed from 64×48 -> 19
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"19",
);
await uploadImage(page, landscapePng);
await expect(width).toHaveValue("25");
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"19",
);
});
test("resize-png applies size preset chips", async ({ page }) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
await page.getByRole("button", { name: "32×32" }).click();
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"32",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"32",
);
});
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: upload via drag-and-drop on the dropzone", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadViaDrop(page, opaquePng);
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("download format selector: JPG warns about alpha and produces JPEG", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, transparentPng);
await expect(resultImage(page)).toBeVisible();
await chooseDownloadFormat(page, "JPG");
await expect(
page.getByRole("status").filter({ hasText: /transparency/ }),
).toBeVisible();
// JPG has a quality setting in the dropdown
await rangeInput(page, "Quality").fill("10");
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("flip-png.jpg");
expect(Array.from(bytes.subarray(0, 3))).toEqual([0xff, 0xd8, 0xff]);
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", exact: true }).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");
// Entries are prefixed with the source name (run-chain glueName), so the
// base is derived from the fixture instead of repeated as a literal.
const base = landscapePng.name.replace(/\.[^.]+$/, "");
await expect(
page.getByText(`${base}-part-1-3.png`, { exact: true }),
).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("split-into-parts-png.zip");
const entries = unzipSync(bytes);
expect(Object.keys(entries)).toEqual([
`${base}-part-1-1.png`,
`${base}-part-1-2.png`,
`${base}-part-1-3.png`,
`${base}-part-2-1.png`,
`${base}-part-2-2.png`,
`${base}-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 }, testInfo) => {
// newContext() does not inherit use.baseURL from the config - pass it
// explicitly, otherwise the relative goto() in helpers does not resolve.
const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
});
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("chain: add step extends pipeline without reload and persists", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
await page.getByRole("button", { name: "Add step" }).last().click();
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
const stepTwo = page.getByRole("heading", {
name: "Grayscale PNG",
exact: true,
});
await expect(stepTwo).toBeVisible();
await expect(resultImage(page)).toBeVisible();
// the chain survives a reload (localStorage)
await page.reload();
await expect(stepTwo).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("chain: remove step returns to a single tool", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await page.getByRole("button", { name: "Add step" }).last().click();
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
const stepTwo = page.getByRole("heading", {
name: "Grayscale PNG",
exact: true,
});
await expect(stepTwo).toBeVisible();
// Click remove in the Grayscale card specifically: step order depends on
// render timing and can differ between engines. The card is matched by its
// own heading, not by hasText on the article - the per-step "Change tool"
// popover lists every tool title inside the card, so the text filter would
// match all cards at once.
const card = page.getByRole("article").filter({ has: stepTwo });
await card.getByLabel("Remove step").click();
await expect(stepTwo).toHaveCount(0);
expectNoErrors(sink);
});
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);
});
}
-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: tolerates the hydration race (input before SvelteKit
// hydration may not be processed on the first attempt).
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);
});
}
});
-7
View File
@@ -1,7 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["./**/*.ts"]
}
-237
View File
@@ -1,237 +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`.
## Плагин conventions: текстовые конвенции
- `conventions/ascii-only` — в файлах, заданных scope'ом конфига (`*.test.ts`,
`*.spec.ts`, `e2e/**`), только ASCII: типографика и неанглийский текст в
`describe`/`it` и ожиданиях мешают поиску и скрывают хардкод локализованных
строк. Осознанные глифы (например `×` в пайплайне) — через опцию `allowChars`
в `eslint.config.js`. Один репорт на строку.
- `conventions/comments-english` — комментарии только на английском, по **белому
списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности
(кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы —
через опцию `allowChars`. Строковые литералы не трогаем — локализованные
значения легальны в словарях. В `.svelte` проверяются оба вида комментариев:
`<script>` и HTML-комментарии шаблона `<!-- -->` (последние — узлы AST, а не
comment-токены, поэтому обходятся отдельным визитором).
- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по
умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15)
— доля комментарных строк от непустых строк файла; density проверяется только
в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в
маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу
кода, а не к сжатию комментария. В `.svelte` считаются все три вида
комментариев (и в строках блока, и в плотности): `<script>`, HTML-комментарии
шаблона `<!-- -->` — отдельным визитором, как в
`conventions/comments-english`, и CSS в `<style>`, который парсится в
postcss-AST и берётся хелпером `design-tokens/style-context.js`. Комментарии
разных видов не склеиваются в один блок: соседние строки продолжают блок
только внутри `<script>`.
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
`*.config.*` живут по тем же конвенциям (dogfooding). Фикстуры и тесты правил
(`__fixtures__/`, `__tests__/`) — намеренные входные кейсы и из scope исключены.
Все три правила — `error`, baseline нулевой. Русские ожидания в тестах не
хардкодятся: значения берутся из словарей `lib/i18n/` (см.
`src/lib/i18n/*.test.ts` и `e2e/helpers/i18n.ts`). Тесты правил:
`__tests__/ascii-only.test.ts`, `__tests__/comments-english.test.ts`,
`__tests__/comment-format.test.ts`.
## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--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/decisions.md`, раздел 6 (как оно
выглядит в гейте — `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__/interface-props.test.ts` и
`__tests__/no-string-union-alias.test.ts` — конвенции плагина `conventions`
(интерфейс пропсов, запрет union-алиасов).
- Хелпер `__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,79 +0,0 @@
// Tests for the conventions/ascii-only rule
// (web/eslint-plugins/conventions/ascii-only.js).
//
// Pure file-content check, no filesystem: standard RuleTester with the
// tseslint parser. The scoping to test files lives in eslint.config.js, so
// filenames here are arbitrary.
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import asciiOnly from "../conventions/ascii-only.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
tester.run("ascii-only", asRuleModule(asciiOnly), {
valid: [
{ code: "const x = 1;", filename: "a.test.ts" },
{
code: "describe('resize pipeline', () => {});",
filename: "a.test.ts",
},
{
// Glyphs listed in allowChars pass.
code: "expect(label).toBe('512×512');",
filename: "a.test.ts",
options: [{ allowChars: ["×"] }],
},
],
invalid: [
{
code: "it('ресайзит картинку', () => {});",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "// arrow → here\nconst x = 1;",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Em dash and ellipsis on one line collapse to a single report.
code: "it('a — b … c', () => {});",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Two offending lines produce two reports.
code: "// über\n// café\nconst x = 1;",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
},
{
// allowChars does not whitelist other glyphs on the same line.
code: "expect(s).toBe('512×512 → done');",
filename: "a.test.ts",
options: [{ allowChars: ["×"] }],
errors: [{ messageId: "nonAscii" }],
},
{
// Control characters (here: vertical tab) are not printable ASCII.
code: "const s = '\x0B';",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// DEL (U+007F) is not printable either.
code: "const s = '\x7F';",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
],
});
@@ -1,207 +0,0 @@
// Tests for the conventions/comment-format rule
// (web/eslint-plugins/conventions/comment-format.js).
//
// Pure AST check (comment nodes + line counts), standard RuleTester. The
// svelte pass pins the two comment kinds the token stream never sees: template
// `<!-- -->` comments (AST nodes) and CSS in `<style>` (a postcss AST); both
// need a svelte parser to exist at all.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import commentFormat from "../conventions/comment-format.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
const svelteTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const longBlock = [
"// one",
"// two",
"// three",
"// four",
"// five",
"// six",
"const x = 1;",
].join("\n");
tester.run("comment-format", asRuleModule(commentFormat), {
// Density is off (maxRatio: 1) in the block-focused cases: tiny snippets
// are comment-dominated by construction.
valid: [
{
code: "// why\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
code: "// a\n// b\n// c\n// d\n// e\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// Blank line breaks the run: two 3-line blocks, both under the cap.
code: "// a\n// b\n// c\n\n// d\n// e\n// f\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// Trailing inline comment does not extend the block above it.
code: "// a\n// b\nconst x = 1; // tail",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// A wider block cap admits the same comment.
code: longBlock,
filename: "a.ts",
options: [{ maxBlockLines: 6, maxRatio: 1 }],
},
{
// Density is skipped below minLines: small files are exempt.
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.4, minLines: 10 }],
},
],
invalid: [
{
code: longBlock,
filename: "a.ts",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// A single block comment counts its full line span.
code: "/*\n1\n2\n3\n4\n5\n6\n*/\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// Density: 2 comment lines against 2 code lines is 50%.
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.4 }],
errors: [{ messageId: "tooDense" }],
},
{
// Both violations in one file produce two reports.
code: longBlock + "\n// extra",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.5 }],
errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }],
},
],
});
svelteTester.run("comment-format (svelte)", asRuleModule(commentFormat), {
valid: [
{
code: "<!-- why -->\n<main>Hello</main>",
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
},
{
// A template comment at the cap is fine, same as in a .ts file.
code: "<!--\na\nb\nc\n-->\n<main>Hello</main>",
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
},
{
// Script and template comments are separate blocks, not one run.
code: '<!-- why -->\n<main>Hello</main>\n<script lang="ts">\n// why\n</script>',
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
},
{
// A CSS comment at the cap is fine, same as a script comment.
code: "<style>\n/*\na\nb\nc\n*/\n.x {\n\tcolor: red;\n}\n</style>",
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
},
{
// Script and <style> comments live in different ASTs: no run across,
// so 2 + 5 lines pass where a merged block of 7 would not.
code: '<script lang="ts">\n// one\n// two\n</script>\n<style>\n/*\na\nb\nc\n*/\n.x {\n\tcolor: red;\n}\n</style>',
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
},
],
invalid: [
{
// The template comment the ts pass could never see, over the cap.
code: "<!--\na\nb\nc\nd\n-->\n<main>Hello</main>",
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// <style> is a postcss AST, so its comments are invisible to
// getAllComments(); the loc must still point at the real line.
code: "<style>\n/*\na\nb\nc\nd\n*/\n.x {\n\tcolor: red;\n}\n</style>",
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong", line: 2 }],
},
{
// A comment nested in markup is collected too (not Program.body).
code: "<div>\n\t<!--\n\ta\n\tb\n\tc\n\td\n\t-->\n</div>",
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// Template comment lines count toward density: 2 of 4 non-blank.
code: "<!-- a -->\n<!-- b -->\n<p>x</p>\n<p>y</p>",
filename: "Component.svelte",
options: [{ maxBlockLines: 5, maxRatio: 0.4 }],
errors: [{ messageId: "tooDense" }],
},
{
// Script and template comments share one density budget.
code: '<!-- a -->\n<script lang="ts">\n// b\n</script>\n<p>c</p>',
filename: "Component.svelte",
options: [{ maxBlockLines: 5, maxRatio: 0.3 }],
errors: [{ messageId: "tooDense" }],
},
{
// CSS lines count toward density too: 2 of 8 non-blank. Consecutive
// CSS comments stay separate blocks (each one line, under the cap).
code: "<p>x</p>\n<style>\n/* a */\n/* b */\n.x {\n\tcolor: red;\n}\n</style>",
filename: "Component.svelte",
options: [{ maxBlockLines: 5, maxRatio: 0.2 }],
errors: [{ messageId: "tooDense" }],
},
{
// All three kinds over the cap: one report per block, by position.
code: "<!--\na\nb\nc\nd\ne\n-->\n<style>\n/*\nx\ny\nz\nw\nv\n*/\n.x {\n\tcolor: red;\n}\n</style>",
filename: "Component.svelte",
options: [{ maxRatio: 1 }],
errors: [
{ messageId: "blockTooLong", line: 1 },
{ messageId: "blockTooLong", line: 9 },
],
},
{
// Both violations in one file produce two reports.
code: "<!--\na\nb\nc\nd\n-->\n<p>x</p>",
filename: "Component.svelte",
options: [{ maxBlockLines: 5, maxRatio: 0.5 }],
errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }],
},
],
});
@@ -1,179 +0,0 @@
// Tests for the conventions/comments-english rule
// (web/eslint-plugins/conventions/comments-english.js).
//
// Pure AST check over comment nodes with an ASCII whitelist; string literals
// are ignored by construction (they are not comments), which the cases below
// pin down. .svelte gets its own pass: template `<!-- -->` comments are AST
// nodes (SvelteHTMLComment), not comment tokens, so they need a svelte parser.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import commentsEnglish from "../conventions/comments-english.js";
import { asRuleModule, svelteComponent } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
const svelteTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
tester.run("comments-english", asRuleModule(commentsEnglish), {
valid: [
{ code: "// resize the canvas\nconst x = 1;", filename: "a.ts" },
{ code: "/* multi\nline */\nconst x = 1;", filename: "a.ts" },
{
// Localized strings are dictionary content, not comments.
code: "const title = 'Пример';",
filename: "a.ts",
},
{
// Glyphs listed in allowChars pass.
code: "// size 512×512\nconst x = 1;",
filename: "a.ts",
options: [{ allowChars: ["×"] }],
},
{
// A tab inside a comment is legitimate whitespace.
code: "//\tnote\nconst x = 1;",
filename: "a.ts",
},
],
invalid: [
{
code: "// ресайз холста\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Whitelist catches scripts beyond Cyrillic too.
code: "// 调整画布大小\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "// تغيير حجم اللوحة\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Typography in an otherwise English comment is still non-ASCII.
code: "// resize — then crop\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "/* много\nстрочный */\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "const x = 1; // хвостовой комментарий",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// One report per offending comment, not per file.
code: "// раз\n// два\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
},
{
// allowChars does not whitelist other glyphs in the same comment.
code: "// 512×512 → done\nconst x = 1;",
filename: "a.ts",
options: [{ allowChars: ["×"] }],
errors: [{ messageId: "nonAscii" }],
},
{
// Control characters (here: vertical tab) are not printable ASCII.
code: "// \x0B note\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
],
});
// Svelte components: both comment kinds are in scope. `<script>` comments
// come in as ordinary comment tokens; template `<!-- -->` comments only exist
// as SvelteHTMLComment nodes, which is the case the ts pass cannot reach.
svelteTester.run("comments-english (svelte)", asRuleModule(commentsEnglish), {
valid: [
{
code: "<!-- resize the canvas -->\n<main>Hello</main>",
filename: "Component.svelte",
},
{
code: svelteComponent("// resize the canvas"),
filename: "Component.svelte",
},
{
code: '<!-- svelte-ignore a11y_missing_attribute -->\n<main lang="en">Hello</main>',
filename: "Component.svelte",
},
{
// allowChars applies to template comments too.
code: "<!-- size 512×512 -->\n<main>Hello</main>",
filename: "Component.svelte",
options: [{ allowChars: ["×"] }],
},
{
// CSS comments in <style> are checked as well, ASCII passes.
code: '<div class="x">hi</div>\n<style>\n/* keep the row height stable */\n.x {\n\tcolor: red;\n}\n</style>',
filename: "Component.svelte",
},
],
invalid: [
{
// The template comment the ts pass could never see.
code: "<!-- русский комментарий -->\n<main>Hello</main>",
filename: "Component.svelte",
errors: [{ messageId: "nonAscii" }],
},
{
// <style> is a postcss AST, so its comments are invisible to
// getAllComments(); the loc must still point at the real line.
code: '<div class="x">hi</div>\n<style>\n/* русский комментарий в стилях */\n.x {\n\tcolor: red;\n}\n</style>',
filename: "Component.svelte",
errors: [{ messageId: "nonAscii", line: 3 }],
},
{
// All three comment kinds in one component, reported by position.
code: '<!-- раз -->\n<style>\n/* два */\n.x {\n\tcolor: red;\n}\n</style>\n<script lang="ts">\n// три\n</script>',
filename: "Component.svelte",
errors: [
{ messageId: "nonAscii", line: 1 },
{ messageId: "nonAscii", line: 3 },
{ messageId: "nonAscii", line: 9 },
],
},
{
code: svelteComponent("// ресайз холста"),
filename: "Component.svelte",
errors: [{ messageId: "nonAscii" }],
},
{
// One report per comment, script and template alike.
code: '<!-- раз -->\n<main>два</main>\n<script lang="ts">\n// три\n</script>',
filename: "Component.svelte",
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
},
{
// allowChars does not whitelist other glyphs in the same comment.
code: "<!-- 512×512 → done -->\n<main>Hello</main>",
filename: "Component.svelte",
options: [{ allowChars: ["×"] }],
errors: [{ messageId: "nonAscii" }],
},
],
});
@@ -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-image-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-image-tools</span>",
filename: "Component.svelte",
options: OPTIONS,
},
{
code: "<footer><span>easy-image-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-image-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-image-tools", ["easy-image-tools"])).toBe(false);
expect(hasUserWords("easy-image-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" }],
},
],
},
);
@@ -1,61 +0,0 @@
// Rule: ASCII-ONLY SOURCE (scoped to test files via eslint.config.js).
// Non-ASCII in test names and expectations hurts grep-ability and hides
// hardcoded localized strings; intentional glyphs go to the allowChars
// option. File-content based (not AST), one report per line.
// Details: web/eslint-plugins/README.md.
const DEFAULT_ALLOW = [];
export default {
meta: {
type: "suggestion",
docs: {
description:
"Require ASCII-only source in scoped files (tests); exceptions go to the allowChars option.",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
allowChars: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 1 },
uniqueItems: true,
},
},
additionalProperties: false,
},
],
messages: {
nonAscii:
"Non-ASCII character {{chars}} on this line. Tests are written in plain ASCII English; keep intentional glyphs in the rule's allowChars option.",
},
},
create(context) {
const allowed = new Set(context.options[0]?.allowChars ?? DEFAULT_ALLOW);
return {
Program() {
const sourceCode = context.sourceCode;
const text = sourceCode.getText();
const reportedLines = new Set();
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
for (const match of text.matchAll(/[^\x09\x0A\x0D\x20-\x7E]/g)) {
const char = match[0];
if (allowed.has(char)) continue;
const loc = sourceCode.getLocFromIndex(match.index);
if (reportedLines.has(loc.line)) continue;
reportedLines.add(loc.line);
context.report({
loc,
messageId: "nonAscii",
data: {
chars: `'${char}' (U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
},
});
}
},
};
},
};
@@ -1,142 +0,0 @@
// Rule: COMMENT FORMAT: bounded blocks and bounded density.
// maxBlockLines caps one comment block; maxRatio caps the share of comment
// lines in files with minLines+ non-blank lines. In .svelte, <script>,
// template <!-- --> and <style> CSS (postcss AST) count alike.
// Details: web/eslint-plugins/README.md.
import {
getStyleNodeLoc,
getStyleRoot,
} from "../design-tokens/style-context.js";
function lineSpan(comment) {
return comment.loc.end.line - comment.loc.start.line + 1;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Cap comment block length and overall comment density per file.",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
maxBlockLines: { type: "integer", minimum: 1 },
maxRatio: { type: "number", minimum: 0, maximum: 1 },
minLines: { type: "integer", minimum: 0 },
},
additionalProperties: false,
},
],
messages: {
blockTooLong:
"Comment block is {{lines}} lines (limit {{max}}). Explain 'why' briefly; move 'what' into names, structure, or a linked doc.",
tooDense:
"Comments take {{percent}}% of this file (limit {{max}}%). Prefer extracting functions/modules over narrating the code.",
},
},
create(context) {
const maxBlockLines = context.options[0]?.maxBlockLines ?? 5;
const maxRatio = context.options[0]?.maxRatio ?? 0.15;
const minLines = context.options[0]?.minLines ?? 0;
const htmlComments = [];
// `<style>` is a postcss AST, not a token stream, so ESLint's comment
// tokens never include those comments; they come from the shared helper
// the design-tokens rules use (null outside Svelte components).
const styleRoot = getStyleRoot(context);
const styleNodeLoc = getStyleNodeLoc(context);
return {
// svelte-eslint-parser keeps `<!-- -->` template comments out of the
// token stream (they are AST nodes, not comment tokens), so
// getAllComments() below never sees them. Collected during traversal
// to catch template comments nested inside markup elements too.
SvelteHTMLComment(node) {
htmlComments.push(node);
},
"Program:exit"(node) {
const sourceCode = context.sourceCode;
// `type: "Block"` keeps a CSS comment from merging with the run
// above it: only adjacent `//` lines continue one block.
const styleComments = [];
if (styleRoot && styleNodeLoc) {
styleRoot.walkComments((comment) => {
const loc = styleNodeLoc(comment);
if (loc.start) styleComments.push({ loc, type: "Block" });
});
}
const comments = [
...sourceCode.getAllComments(),
...htmlComments,
...styleComments,
].sort(
(a, b) =>
a.loc.start.line - b.loc.start.line ||
a.loc.start.column - b.loc.start.column,
);
if (comments.length === 0) return;
const commentLines = new Set();
let blockStart = comments[0];
let blockEndLine = comments[0].loc.end.line;
let blockLines = lineSpan(comments[0]);
const flushBlock = () => {
if (blockLines > maxBlockLines) {
context.report({
loc: blockStart.loc,
messageId: "blockTooLong",
data: { lines: String(blockLines), max: String(maxBlockLines) },
});
}
};
for (let i = 0; i < comments.length; i++) {
const comment = comments[i];
for (let l = comment.loc.start.line; l <= comment.loc.end.line; l++) {
commentLines.add(l);
}
if (i === 0) continue;
// Adjacent `//` lines form one block; HTML template comments and
// everything else start a new one (their own span is the block).
if (
comment.type === "Line" &&
comments[i - 1].type === "Line" &&
comment.loc.start.line === blockEndLine + 1
) {
blockEndLine = comment.loc.end.line;
blockLines += 1;
} else {
flushBlock();
blockStart = comment;
blockEndLine = comment.loc.end.line;
blockLines = lineSpan(comment);
}
}
flushBlock();
const totalLines = sourceCode.lines.filter(
(l) => l.trim() !== "",
).length;
const ratio = commentLines.size / totalLines;
if (totalLines >= minLines && ratio > maxRatio) {
context.report({
loc: node.loc,
messageId: "tooDense",
data: {
percent: (ratio * 100).toFixed(1),
max: (maxRatio * 100).toFixed(1),
},
});
}
},
};
},
};

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