Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
299586fa87 |
@@ -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
|
|
||||||
@@ -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: ${{ 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: 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
|
|
||||||
@@ -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:
|
|
||||||
BASE_PATH: /easy-png-tools
|
|
||||||
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
|
|
||||||
@@ -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
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
node_modules
|
|
||||||
|
|
||||||
# Отчёты разовых аудитов и артефакты прогонов
|
|
||||||
web/audit/cdp-audit*.txt
|
|
||||||
css-lint.txt
|
|
||||||
web/coverage
|
|
||||||
|
|
||||||
# Локальные планы с приватной информацией (выбранное имя домена) — не коммитить
|
|
||||||
docs/plan-domain.local.md
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Точечные исключения, без allowlist-`/*`: корневой `/*` глушил формат-он-сейв
|
|
||||||
# (весь `web/**` резолвился как ignored, дочерние конфиги не успевали примениться).
|
|
||||||
# Дочерние каталоги управляются своими конфигами/игнорами (web/, refs/).
|
|
||||||
node_modules
|
|
||||||
.git
|
|
||||||
refs-html
|
|
||||||
pnpm-lock.yaml
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"proseWrap": "always"
|
|
||||||
}
|
|
||||||
@@ -1,112 +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` |
|
|
||||||
| Переименование бренда и покупка домена | `docs/plan-domain.local.md` † |
|
|
||||||
| Сырые UX-находки по инструментам (вход аудита) | `docs/tools-audit.md` |
|
|
||||||
| Архивирование и удаление старых документов | `docs/archive/README.md` |
|
|
||||||
| Индекс остальных документов | `docs/README.md` |
|
|
||||||
|
|
||||||
† `docs/plan-domain.local.md` вне git, в клоне отсутствует — в этом случае
|
|
||||||
задачу ищут по `docs/plan-seo.md` §6.
|
|
||||||
|
|
||||||
## Инварианты, которые линтер не проверяет
|
|
||||||
|
|
||||||
- **Сначала искать.** Новая константа, тип или утилита не заводится, пока не
|
|
||||||
найдено существующее определение — по ключу в соседних модулях и по всему
|
|
||||||
`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`.
|
|
||||||
@@ -1,242 +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.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).
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,100 +1,7 @@
|
|||||||
# easy-png-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)
|
|
||||||
|
|||||||
@@ -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-png-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 }
|
|
||||||
@@ -1,71 +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/plan-domain.local.md` | Чек-лист переименования бренда и покупки домена | active |
|
|
||||||
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
|
|
||||||
| `web/eslint-plugins/README.md` | Правка lint-правил, токенов и их конфигов | active |
|
|
||||||
| `docs/archive/README.md` | Архивирование и удаление старых документов | active |
|
|
||||||
|
|
||||||
`docs/plan-domain.local.md` **исключён из git** через `.gitignore`: выбранное
|
|
||||||
имя домена не должно попасть в историю до покупки. В клоне файла нет — это
|
|
||||||
ожидаемо, и ссылки на него в `plan-seo.md` и `backlog.md` не разрешаются. Строка
|
|
||||||
в таблице оставлена, чтобы задачу R1/R2 можно было найти по индексу; сам
|
|
||||||
чек-лист лежит только на машине разработчика.
|
|
||||||
|
|
||||||
## Правила
|
|
||||||
|
|
||||||
- У каждой темы ровно один владелец: остальные документы ссылаются на него, а не
|
|
||||||
пересказывают. Новый документ создаётся, только если темы нет в существующем.
|
|
||||||
- У документа есть статус (`draft`, `active`, `completed`, `archived`) и
|
|
||||||
понятная область применения. Ссылки на конкретный файл или команду проверяются
|
|
||||||
при изменении источника истины.
|
|
||||||
- Подробные материалы живут в `docs/`, а не в корне. Не создавать отдельные
|
|
||||||
`docs/commands.md` или `docs/known-issues.md`: команды принадлежат
|
|
||||||
`quality-gates`, известные проблемы — backlog и checklist. `README.md` в корне
|
|
||||||
остаётся кратким обзором продукта, а не вторым руководством агента.
|
|
||||||
- `docs/backlog.md` содержит только открытые задачи: выполненные удаляются, а
|
|
||||||
критичные для проекта факты из них переносятся в тематический документ.
|
|
||||||
- `docs/archive/` — временная историческая справка, не источник критериев
|
|
||||||
приёмки; срок хранения 30 дней, файлы восстановимы из git history.
|
|
||||||
Единственный документ в архиве со статусом `active` —
|
|
||||||
`docs/archive/README.md`: он описывает процедуру архивирования, а не архивный
|
|
||||||
материал, поэтому не подлежит 30-дневному удалению.
|
|
||||||
- Ссылаясь на инструмент, сверяться с реестром: внутреннее имя (`id`) — в
|
|
||||||
`web/src/lib/registry/tools/`, адрес и имя страницы (`slug`) — в
|
|
||||||
`web/src/lib/registry/pages/`; исторические названия в старых планах не
|
|
||||||
действуют (`docs/architecture.md`, раздел 3).
|
|
||||||
- Поиск по `docs/` задевает `docs/archive/`: там вынесены завершённые планы и
|
|
||||||
исследования, поэтому grep по `docs/` даёт больше шума, чем пользы. Для правок
|
|
||||||
production-кода архив не нужен: grep по `docs/*.md` без `archive/`.
|
|
||||||
- Проверка ссылок в документации не автоматизирована; ожидаемо неразрешимыми
|
|
||||||
считаются ссылки на `docs/plan-domain.local.md` (файл вне git). Всё остальное
|
|
||||||
неразрешимое — ошибка.
|
|
||||||
- Протокол «остановиться и показать изменения» — `docs/agent-workflow.md`,
|
|
||||||
раздел 6.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
# Архитектура
|
|
||||||
|
|
||||||
> Статус: **active**. Описание текущего кода: границы модулей, поток данных от
|
|
||||||
> реестра до скачивания, точки расширения. Канонический источник истины — сам
|
|
||||||
> код; этот документ объясняет, **куда смотреть и что с чем связано**, и не
|
|
||||||
> дублирует сигнатуры.
|
|
||||||
|
|
||||||
## 1. Слои
|
|
||||||
|
|
||||||
```txt
|
|
||||||
web/src/lib/
|
|
||||||
registry/ # источник истины: инструменты и страницы
|
|
||||||
registry/tools/ # Tool: реализации (id), без адресов и текстов
|
|
||||||
registry/pages/ # Page: адрес, тексты, категория, список шагов
|
|
||||||
registry-schema.ts # декларативная схема параметров + санитизация дефолтов
|
|
||||||
catalog.ts # группировка страниц и счётчики для /list-tools
|
|
||||||
categories.ts # id/названия категорий
|
|
||||||
tool-icons.ts # slug страницы → иконка
|
|
||||||
core/ # операции над 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`.
|
|
||||||
|
|
||||||
### Чего в коде нет
|
|
||||||
|
|
||||||
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
|
|
||||||
скачала результат. Отсутствуют:
|
|
||||||
|
|
||||||
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для
|
|
||||||
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
|
|
||||||
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
|
|
||||||
полей двух инструментов — отдельная работа, описанная как фаза 11
|
|
||||||
`docs/roadmap.md`. Под неё зарезервированы
|
|
||||||
`components/display/StepCard.svelte` (сейчас импортируется только витриной
|
|
||||||
`/kit`) и ключи `chain.*`, `errors.badPipelineShape`,
|
|
||||||
`errors.pipelineVersion`, `errors.noSteps`.
|
|
||||||
- **Разделения страниц, которые делят один инструмент** — сводит все
|
|
||||||
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` только рисует её вывод.
|
|
||||||
|
|
||||||
## 3. Инструмент и страница
|
|
||||||
|
|
||||||
Реестр двухслойный: реализация и то, что видит пользователь, лежат в разных
|
|
||||||
файлах. `Tool` (`web/src/lib/registry/types.ts`, записи в
|
|
||||||
`registry/tools/<group>.ts`):
|
|
||||||
|
|
||||||
| Поле | Смысл |
|
|
||||||
| ---------- | ------------------------------------------------------------------------------ |
|
|
||||||
| `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker |
|
|
||||||
| `schema` | `toolSchema({ fields, layout?, meta? })` |
|
|
||||||
| `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе |
|
|
||||||
| `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
|
|
||||||
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
|
|
||||||
| `output`? | mime/ext/quality для скачивания (не у всех) |
|
|
||||||
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
|
|
||||||
|
|
||||||
`Page` (записи в `registry/pages/<group>.ts`):
|
|
||||||
|
|
||||||
| Поле | Смысл |
|
|
||||||
| ------------- | ---------------------------------------------------------------------- |
|
|
||||||
| `slug` | уникальный адрес `/tools/<slug>`, имя скачиваемого файла и ключ иконки |
|
|
||||||
| `title` | EN-строка страницы, переводится через `pages.<slug>.title` |
|
|
||||||
| `description` | EN-строка страницы, переводится через `pages.<slug>.description` |
|
|
||||||
| `category` | id из `categories.ts`; по нему каталог группирует страницы |
|
|
||||||
| `steps` | упорядоченный список шагов `{ id, params? }` — ссылки на инструменты |
|
|
||||||
|
|
||||||
Кто по чему адресуется: `slug` — маршрут, имя файла, `TOOL_ICONS`, поиск по
|
|
||||||
каталогу; `id` — ключ `tools.<id>.*` (подписи полей, `options`, `results`),
|
|
||||||
`toolId` worker-протокола и запись пайплайна. Модель допускает, что один
|
|
||||||
инструмент обслуживает несколько страниц, а страница состоит из нескольких
|
|
||||||
инструментов; на практике пока каждая страница ссылается на свой инструмент, а
|
|
||||||
исполняется первый (и единственный) шаг.
|
|
||||||
|
|
||||||
Инструмент работает с любым поддержанным форматом, поэтому png-интент живёт
|
|
||||||
только в `slug`: `id: "crop"` → страница `slug: "crop-png"` → `crop-png.png`.
|
|
||||||
Правило имени `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.ts` — единственное место, где описаны виды полей.
|
|
||||||
|
|
||||||
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
|
|
||||||
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
|
|
||||||
`font-style`, `plate`, `gradient`.
|
|
||||||
- **Фабрика `field.*`** создаёт `Field<T>`; UI-контрол подбирается в
|
|
||||||
`SchemaFields.svelte` по `spec.kind` через карту `FIELDS`.
|
|
||||||
- **`schema.layout`** группирует поля (`resolveLayoutGroups`); раскладка
|
|
||||||
разделов — единственное место, где живёт порядок и группировка.
|
|
||||||
- **Значения по умолчанию** даёт `defaultSchemaParams`; `applySourceDefaults`
|
|
||||||
подставляет размеры текущего source; `sanitizeSchemaParams` приводит мусор к
|
|
||||||
допустимому виду. Правка поведения полей идёт здесь, а не в компоненте.
|
|
||||||
|
|
||||||
Новый вид поля требует: спека + фабрика `field.*` → запись в `fieldSpecs` →
|
|
||||||
контрол (`components/fields/schema/`) → ключ в `FIELDS` → дефолт и санитизация →
|
|
||||||
i18n-подписи → тест в `registry-schema.test.ts`.
|
|
||||||
|
|
||||||
## 5. Исполнение
|
|
||||||
|
|
||||||
`executor/executor.ts` — фабрика `createExecutor({ 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.*` в словарях + `tool-icons.ts` + `docs/tools-map.md` + хардкод slug в тестах и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи |
|
|
||||||
| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
|
|
||||||
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
|
|
||||||
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
|
|
||||||
| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу |
|
|
||||||
| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов |
|
|
||||||
| Мультифайловый результат | `result: "files"` + `zip.ts` |
|
|
||||||
|
|
||||||
## 8. Рецепт: добавить инструмент
|
|
||||||
|
|
||||||
1. **Найти соседей.** Поиск по ключу в `registry/tools/<group>.ts` и по всему
|
|
||||||
`web/src/` — не заводить дублирующую константу, тип или утилиту.
|
|
||||||
2. **Выбрать группу** (`geometry`/`alpha`/`color`/`convert`/`filters`/
|
|
||||||
`generate`/`text`/`analyze`) и положить инструмент в её экспорт; при новой
|
|
||||||
категории — сначала `categories.ts`.
|
|
||||||
3. **Задать `id`** по правилу раздела 3: без png-интента (`crop`, `to-jpg`,
|
|
||||||
`from-base64`, `is-grayscale`).
|
|
||||||
4. **Описать схему** через `field.*`, задать `layout`, если поля нужно
|
|
||||||
сгруппировать. Качество кодирования (`quality`) — не поле страницы, а
|
|
||||||
настройка скачивания (`docs/plan-seo.md` §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`).
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Архив документов
|
|
||||||
|
|
||||||
> Статус: **active**. Здесь хранятся завершённые, отменённые и явно устаревшие
|
|
||||||
> документы как временная историческая справка. Этот README — единственный
|
|
||||||
> документ архива со статусом `active`: он описывает процедуру, а не архивный
|
|
||||||
> материал, поэтому не подлежит 30-дневному удалению.
|
|
||||||
|
|
||||||
## Назначение
|
|
||||||
|
|
||||||
Архив помогает быстро посмотреть, какое решение принималось раньше и почему. Он
|
|
||||||
не является источником текущих требований, критериев приёмки или команды. Для
|
|
||||||
актуальных правил используются `AGENTS.md`, `docs/README.md` и активные планы.
|
|
||||||
|
|
||||||
Если решение снова понадобится, его можно восстановить из git history или
|
|
||||||
создать новый активный документ: **архивные файлы удаляются, а не хранятся
|
|
||||||
вечно.** Удалённые планы восстановимы через
|
|
||||||
`git log --diff-filter=D -- name-only -- docs/archive/` и
|
|
||||||
`git show <commit>^:<path>`.
|
|
||||||
|
|
||||||
## Обязательные метаданные
|
|
||||||
|
|
||||||
При переносе документа в архив добавить в начало:
|
|
||||||
|
|
||||||
```text
|
|
||||||
> Статус: archived
|
|
||||||
> Архивирован: YYYY-MM-DD
|
|
||||||
> Удалить после: YYYY-MM-DD
|
|
||||||
> Причина: краткое объяснение, почему документ больше не активен
|
|
||||||
> Использование: только историческая справка
|
|
||||||
```
|
|
||||||
|
|
||||||
Дата `Удалить после` — дата архивирования плюс 30 дней.
|
|
||||||
|
|
||||||
## Порядок архивирования
|
|
||||||
|
|
||||||
1. Убедиться, что документ действительно завершён, отменён или заменён более
|
|
||||||
актуальным источником.
|
|
||||||
2. Убрать из него ссылки на несуществующие пути и артефакты прошлых волн.
|
|
||||||
3. Добавить обязательные метаданные.
|
|
||||||
4. Переместить файл в `docs/archive/`.
|
|
||||||
5. Найти оставшиеся входящие ссылки:
|
|
||||||
`git grep -n "<filename>" -- 'docs/*.md' AGENTS.md README.md web/eslint-plugins/README.md`.
|
|
||||||
Шаг 4 ломает их, и агент без этого шага напишет в новом документе ссылку на
|
|
||||||
удалённый файл. Ожидаемо неразрешимыми остаются ссылки на
|
|
||||||
`docs/plan-domain.local.md` — он вне git, это не ошибка (`docs/README.md`,
|
|
||||||
раздел «Правила»).
|
|
||||||
6. Не менять архивный документ под новые требования.
|
|
||||||
|
|
||||||
## Удаление
|
|
||||||
|
|
||||||
Через 30 дней после архивирования:
|
|
||||||
|
|
||||||
- проверить, что документ не нужен для текущей работы;
|
|
||||||
- найти и удалить оставшиеся ссылки на него из активных документов;
|
|
||||||
- удалить файл из `docs/archive/`.
|
|
||||||
|
|
||||||
До истечения срока архивный файл не превращается обратно в live-документ: если
|
|
||||||
он снова актуален, создаётся новый документ или возвращается версия из git
|
|
||||||
истории с новым статусом и датами.
|
|
||||||
@@ -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` = один компонент с разными подписями/дефолтами). Не плодить
|
|
||||||
дублирующий код.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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` следует за локалью.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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 млн) запросов на чтение в сутки.
|
|
||||||
- Для пет-проекта этого хватит навсегда, даже если вы будете делать запросы
|
|
||||||
напрямую с фронтенда без кеширования.
|
|
||||||
@@ -1,279 +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`.
|
|
||||||
|
|
||||||
## lint и прочие правила
|
|
||||||
|
|
||||||
- [ ] `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`)
|
|
||||||
|
|
||||||
## Мелочи всякие
|
|
||||||
|
|
||||||
- [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
|
|
||||||
нарезки ассетов
|
|
||||||
- [ ] crop, resize (но скорее всего все инструменты) - при загрузке нового
|
|
||||||
изображения сбрасывает размеры, настроенные пользователем
|
|
||||||
- [ ] split-into-parts-png, resize (может еще какие-то) - возможность указывать
|
|
||||||
размеры из заранее одобренного списка размеров - 16х16, 32х32, 64х64 как
|
|
||||||
минимум, может еще какие-то размеры добавить, единый список из предыдущей
|
|
||||||
задачи - (для инди разработчиков, стандартные тайлы часто делаются)
|
|
||||||
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
|
||||||
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
|
||||||
(`docs/testing-strategy.md`, раздел 5)
|
|
||||||
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
|
||||||
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
|
||||||
схемой (FA-E1), либо удалить ключ из обеих локалей
|
|
||||||
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
|
||||||
привязанные к разметке или svelte-классам (сейчас остались:
|
|
||||||
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
|
||||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
|
||||||
выделенной области
|
|
||||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
|
||||||
какой размер и 2 сетки)
|
|
||||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
|
||||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
|
||||||
- [ ] можно ли в тестах получать i18n ключ вместо текста?
|
|
||||||
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
|
|
||||||
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
|
||||||
должны быть настраиваемой 0-100%, а не переключателем
|
|
||||||
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
|
||||||
единую структуру, позволит упростить некоторые вещи - например
|
|
||||||
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
|
|
||||||
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
|
||||||
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
|
||||||
`ondownloadtxt`, `ondownload`,
|
|
||||||
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
|
||||||
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
|
||||||
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть
|
|
||||||
в кнопке Download
|
|
||||||
- [ ] Кнопка download должна сразу писать какой формат сейчас, быть с селектором
|
|
||||||
формата и подсвечивать предупреждение если формат не совместим (альфа
|
|
||||||
канал)
|
|
||||||
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
|
||||||
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
|
||||||
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
|
||||||
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
|
||||||
паспортом, семейства с кириллицей)
|
|
||||||
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
|
||||||
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
|
||||||
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
|
||||||
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
|
||||||
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
|
||||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
|
||||||
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
|
||||||
значения
|
|
||||||
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
|
|
||||||
изменено
|
|
||||||
- [ ] обновить страницу kit или сделать storybook
|
|
||||||
- [ ] Свести 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`
|
|
||||||
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
|
|
||||||
вывода
|
|
||||||
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
|
|
||||||
удаление, добавление инструментов. переход один инструмент - pipeline без
|
|
||||||
перезагрузки
|
|
||||||
- [ ] `CatalogToolbar`: фильтры используют сырой `<button>`, а не `ui/Button` —
|
|
||||||
единый контрол для всей библиотеки
|
|
||||||
(`web/src/lib/components/CatalogToolbar.svelte`).
|
|
||||||
- [ ] `SchemaTextResult` вердикт-бейдж рисует руками: свои классы
|
|
||||||
`badge`/`badge-tone--{tone}` и свой CSS в `<style>` вместо
|
|
||||||
`ui/Badge.svelte` — тон и разметка бейджа живут в двух местах
|
|
||||||
(`web/src/lib/components/schema/SchemaTextResult.svelte`).
|
|
||||||
- [ ] `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
|
|
||||||
|
|
||||||
## Переезд
|
|
||||||
|
|
||||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
|
||||||
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
|
|
||||||
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
|
|
||||||
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
|
|
||||||
plurals есть все формы, для которых нужен перевод, `other` никогда не
|
|
||||||
пустой, число подставляется в текст без склейки
|
|
||||||
|
|
||||||
## SEO/GEO
|
|
||||||
|
|
||||||
- [ ] переименование бренда + домен: чек-лист в локальном файле
|
|
||||||
`plan-domain.local.md` (в git не входит, в клоне отсутствует; подробности
|
|
||||||
и порядок — `docs/plan-seo.md` §6, R1/R2)
|
|
||||||
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
|
|
||||||
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
|
|
||||||
- [ ] download с селектором формата PNG/JPG/WebP/BMP для всех инструментов (райз
|
|
||||||
«Download» → S1f)
|
|
||||||
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
|
|
||||||
вордстату с уникальным контентом; редиректы только для опечаток и
|
|
||||||
переименований уже опубликованных страниц; клонов с canonical не делать
|
|
||||||
(`docs/plan-seo.md` §5)
|
|
||||||
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
|
|
||||||
- [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e)
|
|
||||||
- [ ] контент страницы инструмента: seo-блок в словарях, 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)
|
|
||||||
|
|
||||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
|
|
||||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
|
||||||
(после wasm-фаз 5–7)
|
|
||||||
- [ ] Шрифты: лицензирование (F1–F4) и DOM-free рендер (F5–F6) — до фазы 8
|
|
||||||
(`docs/plan-platform.md` §4)
|
|
||||||
|
|
||||||
## Качество production UI
|
|
||||||
|
|
||||||
- [ ] `/` и `/list-tools` выходят без `<title>`: `svelte:head` есть только на
|
|
||||||
странице инструмента, а готовые ключи `home.defaultTitle`,
|
|
||||||
`catalog.pageTitle`, `catalog.metaDescription` не читает ни один компонент
|
|
||||||
(S1d)
|
|
||||||
- [ ] **Баг e2e:** `tool-flows.spec.ts:179` создаёт контекст через
|
|
||||||
`browser.newContext()`, который не наследует `use.baseURL` из
|
|
||||||
`playwright.config.ts`, — относительный `goto('/tools/flip-png')` внутри
|
|
||||||
`openTool` не резолвится, и тест «работает без Web Worker (fallback)»
|
|
||||||
падает на всех движках. В CI это не видно: job `e2e` неблокирующий
|
|
||||||
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
|
||||||
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
|
||||||
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
|
||||||
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
|
|
||||||
|
|
||||||
## Идеи
|
|
||||||
|
|
||||||
Идеи 1–5 и 15 — про пайплайн (фаза 11 `docs/roadmap.md`). Идеи 6–14 и 16 — про
|
|
||||||
существующие инструменты и UI. Оценки (S/M/L) — оценка труда, не приоритет.
|
|
||||||
|
|
||||||
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
|
|
||||||
зафиксирован при открытии страницы; хочется заменить его на другой без
|
|
||||||
разборки цепочки: шаги и их параметры сохраняются, вход перечитывается.
|
|
||||||
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
|
|
||||||
file).
|
|
||||||
|
|
||||||
2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
|
|
||||||
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
|
|
||||||
помнить в workspace-pipeline.
|
|
||||||
|
|
||||||
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
|
|
||||||
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
|
|
||||||
Формат экспортного JSON расширить опциональным полем имени; обратной
|
|
||||||
совместимости со старыми выгрузками не делаем — формат не устоялся: он
|
|
||||||
появится вместе с фазой 11 `docs/roadmap.md` и до её старта может измениться.
|
|
||||||
|
|
||||||
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
|
|
||||||
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
|
|
||||||
chain по каждому файлу, сборка результата обратно в zip для скачивания.
|
|
||||||
Зависит от идеи 3 (выбор цепочки) и от экспортного формата pipeline JSON;
|
|
||||||
прогресс и ошибки — пофайлово.
|
|
||||||
|
|
||||||
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
|
|
||||||
карточках каталога, результатах поиска и в шапке страницы инструмента; список
|
|
||||||
slug в localStorage. Избранное показывается отдельной секцией сверху каталога
|
|
||||||
и поднимается в выдаче поиска (бонус к popularity при скоринге).
|
|
||||||
|
|
||||||
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
|
|
||||||
— оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и
|
|
||||||
задача **S1f**; почему селектор не заменяет существующие convert-страницы —
|
|
||||||
`docs/plan-seo.md` §5. Механическая часть уже есть: `Tool.output`
|
|
||||||
(`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в
|
|
||||||
`SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось
|
|
||||||
сверх S1f:
|
|
||||||
- экспорт с лимитом размера → «сжатие до целевого КБ»: для lossy это quality
|
|
||||||
в селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
|
|
||||||
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
|
|
||||||
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
|
|
||||||
download-флоу.
|
|
||||||
|
|
||||||
7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна
|
|
||||||
overlay-механика: второй источник изображения (getOverlay/store), параметры
|
|
||||||
scale/opacity/position/margin/aspect. Решается вместе с вопросом, как второй
|
|
||||||
источник картинки вписывается в schema-driven preview (`SchemaToolView`).
|
|
||||||
|
|
||||||
8. **`png-info` (детальная информация о PNG)** — оценка M/L. Отдельный райз:
|
|
||||||
exif-теги, редактирование, структурированный вывод; нужен свой отдельный
|
|
||||||
случай в UI, не «ещё один text/verdict». Возможно нужна вообще отдельная
|
|
||||||
страница, несовместимая с другими инструментами: 1) она работает не с
|
|
||||||
изображением, а с метаданными из файла, 2) она не может быть частью цепочки,
|
|
||||||
т.к. результат не является изображением, 3) если надо изменить теги, никакого
|
|
||||||
форматирования и изменения в изображение вносить не надо, только в
|
|
||||||
метаинформацию.
|
|
||||||
|
|
||||||
9. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
|
|
||||||
reverse-area) — ждут UI выделения области на превью. Вне очереди.
|
|
||||||
|
|
||||||
10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
|
|
||||||
— механизм «результат = набор файлов» (1 → many) уже есть
|
|
||||||
(`docs/architecture.md`, раздел 7), первый потребитель —
|
|
||||||
`split-into-parts-png`. Остальные подключаются по мере нужды.
|
|
||||||
|
|
||||||
11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
|
|
||||||
решение о формате.
|
|
||||||
|
|
||||||
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital).
|
|
||||||
|
|
||||||
13. **Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
|
||||||
`--color-main`/`--color-accent` подняты под текущий порог `palette.test.ts`;
|
|
||||||
targeted-тест проходит. Light-тема всё ещё ниже AA 4.5 для обычного текста,
|
|
||||||
а `--color-danger` в light-теме на границе: после завершения редизайна
|
|
||||||
вернуть порог 4.5 и перепроверить оба токена.
|
|
||||||
|
|
||||||
14. **Отображение маски для инструментов (`runMask`)** — придумать и реализовать
|
|
||||||
процесс показа маски результата. **Контекст:** удалённый UI-v1 умел
|
|
||||||
показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
|
|
||||||
переходе на единый `run(ctx)` этого поля не осталось — executor и
|
|
||||||
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
|
|
||||||
дать каждому `run` возможность вернуть (или инструмент — объявить) `runMask`
|
|
||||||
— ч/б изображение маски изменений либо иной подход (отдельное поле `mask` +
|
|
||||||
функция маски, второй результат, overlay). Оценка — на усмотрение при
|
|
||||||
проработке (S/M). Пока живём без неё. **Идея 2:** каждый `run` возвращает
|
|
||||||
два изображения — для отображения (показывает маску или сам результат) и для
|
|
||||||
передачи в следующий инструмент (всегда результат). Оценка — на усмотрение
|
|
||||||
при проработке (S/M). Пока живём без неё. Первые кандидаты на маску:
|
|
||||||
`remove-color-from-png`, `remove-background-png`.
|
|
||||||
|
|
||||||
15. **Набор файлов в пайплайне: 1 → many не должен быть терминальным** — оценка
|
|
||||||
L. **Сейчас** `execute` принимает один `source: PixelImage | undefined` и
|
|
||||||
возвращает один `ToolResult`, а `SchemaToolView` раскладывает вывод в
|
|
||||||
`result` / `fileResult` / `textResult` по `resultKind` — поэтому шаг с
|
|
||||||
`result: "files"` упирается в конец цепочки. **Направление:** следующий
|
|
||||||
инструмент применяется **ко всем картинкам** предыдущего результата
|
|
||||||
(fan-out), а many → 1 (вход = архив или несколько файлов) — обратная сторона
|
|
||||||
того же контракта. Терминальными остаются только результаты, которые не
|
|
||||||
картинка (text, verdict). Что обдумать:
|
|
||||||
- где проходит граница: один вызов `run(ctx)` на файл (имя части склеивается
|
|
||||||
в `FileResult.files`) или батч целиком на стороне исполнителя;
|
|
||||||
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
|
|
||||||
прогресс и отмена;
|
|
||||||
- частичные ошибки: один битый файл не должен ронять весь шаг — нужен
|
|
||||||
per-file отчёт, а не один `ToolError` на шаг;
|
|
||||||
- имена частей на каждом шаге и Download для набора (сейчас zip по
|
|
||||||
`page.slug`, `SchemaToolView.download()`);
|
|
||||||
- обратная сторона: распаковка zip в браузере, порядок файлов, поведение
|
|
||||||
many → 1 → many в одной цепочке. Зависит от идеи 10 (остальные потребители
|
|
||||||
1 → many) и идеи 4 (batch по архиву), `docs/roadmap.md` фаза 8
|
|
||||||
«batch-страница».
|
|
||||||
|
|
||||||
16. **Главная должна отличаться от каталога** — оценка S. Сейчас
|
|
||||||
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
|
|
||||||
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
|
|
||||||
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
|
|
||||||
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
|
|
||||||
Замысел: главная — крупнее и с более простым поиском, без категорий; каталог
|
|
||||||
остаётся группировкой с фильтрами. Детектор дублирования разметки
|
|
||||||
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
|
|
||||||
ловится на ревью.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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`)
|
|
||||||
@@ -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
|
|
||||||
визуально идентичный результат; в дистрибутиве нет ни одного файла шрифта без
|
|
||||||
паспорта лицензии.
|
|
||||||
@@ -1,438 +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. Решения
|
|
||||||
|
|
||||||
- **Бренд: переименовать до индексации** (новое имя и домен сознательно не
|
|
||||||
записаны в этом репозитории — чек-лист в локальном файле
|
|
||||||
`plan-domain.local.md`, см. §6, он вне git: выбранное имя не должно утекать в
|
|
||||||
историю коммитов до покупки домена). Легенда честная: «started as PNG tools,
|
|
||||||
now all formats» — это контент-плюс, а не баг. Адреса страниц
|
|
||||||
(`/tools/crop-png`) и localStorage-ключи не переименовывать (см. решение про
|
|
||||||
`slug` ниже). Точка истины по бренду: константа в i18n + README; хардкод в
|
|
||||||
компонентах недопустим.
|
|
||||||
- **Форматы: формат = выбор в UI, не отдельные страницы.** Download-кнопка
|
|
||||||
каждого инструмента — с селектором PNG/JPG/WebP/BMP (+quality для lossy). Райз
|
|
||||||
«выбор формата/качества в Download» вытащен из backlog №7 в S1f: механика уже
|
|
||||||
есть (`Tool.output`), нужна как продуктовая основа «image
|
|
||||||
tools»-позиционирования. В направлении «из формата в PNG» отдельных
|
|
||||||
инструментов нет (загрузка принимает любой формат) — см. §5.
|
|
||||||
- **Адреса страниц `-png` сохраняются, внутренние имена — без.** Инструмент
|
|
||||||
работает с любым форматом, поэтому его `id` не должен врать (`crop`, а не
|
|
||||||
`crop-png`); png-интент держат адрес страницы и имя скачиваемого файла.
|
|
||||||
**Реализовано:** реестр разделён на инструменты (`registry/tools/`, уникальный
|
|
||||||
`id`) и страницы (`registry/pages/`, уникальный `slug`), роут —
|
|
||||||
`tools/[slug]/`; модель — `docs/architecture.md`, раздел 3. **Следствие для
|
|
||||||
SEO: редиректы не понадобились, canonical и sitemap не меняются, адреса те
|
|
||||||
же** — поэтому это была безопасная правка до индексации, а не после. Побочная
|
|
||||||
выгода: широкие интенты («crop image online») переносятся теми же страницами
|
|
||||||
контентом — H1 «Crop image», текст «works with PNG, JPG, WebP»; URL-алиасы без
|
|
||||||
`-png` по-прежнему не создаются (канонический беспорядок). Побочный эффект
|
|
||||||
модели: страница может ссылаться на один инструмент, а инструмент —
|
|
||||||
обслуживать много страниц, поэтому следующие шаги (свести convert-страницы на
|
|
||||||
один `convert`, селектор формата в Download — S1f) не требуют новой миграции
|
|
||||||
реестра. Задача на консолидацию — `docs/backlog.md`.
|
|
||||||
- **Индексируемая локаль — EN** (базовая; конкурент англоязычный). RU-версия —
|
|
||||||
отдельно и позже: префикс `/ru/`, prerender обеих локалей, hreflang
|
|
||||||
(сознательно отложено). Пока язык переключается на клиенте (localStorage) —
|
|
||||||
для SEO не учитываем.
|
|
||||||
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`, см.
|
|
||||||
`BASE_PATH` в `web/vite.config.ts` и `deploy.yml`) ломает абсолютные URL,
|
|
||||||
canonical и «корневость» страниц. Свой домен + корень. Текущий деплой — GitHub
|
|
||||||
Pages (`.github/workflows/deploy.yml`); перенос на свой домен выполняется
|
|
||||||
сменой origin в CI и `PUBLIC_SITE_ORIGIN`.
|
|
||||||
- **URL-схема инструментов:** короткие плоские адреса — `/tools/<slug>` для
|
|
||||||
страницы, `/list-tools` для каталога, где `<slug>` берётся из записи
|
|
||||||
`registry/pages/`. Схему не менять без отдельного SEO-плана: после индексации
|
|
||||||
переименование URL означает 301 и потерю позиций. Разделение инструмента и
|
|
||||||
страницы (решение выше) адресов не меняет.
|
|
||||||
- **Аналитика — 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` | нет | S1 |
|
|
||||||
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
|
|
||||||
| `rel=canonical` | нет | S1 |
|
|
||||||
| OG/Twitter-теги, OG-картинки | нет | S1 |
|
|
||||||
| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | 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-картинки будут абсолютными и корректными.
|
|
||||||
- [ ] **[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. Существующие страницы не трогать: `convert-png-to-jpg`,
|
|
||||||
`convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами
|
|
||||||
со своим контентом (§5).
|
|
||||||
|
|
||||||
#### S1-domain: с доменом (после покупки + DNS)
|
|
||||||
|
|
||||||
- [ ] **[S1a]** Перевести прод на собственный домен: убрать `BASE_PATH` из
|
|
||||||
`deploy.yml`, задать `PUBLIC_SITE_ORIGIN`, перенаправить
|
|
||||||
`https://<юзер>/<repo>/…` на корневые URL (301). Делается одновременно с
|
|
||||||
S1d (canonical), чтобы Google проиндексировал сразу с правильным доменом.
|
|
||||||
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
|
|
||||||
`build/` из `PAGES` реестра (`web/src/lib/registry`) + статических
|
|
||||||
страниц. Взять адреса у страниц, а не у инструментов: страниц может быть
|
|
||||||
несколько на один инструмент, у инструмента адреса нет. Вызов из
|
|
||||||
`pnpm build` после `vite build` (adapter-static пишет в `build/`,
|
|
||||||
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
|
|
||||||
опционального `BASE_PATH`.
|
|
||||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
|
|
||||||
cleanup — вернуть минимальный allow-all.
|
|
||||||
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
|
||||||
актуальному маршруту), OG-теги (title/description/type/url/image),
|
|
||||||
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
|
||||||
для абсолютных URL в canonical и OG:url.
|
|
||||||
|
|
||||||
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
|
|
||||||
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
|
|
||||||
> `build/` лежит корректный `sitemap.xml` со всеми страницами инструментов;
|
|
||||||
> canonical/OG присутствуют на всех страницах; `noindex` отсутствует в собранном
|
|
||||||
> HTML (`pnpm --dir web build` и проверка `build/tools/*/index.html`);
|
|
||||||
> `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job
|
|
||||||
> `e2e` non-blocking, поэтому его статус читается по отдельному job
|
|
||||||
> (`docs/decisions.md`, раздел 10).
|
|
||||||
|
|
||||||
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
|
|
||||||
|
|
||||||
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
|
||||||
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
|
||||||
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
|
|
||||||
вручную пачками по категориям (самый трудоёмкий этап: объём растёт вместе
|
|
||||||
с числом инструментов × 2 локали; RU — отложить до решения о `/ru/`).
|
|
||||||
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
|
|
||||||
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
|
|
||||||
внутри категории + 2–3 «соседних» интента).
|
|
||||||
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI.
|
|
||||||
- [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
|
|
||||||
(приоритет по вордстату: 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`.
|
|
||||||
|
|
||||||
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
|
|
||||||
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
|
|
||||||
> инструментов нет и создавать их не хочется — загрузка файла уже принимает
|
|
||||||
> любой формат, а конвертер «во что-то» закрывает обратное направление. Это
|
|
||||||
> high-volume интент без посадочной страницы. Решение (страница-обёртка на
|
|
||||||
> существующий инструмент / приём в H1 и тексте / ничего) требует вордстата и
|
|
||||||
> должно быть принято до старта S2, иначе первая волна контента закроет не ядро
|
|
||||||
> запросов.
|
|
||||||
|
|
||||||
Новая волна форматов (после S1f): jpg↔webp и другие конвертации «формат →
|
|
||||||
формат» — только по подтверждённому вордстату; базовые кейсы закрывает селектор
|
|
||||||
формата в Download (S1f), страницы заводим лишь там, где интент стабильно
|
|
||||||
ищется.
|
|
||||||
|
|
||||||
- **crop-png: H1 «Crop image online — free, no upload»**, текст «works with PNG,
|
|
||||||
JPG, WebP» — широкий интент обслуживается контентом страницы с png-слагом,
|
|
||||||
слаг при этом остаётся якорем дешёвого png-интента.
|
|
||||||
|
|
||||||
### Форматные варианты страниц (`crop-jpeg`, `crop-webp`, …)
|
|
||||||
|
|
||||||
Три возможных механики — важно не перепутать:
|
|
||||||
|
|
||||||
1. **301-редирект на существующую страницу** (`/crop-jpg` → `/crop-jpeg`, или
|
|
||||||
`/crop-png` → новый слаг после переименования) — законно **только** для
|
|
||||||
опечаток, альтернативных написаний и переименований **уже опубликованной**
|
|
||||||
страницы: URL, которого никогда не было в индексе, редиректом не закрывается.
|
|
||||||
Для самостоятельного запроса редирект — это отказ от страницы: Google
|
|
||||||
перестаёт показывать редирект, ранжировать будет только цель. Как посадочная
|
|
||||||
под «crop jpeg» не работает вообще.
|
|
||||||
2. **Зеркало с canonical на `/crop-png`** — технически легально, но бесполезно:
|
|
||||||
canonical-дубль в индекс не попадает, видимости не добавляет, а массовое
|
|
||||||
производство таких зеркал выглядит как накрутка площади и при ошибке в
|
|
||||||
canonical превращается в дорвейный кластер. Не делаем.
|
|
||||||
3. **Самостоятельная страница = самостоятельный контент.** Заводить `/crop-jpeg`
|
|
||||||
можно только если: (а) интент подтверждён вордстатом, (б) на странице
|
|
||||||
собственный текст — H1 «Crop JPEG», FAQ про качество/артефакты/ когда
|
|
||||||
выбирать JPEG вместо PNG, отличия от png-кейса, (в) текст написан или выверен
|
|
||||||
человеком, а не получен подстановкой формата в шаблон. Реестром
|
|
||||||
автоматизировать **данные** (тот же tool, другой дефолтный output) — можно;
|
|
||||||
автоматизировать **текст** — нельзя: массовая подстановка {tool}×{format} в
|
|
||||||
шаблон — doorway-паттерн, риск deindex всего сайта.
|
|
||||||
|
|
||||||
Как правильно получать видимость под «jpeg»/«webp» без клонов: широкие
|
|
||||||
формулировки закрывает контент существующей страницы (H1 «Crop image», «works
|
|
||||||
with PNG, JPG, WebP» в тексте + селектор формата в Download, S1f). Отдельные
|
|
||||||
форматные страницы — точечные исключения по вордстату, не серия.
|
|
||||||
|
|
||||||
### Convert-инструменты: страницы и «универсальный конвертер»
|
|
||||||
|
|
||||||
Разводить два разных интента:
|
|
||||||
|
|
||||||
- **«convert png to jpg»** — интент «страница-конвертер»: пользователь ищет
|
|
||||||
конкретную пару форматов (лучшее качество конверсии, меньше ошибок), это
|
|
||||||
готовые лендинги. Существующие (`convert-png-to-jpg`, `convert-png-to-webp`,
|
|
||||||
`png-to-bmp`, `svg-to-png`, `base64-to-png`, …) **оставить и наполнить
|
|
||||||
контентом как все**: свой H1 («Convert PNG to JPG — free, no upload»), FAQ про
|
|
||||||
качество/прозрачность/фон, related. Селектор формата (S1f) их **не заменяет**
|
|
||||||
— он про «уже открыл инструмент и хочу другой формат на выходе».
|
|
||||||
- **«convert image» / «image converter»** — интент «швейцарский нож». Под него —
|
|
||||||
**один будущий инструмент `/convert-image`**: сначала пустой **core-tool** в
|
|
||||||
`core/` — функция ре-энкода («копия с перекодированием»: декодирует любой
|
|
||||||
поддержанный исходник в ImageData и кодирует в выбранный формат; поверх неё
|
|
||||||
S1f строит селектор формата). Инструмент и страницу добавлять **только вместе
|
|
||||||
|
|
||||||
с собственным текстом** («Convert any image to PNG, JPG or WebP in your
|
|
||||||
browser») — core в коде не обязывает к странице. Этот же шаблон потом
|
|
||||||
закрывает форматные волны (jpg→webp и т.п.) без новых страниц.
|
|
||||||
|
|
||||||
Не делать: серию `convert-jpeg`/`convert-webp`-клонов с подстановкой формата
|
|
||||||
(doorway, см. выше) и «универсальный конвертер» как единственную страницу вместо
|
|
||||||
форматных лендингов — теряется ядро «convert png to jpg»-запросов.
|
|
||||||
|
|
||||||
Правило добавления новых инструментов: брать из хвоста конкурента только те
|
|
||||||
интенты, которые подтверждены вордстатом, и сразу с контентом S2 (страница без
|
|
||||||
текста — потраченный URL).
|
|
||||||
|
|
||||||
## 6. Переименование бренда и домен (сделать до S1-domain, дёшево сейчас)
|
|
||||||
|
|
||||||
Выбранное имя и домен **в этом репозитории не записаны**: детальный чек-лист
|
|
||||||
лежит в локальном файле `plan-domain.local.md` рядом с этим планом, он исключён
|
|
||||||
из git через `.gitignore`, чтобы имя не попало в историю коммитов до покупки
|
|
||||||
домена. Агент, работающий из клона, этого файла не увидит — при необходимости
|
|
||||||
попросить чек-лист у пользователя.
|
|
||||||
|
|
||||||
- [ ] **[R1]** Механический переезд бренда на новое имя. Список файлов и строк с
|
|
||||||
хардкодом — в локальном плане (раздел «Где живёт бренд в коде»); здесь
|
|
||||||
только SEO-ограничения. **НЕ трогать:** адреса страниц инструментов
|
|
||||||
(`/tools/crop-png`) и localStorage-ключи — `theme` и `locale` лежат без
|
|
||||||
префикса бренда, поэтому переименование с ними не столкнётся (проверено в
|
|
||||||
`theme.svelte.ts` и `i18n/locale.svelte.ts`); историю `docs/archive`.
|
|
||||||
Бренд должен стать i18n-константой, а не тремя независимыми хардкодами.
|
|
||||||
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
|
||||||
перевести прод на него без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
|
||||||
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
|
||||||
- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
|
|
||||||
блокируют). R2 (домен) — блокер для S1-domain (canonical + sitemap), а
|
|
||||||
S1-domain — блокер для S1h (снятие `noindex`). После индексации переименование
|
|
||||||
= переезд с 301, потеря позиций и ссылок — не делать.
|
|
||||||
|
|
||||||
## 7. Что осознанно НЕ делаем
|
|
||||||
|
|
||||||
- Клонирование всего каталога конкурента (низкокачественные дубли + нет ресурсов
|
|
||||||
на контент) — вместо этого ядро + обоснованные хвосты.
|
|
||||||
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
|
|
||||||
- Реклама, ремаркетинг, 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 (общее ядро, оффлайн-режим).
|
|
||||||
- `plan-domain.local.md` — локальный чек-лист переименования и покупки домена;
|
|
||||||
**в git не входит** (`.gitignore`), в клоне отсутствует.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# План развития: easy-png-tools
|
|
||||||
|
|
||||||
> **Статус: active.** Фаза 1 выполнена — сайт живёт на типизированном реестре,
|
|
||||||
> production UI — корневые маршруты. Текущий фокус: SEO S1-free
|
|
||||||
> (`docs/plan-seo.md`) + открытые задачи (`docs/backlog.md`). Домен нужен для
|
|
||||||
> SEO (S1-domain). Фазы 2–8 — будущие; 9–10 спланированы в
|
|
||||||
> `docs/plan-platform.md`; 11 — пайплайн.
|
|
||||||
>
|
|
||||||
> **Идентификаторы.** Ссылаться на фазу этого документа. Пространства
|
|
||||||
> идентификаторов задач: `FA-*` — правки полей и раскладки
|
|
||||||
> (`docs/plan-fields-audit-fixes.md`); `S*` — SEO/GEO (`docs/plan-seo.md`); `A*`
|
|
||||||
> — API, `P*` — PWA, `F*` — шрифты (`docs/plan-platform.md`); `R*` — бренд и
|
|
||||||
> домен (`docs/plan-seo.md`, §6). Буквы `Q*`/`C*` в архивных документах
|
|
||||||
> относятся к волнам редизайна и как идентификаторы задач не используются.
|
|
||||||
|
|
||||||
## 0. Решения
|
|
||||||
|
|
||||||
Продуктовые и стратегические — здесь; устройство кода (реестр, исполнитель,
|
|
||||||
слои) — `docs/architecture.md`, разделы 2–3.
|
|
||||||
|
|
||||||
- **Набор утилит для изображений, всё в браузере** (SvelteKit +
|
|
||||||
`adapter-static`): файл картинки не покидает машину. Счётчик посещений 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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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; отдельные страницы под эти
|
|
||||||
интенты — предмет решения там же.
|
|
||||||
@@ -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>
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "easy-png-tools",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: {}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 79 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 225 B |
|
Before Width: | Height: | Size: 96 B |
|
Before Width: | Height: | Size: 203 B |
|
Before Width: | Height: | Size: 290 B |
|
Before Width: | Height: | Size: 601 B |
|
Before Width: | Height: | Size: 73 B |
@@ -1 +0,0 @@
|
|||||||
this is definitely not a png 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);
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 879 B |
|
Before Width: | Height: | Size: 722 B |
|
Before Width: | Height: | Size: 16 B |
@@ -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 +0,0 @@
|
|||||||
engine-strict=true
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
node_modules
|
|
||||||
build
|
|
||||||
.svelte-kit
|
|
||||||
dist
|
|
||||||
pnpm-lock.yaml
|
|
||||||
static
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
audit
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
import { deflateSync } from "node:zlib";
|
|
||||||
|
|
||||||
export interface SourceFile {
|
|
||||||
name: string;
|
|
||||||
mimeType: string;
|
|
||||||
buffer: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CRC_TABLE = (() => {
|
|
||||||
const table = new Uint32Array(256);
|
|
||||||
for (let n = 0; n < 256; n++) {
|
|
||||||
let c = n;
|
|
||||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
table[n] = c >>> 0;
|
|
||||||
}
|
|
||||||
return table;
|
|
||||||
})();
|
|
||||||
|
|
||||||
function crc32(data: Uint8Array): number {
|
|
||||||
let c = 0xffffffff;
|
|
||||||
for (let i = 0; i < data.length; i++)
|
|
||||||
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
|
||||||
return (c ^ 0xffffffff) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
||||||
|
|
||||||
function makeIhdr(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
bitDepth: number,
|
|
||||||
colorType: number,
|
|
||||||
): Buffer {
|
|
||||||
const ihdr = Buffer.alloc(13);
|
|
||||||
ihdr.writeUInt32BE(width, 0);
|
|
||||||
ihdr.writeUInt32BE(height, 4);
|
|
||||||
ihdr[8] = bitDepth;
|
|
||||||
ihdr[9] = colorType;
|
|
||||||
return ihdr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunk(type: string, data: Buffer): Buffer {
|
|
||||||
const out = Buffer.alloc(8 + data.length + 4);
|
|
||||||
out.writeUInt32BE(data.length, 0);
|
|
||||||
out.write(type, 4, "ascii");
|
|
||||||
data.copy(out, 8);
|
|
||||||
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findChunk(png: Buffer, type: string): number {
|
|
||||||
let offset = PNG_SIGNATURE.length;
|
|
||||||
while (offset + 12 <= png.length) {
|
|
||||||
const length = png.readUInt32BE(offset);
|
|
||||||
const chunkType = png.subarray(offset + 4, offset + 8).toString("ascii");
|
|
||||||
if (chunkType === type) return offset;
|
|
||||||
offset += 12 + length;
|
|
||||||
}
|
|
||||||
throw new Error(`PNG chunk ${type} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
|
|
||||||
export function makePng(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
|
||||||
): Buffer {
|
|
||||||
const stride = width * 4 + 1;
|
|
||||||
const raw = Buffer.alloc(height * stride);
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const [r, g, b, a] = pixelAt(x, y);
|
|
||||||
const p = y * stride + 1 + x * 4;
|
|
||||||
raw[p] = r;
|
|
||||||
raw[p + 1] = g;
|
|
||||||
raw[p + 2] = b;
|
|
||||||
raw[p + 3] = a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Buffer.concat([
|
|
||||||
PNG_SIGNATURE,
|
|
||||||
chunk("IHDR", makeIhdr(width, height, 8, 6)),
|
|
||||||
chunk("IDAT", deflateSync(raw)),
|
|
||||||
chunk("IEND", Buffer.alloc(0)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeIndexedPng(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
palette: [number, number, number][],
|
|
||||||
pixelAt: (x: number, y: number) => number,
|
|
||||||
): Buffer {
|
|
||||||
const plte = Buffer.alloc(palette.length * 3);
|
|
||||||
for (const [index, color] of palette.entries()) {
|
|
||||||
plte[index * 3] = color[0];
|
|
||||||
plte[index * 3 + 1] = color[1];
|
|
||||||
plte[index * 3 + 2] = color[2];
|
|
||||||
}
|
|
||||||
const stride = width + 1;
|
|
||||||
const raw = Buffer.alloc(height * stride);
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
raw[y * stride + 1 + x] = pixelAt(x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Buffer.concat([
|
|
||||||
PNG_SIGNATURE,
|
|
||||||
chunk("IHDR", makeIhdr(width, height, 8, 3)),
|
|
||||||
chunk("PLTE", plte),
|
|
||||||
chunk("IDAT", deflateSync(raw)),
|
|
||||||
chunk("IEND", Buffer.alloc(0)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function make16BitPng(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
|
||||||
): Buffer {
|
|
||||||
const stride = width * 8 + 1;
|
|
||||||
const raw = Buffer.alloc(height * stride);
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const [r, g, b, a] = pixelAt(x, y);
|
|
||||||
const p = y * stride + 1 + x * 8;
|
|
||||||
raw.writeUInt16BE(r, p);
|
|
||||||
raw.writeUInt16BE(g, p + 2);
|
|
||||||
raw.writeUInt16BE(b, p + 4);
|
|
||||||
raw.writeUInt16BE(a, p + 6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Buffer.concat([
|
|
||||||
PNG_SIGNATURE,
|
|
||||||
chunk("IHDR", makeIhdr(width, height, 16, 6)),
|
|
||||||
chunk("IDAT", deflateSync(raw)),
|
|
||||||
chunk("IEND", Buffer.alloc(0)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withPngChunk(png: Buffer, type: string, data: Buffer): Buffer {
|
|
||||||
const iendOffset = png.length - 12;
|
|
||||||
return Buffer.concat([
|
|
||||||
png.subarray(0, iendOffset),
|
|
||||||
chunk(type, data),
|
|
||||||
png.subarray(iendOffset),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withBadChunkCrc(png: Buffer, type: string): Buffer {
|
|
||||||
const copy = Buffer.from(png);
|
|
||||||
const offset = findChunk(copy, type);
|
|
||||||
const length = copy.readUInt32BE(offset);
|
|
||||||
copy[offset + 8 + length + 3] ^= 0xff;
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function truncatePng(png: Buffer, length: number): Buffer {
|
|
||||||
return Buffer.from(png.subarray(0, length));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function truncateChunkData(
|
|
||||||
png: Buffer,
|
|
||||||
type: string,
|
|
||||||
length: number,
|
|
||||||
): Buffer {
|
|
||||||
const offset = findChunk(png, type);
|
|
||||||
return truncatePng(png, offset + 8 + length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withTrailingBytes(png: Buffer, tail: Buffer): Buffer {
|
|
||||||
return Buffer.concat([png, Buffer.from(tail)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const checker = (x: number, y: number): [number, number, number, number] =>
|
|
||||||
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
|
|
||||||
|
|
||||||
const alphaGrid = (x: number, y: number): [number, number, number, number] =>
|
|
||||||
(x + y) % 3 === 0 ? [0, 0, 0, 0] : [0, 128, 255, 255];
|
|
||||||
|
|
||||||
const solidRed = (): [number, number, number, number] => [255, 0, 0, 255];
|
|
||||||
|
|
||||||
export function asSourceFile(buffer: Buffer, name: string): SourceFile {
|
|
||||||
return { name, mimeType: "image/png", buffer };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const opaquePng: SourceFile = asSourceFile(
|
|
||||||
makePng(64, 48, checker),
|
|
||||||
"opaque.png",
|
|
||||||
);
|
|
||||||
export const transparentPng: SourceFile = asSourceFile(
|
|
||||||
makePng(64, 64, alphaGrid),
|
|
||||||
"transparent.png",
|
|
||||||
);
|
|
||||||
export const onePixelPng: SourceFile = asSourceFile(
|
|
||||||
makePng(1, 1, solidRed),
|
|
||||||
"1px.png",
|
|
||||||
);
|
|
||||||
export const landscapePng: SourceFile = asSourceFile(
|
|
||||||
makePng(64, 16, checker),
|
|
||||||
"landscape.png",
|
|
||||||
);
|
|
||||||
export const largePng: SourceFile = asSourceFile(
|
|
||||||
makePng(256, 256, checker),
|
|
||||||
"large.png",
|
|
||||||
);
|
|
||||||
export const corruptPng: SourceFile = {
|
|
||||||
name: "corrupt.png",
|
|
||||||
mimeType: "image/png",
|
|
||||||
buffer: Buffer.from("this is definitely not a png file", "utf8"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const specialPng = makePng(8, 8, checker);
|
|
||||||
const specialWithText = withPngChunk(
|
|
||||||
specialPng,
|
|
||||||
"tEXt",
|
|
||||||
Buffer.from("Comment\0q6d", "ascii"),
|
|
||||||
);
|
|
||||||
const palette4 = [
|
|
||||||
[255, 0, 0],
|
|
||||||
[0, 255, 0],
|
|
||||||
[0, 0, 255],
|
|
||||||
[255, 255, 255],
|
|
||||||
] as [number, number, number][];
|
|
||||||
const palette256 = Array.from({ length: 256 }, (_, index) => [
|
|
||||||
index,
|
|
||||||
(index * 3) % 256,
|
|
||||||
(index * 7) % 256,
|
|
||||||
]) as [number, number, number][];
|
|
||||||
|
|
||||||
export const crcBadIdatPng = asSourceFile(
|
|
||||||
withBadChunkCrc(specialPng, "IDAT"),
|
|
||||||
"crc-bad-idat.png",
|
|
||||||
);
|
|
||||||
export const crcBadAncillaryPng = asSourceFile(
|
|
||||||
withBadChunkCrc(specialWithText, "tEXt"),
|
|
||||||
"crc-bad-ancillary.png",
|
|
||||||
);
|
|
||||||
export const truncatedHeaderPng = asSourceFile(
|
|
||||||
truncatePng(specialPng, PNG_SIGNATURE.length + 12 + 13),
|
|
||||||
"truncated-header.png",
|
|
||||||
);
|
|
||||||
export const truncatedNoIendPng = asSourceFile(
|
|
||||||
truncatePng(specialPng, specialPng.length - 12),
|
|
||||||
"truncated-no-iend.png",
|
|
||||||
);
|
|
||||||
export const idatCutPng = asSourceFile(
|
|
||||||
truncateChunkData(specialPng, "IDAT", 4),
|
|
||||||
"idat-cut.png",
|
|
||||||
);
|
|
||||||
export const garbageTailPng = asSourceFile(
|
|
||||||
withTrailingBytes(specialPng, Buffer.from("trailing-garbage", "ascii")),
|
|
||||||
"garbage-tail.png",
|
|
||||||
);
|
|
||||||
export const palette4Png = asSourceFile(
|
|
||||||
makeIndexedPng(
|
|
||||||
8,
|
|
||||||
8,
|
|
||||||
palette4,
|
|
||||||
(x, y) => (Math.floor(x / 2) + Math.floor(y / 2)) % palette4.length,
|
|
||||||
),
|
|
||||||
"palette-4.png",
|
|
||||||
);
|
|
||||||
export const palette256Png = asSourceFile(
|
|
||||||
makeIndexedPng(16, 16, palette256, (x, y) => (x + y * 16) % 256),
|
|
||||||
"palette-256.png",
|
|
||||||
);
|
|
||||||
export const sixteenBitPng = asSourceFile(
|
|
||||||
make16BitPng(8, 8, (x, y) => [x * 8000, y * 8000, 32768, 65535]),
|
|
||||||
"16bit.png",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
|
|
||||||
|
|
||||||
export const svgMarkup =
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6">' +
|
|
||||||
'<rect width="10" height="6" fill="#ff0000"/></svg>';
|
|
||||||
|
|
||||||
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
|
|
||||||
`${r} ${g} ${b} ${a}`;
|
|
||||||
|
|
||||||
/** Строка для bytes-to-png / rgb-values-to-png, ровно 32 пикселя в ширину по умолчанию. */
|
|
||||||
export function pixelRow(
|
|
||||||
count: number,
|
|
||||||
rgba: [number, number, number, number],
|
|
||||||
): string {
|
|
||||||
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import {
|
|
||||||
expect,
|
|
||||||
type Download,
|
|
||||||
type Locator,
|
|
||||||
type Page,
|
|
||||||
} from "playwright/test";
|
|
||||||
import type { SourceFile } from "./fixtures";
|
|
||||||
|
|
||||||
export const TEST_IDS = {
|
|
||||||
source: "source-image",
|
|
||||||
result: "result-image",
|
|
||||||
verdict: "result-verdict",
|
|
||||||
empty: "empty-state",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type ErrorSink = { errors: string[] };
|
|
||||||
|
|
||||||
export function trackErrors(page: Page): ErrorSink {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
|
|
||||||
page.on("console", (m) => {
|
|
||||||
if (m.type() === "error") errors.push(`console: ${m.text()}`);
|
|
||||||
});
|
|
||||||
return { errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function useEnglish(page: Page): Promise<void> {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
window.localStorage.setItem("locale", "en");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openTool(page: Page, slug: string): Promise<void> {
|
|
||||||
await useEnglish(page);
|
|
||||||
await page.goto(`/tools/${slug}`);
|
|
||||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toolLink(page: Page, slug: string): Locator {
|
|
||||||
return page.locator(`a[href="/tools/${slug}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resultImage(page: Page): Locator {
|
|
||||||
return page.getByTestId(TEST_IDS.result);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sourceImage(page: Page): Locator {
|
|
||||||
return page.getByTestId(TEST_IDS.source);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verdictStatus(page: Page): Locator {
|
|
||||||
return page.getByTestId(TEST_IDS.verdict);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function textResult(page: Page): Locator {
|
|
||||||
return page.getByLabel("Text result");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorAlert(page: Page): Locator {
|
|
||||||
return page.getByRole("alert");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyState(page: Page): Locator {
|
|
||||||
return page.getByTestId(TEST_IDS.empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rangeInput(page: Page, name: RegExp | string): Locator {
|
|
||||||
return page.getByRole("slider", { name });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderText(page: Page, value: string): Promise<void> {
|
|
||||||
const input = page.getByRole("textbox", { name: "Text data" });
|
|
||||||
await input.fill(value);
|
|
||||||
await page.getByRole("button", { name: "Render text" }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
|
||||||
const input = page.locator('input[type="file"]');
|
|
||||||
await expect(input).toHaveCount(1);
|
|
||||||
await input.setInputFiles({
|
|
||||||
name: file.name,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
buffer: file.buffer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadResultFile(page: Page): Promise<Download> {
|
|
||||||
const button = page.getByRole("button", { name: "Download result" });
|
|
||||||
await expect(button).toBeVisible();
|
|
||||||
const [download] = await Promise.all([
|
|
||||||
page.waitForEvent("download"),
|
|
||||||
button.click(),
|
|
||||||
]);
|
|
||||||
return download;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadResultBytes(
|
|
||||||
page: Page,
|
|
||||||
): Promise<{ name: string; bytes: Uint8Array }> {
|
|
||||||
const download = await downloadResultFile(page);
|
|
||||||
const path = await download.path();
|
|
||||||
if (!path) throw new Error("download path is unavailable");
|
|
||||||
return {
|
|
||||||
name: download.suggestedFilename(),
|
|
||||||
bytes: new Uint8Array(await readFile(path)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadResult(page: Page): Promise<string> {
|
|
||||||
return (await downloadResultFile(page)).suggestedFilename();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
|
||||||
await expect(errorAlert(page)).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
|
||||||
expect(sink.errors, "нет console/page errors").toEqual([]);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { expect, test } from "playwright/test";
|
|
||||||
import { pixelRow } from "./helpers/fixtures";
|
|
||||||
import { openTool, toolLink, useEnglish } from "./helpers/page";
|
|
||||||
|
|
||||||
test("поиск каталога находит по названию из другой локали", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await useEnglish(page);
|
|
||||||
await page.goto("/list-tools");
|
|
||||||
const input = page.getByRole("textbox");
|
|
||||||
const flipCard = toolLink(page, "flip-png");
|
|
||||||
await input.fill("отразить");
|
|
||||||
await expect(flipCard).toContainText("Flip PNG");
|
|
||||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
|
||||||
await expect(flipCard).toContainText("Отразить PNG");
|
|
||||||
await input.fill("flip");
|
|
||||||
await expect(flipCard).toContainText("Отразить PNG");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("RU/EN: переключение переводит заголовок инструмента", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await openTool(page, "resize-png");
|
|
||||||
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
|
|
||||||
const enBtn = page.getByRole("button", { name: "EN", exact: true });
|
|
||||||
const heading = page.getByRole("heading", { level: 1 });
|
|
||||||
await ruBtn.click();
|
|
||||||
await expect(heading).toHaveText("Изменить размер PNG");
|
|
||||||
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
|
||||||
await enBtn.click();
|
|
||||||
await expect(heading).toHaveText("Resize PNG");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ошибка run'а локализуется и переводится при смене языка", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await openTool(page, "bytes-to-png");
|
|
||||||
await page
|
|
||||||
.getByRole("textbox", { name: "Text data" })
|
|
||||||
.fill(pixelRow(33, [255, 0, 0, 255]));
|
|
||||||
await page.getByRole("button", { name: "Render text" }).click();
|
|
||||||
const alert = page.getByRole("alert");
|
|
||||||
await expect(alert).toBeVisible();
|
|
||||||
await expect(alert).toHaveText(/\S/);
|
|
||||||
await expect(alert).not.toHaveText(/^errors\./);
|
|
||||||
const english = await alert.textContent();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
|
||||||
await expect(alert).not.toHaveText(english ?? "");
|
|
||||||
await expect(alert).not.toHaveText(/^errors\./);
|
|
||||||
});
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { expect, test, type Page } from "playwright/test";
|
|
||||||
import {
|
|
||||||
crcBadAncillaryPng,
|
|
||||||
crcBadIdatPng,
|
|
||||||
garbageTailPng,
|
|
||||||
idatCutPng,
|
|
||||||
palette4Png,
|
|
||||||
palette256Png,
|
|
||||||
sixteenBitPng,
|
|
||||||
truncatedHeaderPng,
|
|
||||||
truncatedNoIendPng,
|
|
||||||
type SourceFile,
|
|
||||||
} from "./helpers/fixtures";
|
|
||||||
import {
|
|
||||||
errorAlert,
|
|
||||||
expectNoErrorAlert,
|
|
||||||
expectNoErrors,
|
|
||||||
openTool,
|
|
||||||
resultImage,
|
|
||||||
trackErrors,
|
|
||||||
uploadImage,
|
|
||||||
} from "./helpers/page";
|
|
||||||
|
|
||||||
async function expectRejected(page: Page, file: SourceFile): Promise<void> {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "flip-png");
|
|
||||||
await uploadImage(page, file);
|
|
||||||
await expect(errorAlert(page)).toBeVisible();
|
|
||||||
await expect(resultImage(page)).toHaveCount(0);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectAccepted(page: Page, file: SourceFile): Promise<void> {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "flip-png");
|
|
||||||
await uploadImage(page, file);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DecodeContract = {
|
|
||||||
file: SourceFile;
|
|
||||||
rejectedIn: readonly string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeContracts: readonly DecodeContract[] = [
|
|
||||||
{ file: crcBadIdatPng, rejectedIn: ["chromium", "firefox"] },
|
|
||||||
{ file: truncatedHeaderPng, rejectedIn: ["chromium", "firefox", "webkit"] },
|
|
||||||
{ file: truncatedNoIendPng, rejectedIn: ["chromium"] },
|
|
||||||
{ file: idatCutPng, rejectedIn: ["chromium"] },
|
|
||||||
{ file: crcBadAncillaryPng, rejectedIn: [] },
|
|
||||||
{ file: garbageTailPng, rejectedIn: [] },
|
|
||||||
{ file: palette4Png, rejectedIn: [] },
|
|
||||||
{ file: palette256Png, rejectedIn: [] },
|
|
||||||
{ file: sixteenBitPng, rejectedIn: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe("PNG special fixtures", () => {
|
|
||||||
for (const { file, rejectedIn } of decodeContracts) {
|
|
||||||
test(`${file.name}: matches the engine decode contract`, async ({
|
|
||||||
browserName,
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
if (rejectedIn.includes(browserName)) {
|
|
||||||
await expectRejected(page, file);
|
|
||||||
} else {
|
|
||||||
await expectAccepted(page, file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { unzipSync } from "fflate";
|
|
||||||
import { expect, test } from "playwright/test";
|
|
||||||
import {
|
|
||||||
corruptPng,
|
|
||||||
landscapePng,
|
|
||||||
largePng,
|
|
||||||
onePixelPng,
|
|
||||||
opaquePng,
|
|
||||||
} from "./helpers/fixtures";
|
|
||||||
import {
|
|
||||||
downloadResultBytes,
|
|
||||||
errorAlert,
|
|
||||||
expectNoErrorAlert,
|
|
||||||
expectNoErrors,
|
|
||||||
openTool,
|
|
||||||
rangeInput,
|
|
||||||
resultImage,
|
|
||||||
sourceImage,
|
|
||||||
trackErrors,
|
|
||||||
uploadImage,
|
|
||||||
useEnglish,
|
|
||||||
} from "./helpers/page";
|
|
||||||
|
|
||||||
test("resize-png uses the source dimensions by default", async ({ page }) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "resize-png");
|
|
||||||
await uploadImage(page, opaquePng);
|
|
||||||
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
|
||||||
"64",
|
|
||||||
);
|
|
||||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
|
||||||
"48",
|
|
||||||
);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("crop-png starts with the full source area", async ({ page }) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "crop-png");
|
|
||||||
await uploadImage(page, opaquePng);
|
|
||||||
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
|
||||||
"64",
|
|
||||||
);
|
|
||||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
|
||||||
"48",
|
|
||||||
);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("flip-png: full flow - upload, result, download", async ({ page }) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "flip-png");
|
|
||||||
await uploadImage(page, landscapePng);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
const { name, bytes } = await downloadResultBytes(page);
|
|
||||||
expect(name).toBe("flip-png.png");
|
|
||||||
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
|
||||||
137, 80, 78, 71, 13, 10, 26, 10,
|
|
||||||
]);
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("convert-png-to-jpg: produces JPEG and quality affects size", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "convert-png-to-jpg");
|
|
||||||
await uploadImage(page, largePng);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
const quality = rangeInput(page, "Quality");
|
|
||||||
await quality.fill("10");
|
|
||||||
const low = await downloadResultBytes(page);
|
|
||||||
expect(low.name).toBe("convert-png-to-jpg.jpg");
|
|
||||||
expect(Array.from(low.bytes.subarray(0, 3))).toEqual([255, 216, 255]);
|
|
||||||
await quality.fill("90");
|
|
||||||
const high = await downloadResultBytes(page);
|
|
||||||
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("convert-png-to-webp: produces WebP and quality affects size", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "convert-png-to-webp");
|
|
||||||
await uploadImage(page, largePng);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
const quality = rangeInput(page, "Quality");
|
|
||||||
await quality.fill("10");
|
|
||||||
const low = await downloadResultBytes(page);
|
|
||||||
expect(low.name).toBe("convert-png-to-webp.webp");
|
|
||||||
expect(Array.from(low.bytes.subarray(0, 4))).toEqual([82, 73, 70, 70]);
|
|
||||||
expect(Array.from(low.bytes.subarray(8, 12))).toEqual([87, 69, 66, 80]);
|
|
||||||
await quality.fill("90");
|
|
||||||
const high = await downloadResultBytes(page);
|
|
||||||
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("png-to-bmp: produces a BMP download", async ({ page }) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "png-to-bmp");
|
|
||||||
await uploadImage(page, opaquePng);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
const { name, bytes } = await downloadResultBytes(page);
|
|
||||||
expect(name).toBe("png-to-bmp.bmp");
|
|
||||||
expect(Array.from(bytes.subarray(0, 2))).toEqual([66, 77]);
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reset restores defaults and re-runs with the source", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "blur-png");
|
|
||||||
await uploadImage(page, opaquePng);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
const radius = rangeInput(page, /radius/i);
|
|
||||||
await radius.fill("20");
|
|
||||||
await expect(radius).toHaveValue("20");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Reset" }).click();
|
|
||||||
await expect(radius).toHaveValue("4");
|
|
||||||
await expect(sourceImage(page)).toBeVisible();
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("split-into-parts-png downloads a zip with named PNG parts", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "split-into-parts-png");
|
|
||||||
await uploadImage(page, landscapePng);
|
|
||||||
await rangeInput(page, "Columns").fill("3");
|
|
||||||
await rangeInput(page, "Rows").fill("2");
|
|
||||||
await expect(page.getByText("part-1-3.png")).toBeVisible();
|
|
||||||
|
|
||||||
const { name, bytes } = await downloadResultBytes(page);
|
|
||||||
expect(name).toBe("split-into-parts-png.zip");
|
|
||||||
const entries = unzipSync(bytes);
|
|
||||||
|
|
||||||
expect(Object.keys(entries)).toEqual([
|
|
||||||
"part-1-1.png",
|
|
||||||
"part-1-2.png",
|
|
||||||
"part-1-3.png",
|
|
||||||
"part-2-1.png",
|
|
||||||
"part-2-2.png",
|
|
||||||
"part-2-3.png",
|
|
||||||
]);
|
|
||||||
for (const bytes of Object.values(entries)) {
|
|
||||||
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
|
||||||
137, 80, 78, 71, 13, 10, 26, 10,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("blur: changing slider updates result image", async ({ page }) => {
|
|
||||||
await openTool(page, "blur-png");
|
|
||||||
await uploadImage(page, opaquePng);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
const firstSource = await resultImage(page).getAttribute("src");
|
|
||||||
await rangeInput(page, /radius/i).fill("20");
|
|
||||||
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
|
||||||
const context = await browser.newContext();
|
|
||||||
try {
|
|
||||||
await context.addInitScript(() => {
|
|
||||||
Object.defineProperty(window, "Worker", {
|
|
||||||
value: undefined,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
await useEnglish(page);
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "flip-png");
|
|
||||||
await uploadImage(page, landscapePng);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "flip-png");
|
|
||||||
await uploadImage(page, corruptPng);
|
|
||||||
await expect(errorAlert(page)).toBeVisible();
|
|
||||||
await expect(resultImage(page)).toHaveCount(0);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const fixture of [onePixelPng, largePng]) {
|
|
||||||
test(`handles ${fixture.name} correctly`, async ({ page }) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
await openTool(page, "flip-png");
|
|
||||||
await uploadImage(page, fixture);
|
|
||||||
await expect(resultImage(page)).toBeVisible();
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { expect, test } from "playwright/test";
|
|
||||||
import type { SourceFile } from "./helpers/fixtures";
|
|
||||||
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
|
||||||
import {
|
|
||||||
expectNoErrorAlert,
|
|
||||||
expectNoErrors,
|
|
||||||
openTool,
|
|
||||||
resultImage,
|
|
||||||
trackErrors,
|
|
||||||
textResult,
|
|
||||||
uploadImage,
|
|
||||||
verdictStatus,
|
|
||||||
} from "./helpers/page";
|
|
||||||
|
|
||||||
type Kind = "image" | "text-out" | "verdict";
|
|
||||||
|
|
||||||
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
|
|
||||||
// ── convert ────────────────────────────────────────────────
|
|
||||||
{ id: "convert-png-to-jpg", kind: "image" },
|
|
||||||
{ id: "convert-png-to-webp", kind: "image" },
|
|
||||||
{ id: "png-to-bmp", kind: "image" },
|
|
||||||
{ id: "png-to-base64", kind: "text-out" },
|
|
||||||
{ id: "png-to-data-uri", kind: "text-out" },
|
|
||||||
{ id: "png-to-hex", kind: "text-out" },
|
|
||||||
{ id: "png-to-bytes", kind: "text-out" },
|
|
||||||
{ id: "png-to-rgb-values", kind: "text-out" },
|
|
||||||
// ── alpha ──────────────────────────────────────────────────
|
|
||||||
{ id: "remove-background-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "remove-color-from-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "round-corners-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "add-stroke-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "circle-mask-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "wavy-mask-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "find-contour-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "feather-edges-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "clean-edges-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "make-thicker-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "make-thinner-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "despeckle-alpha-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "close-holes-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "harden-alpha-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "invert-alpha-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "set-alpha-channel-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "extract-alpha-mask-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "remove-alpha-channel-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "square-mask-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "star-mask-png", kind: "image", file: transparentPng },
|
|
||||||
// ── color ──────────────────────────────────────────────────
|
|
||||||
{ id: "grayscale-png", kind: "image" },
|
|
||||||
{ id: "invert-colors-png", kind: "image" },
|
|
||||||
{ id: "sepia-png", kind: "image" },
|
|
||||||
{ id: "quantize-png", kind: "image" },
|
|
||||||
{ id: "dithering-png", kind: "image" },
|
|
||||||
{ id: "decrease-color-count-png", kind: "image" },
|
|
||||||
{ id: "two-colors-png", kind: "image" },
|
|
||||||
{ id: "tint-png", kind: "image" },
|
|
||||||
{ id: "auto-contrast-png", kind: "image" },
|
|
||||||
{ id: "png-to-hsl", kind: "image" },
|
|
||||||
{ id: "png-to-cmyk", kind: "image" },
|
|
||||||
{ id: "gamma-png", kind: "image" },
|
|
||||||
{ id: "temperature-png", kind: "image" },
|
|
||||||
{ id: "custom-palette-png", kind: "image" },
|
|
||||||
{ id: "adjust-brightness-contrast-png", kind: "image" },
|
|
||||||
{ id: "change-png-opacity", kind: "image" },
|
|
||||||
{ id: "change-png-hue", kind: "image" },
|
|
||||||
{ id: "swap-channels-png", kind: "image" },
|
|
||||||
{ id: "black-and-white-png", kind: "image" },
|
|
||||||
{ id: "posterize-png", kind: "image" },
|
|
||||||
{ id: "png-to-hsv", kind: "image" },
|
|
||||||
{ id: "png-to-hsi", kind: "image" },
|
|
||||||
{ id: "png-to-ycbcr", kind: "image" },
|
|
||||||
{ id: "png-to-lab", kind: "image" },
|
|
||||||
// ── geometry ───────────────────────────────────────────────
|
|
||||||
{ id: "flip-png", kind: "image" },
|
|
||||||
{ id: "rotate-png", kind: "image" },
|
|
||||||
{ id: "rotate-free-png", kind: "image" },
|
|
||||||
{ id: "fit-on-background-png", kind: "image" },
|
|
||||||
{ id: "add-padding-png", kind: "image" },
|
|
||||||
{ id: "add-border-png", kind: "image" },
|
|
||||||
{ id: "tile-png", kind: "image" },
|
|
||||||
{ id: "change-canvas-size-png", kind: "image" },
|
|
||||||
{ id: "trim-empty-space-png", kind: "image" },
|
|
||||||
{ id: "swap-orientation-png", kind: "image" },
|
|
||||||
{ id: "skew-png", kind: "image" },
|
|
||||||
{ id: "zoom-png", kind: "image" },
|
|
||||||
{ id: "center-by-alpha-png", kind: "image" },
|
|
||||||
{ id: "change-aspect-ratio-png", kind: "image" },
|
|
||||||
{ id: "symmetric-copy-png", kind: "image" },
|
|
||||||
{ id: "shift-png", kind: "image" },
|
|
||||||
// ── filters ────────────────────────────────────────────────
|
|
||||||
{ id: "blur-png", kind: "image" },
|
|
||||||
{ id: "sharpen-png", kind: "image" },
|
|
||||||
{ id: "pixelate-png", kind: "image" },
|
|
||||||
{ id: "add-noise-png", kind: "image" },
|
|
||||||
{ id: "vignette-png", kind: "image" },
|
|
||||||
{ id: "silhouette-png", kind: "image" },
|
|
||||||
{ id: "randomize-pixels-png", kind: "image" },
|
|
||||||
{ id: "jpeg-artifacts-png", kind: "image" },
|
|
||||||
// ── text ───────────────────────────────────────────────────
|
|
||||||
{ id: "add-text-png", kind: "image" },
|
|
||||||
{ id: "date-stamp-png", kind: "image" },
|
|
||||||
{ id: "watermark-tile-png", kind: "image" },
|
|
||||||
// ── analyze (image output) ─────────────────────────────────
|
|
||||||
{ id: "extract-color-from-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "show-transparent-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "light-pixel-mask-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "extract-channel-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "unique-color-mask-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "show-grayscale-pixels-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "show-color-pixels-png", kind: "image", file: transparentPng },
|
|
||||||
{ id: "dark-pixel-mask-png", kind: "image", file: transparentPng },
|
|
||||||
// ── analyze (verdict output) ───────────────────────────────
|
|
||||||
{ id: "png-is-transparent", kind: "verdict" },
|
|
||||||
{ id: "png-is-grayscale", kind: "verdict" },
|
|
||||||
{ id: "png-orientation", kind: "verdict" },
|
|
||||||
{ id: "png-file-size", kind: "verdict" },
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe("smoke: tools produce output without errors", () => {
|
|
||||||
for (const { id, kind, file: fixture } of CASES) {
|
|
||||||
test(`tool ${id} (${kind})`, async ({ page }) => {
|
|
||||||
const sink = trackErrors(page);
|
|
||||||
|
|
||||||
const outputLocator = () => {
|
|
||||||
switch (kind) {
|
|
||||||
case "image":
|
|
||||||
return resultImage(page);
|
|
||||||
case "text-out":
|
|
||||||
return textResult(page);
|
|
||||||
case "verdict":
|
|
||||||
return verdictStatus(page);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// toPass: устойчивость к hydration-рейсу (ввод до гидрации SvelteKit
|
|
||||||
// может не обработаться с первого раза).
|
|
||||||
await expect(async () => {
|
|
||||||
await openTool(page, id);
|
|
||||||
await uploadImage(page, fixture ?? opaquePng);
|
|
||||||
await expect(outputLocator()).toBeVisible({ timeout: 8_000 });
|
|
||||||
}).toPass({ timeout: 25_000 });
|
|
||||||
|
|
||||||
await expectNoErrorAlert(page);
|
|
||||||
expectNoErrors(sink);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
# ESLint-плагины и дизайн-токены: детали
|
|
||||||
|
|
||||||
> Статус: **active**. Область применения — `web/eslint-plugins/**`,
|
|
||||||
> `web/scripts/**`, конфиги линтинга. Остальная рутина разработки её не требует;
|
|
||||||
> детали нужны только при правке самих линт-правил или конфигов. Команды и
|
|
||||||
> пороги — `docs/quality-gates.md`.
|
|
||||||
|
|
||||||
## Структура
|
|
||||||
|
|
||||||
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
|
||||||
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
|
||||||
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
|
||||||
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
|
||||||
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
|
||||||
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
|
||||||
токенов) и мини-словари `lib/i18n/`.
|
|
||||||
- `web/scripts/`:
|
|
||||||
- `lint-all.mjs` — оркестратор `lint:all`.
|
|
||||||
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
|
||||||
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
|
|
||||||
- `__tests__/` — тесты аудита и HCT-плагина; подхватываются обычным
|
|
||||||
`vitest run` через `scripts/**/*.test.mjs`, поэтому входят в `verify`.
|
|
||||||
- `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css.
|
|
||||||
|
|
||||||
## Конфигурация ESLint (`web/eslint.config.js`)
|
|
||||||
|
|
||||||
Устроена инкрементально:
|
|
||||||
|
|
||||||
- На **весь код** — парсинг TS/Svelte + правило
|
|
||||||
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
|
|
||||||
`prefer: 'type-imports'`).
|
|
||||||
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
|
|
||||||
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
|
|
||||||
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
|
|
||||||
устаревшие пути в `eslint.config.js`.
|
|
||||||
- Базовый `js.configs.recommended` + `globals.node` навешаны на инфраструктуру
|
|
||||||
линтинга: `eslint-plugins/**/*.js` и `scripts/**/*.mjs`.
|
|
||||||
TS/svelte-рекомендации туда не подключаются.
|
|
||||||
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
|
||||||
`let`).
|
|
||||||
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
|
||||||
там запрещены tseslint) — при добавлении файлов в `__tests__/` /
|
|
||||||
`__fixtures__/` дописать их туда же.
|
|
||||||
- design-tokens правила применяются к тем же scoped-путям
|
|
||||||
(`src/lib/components/**`, `src/routes/**`).
|
|
||||||
- `i18n/no-hardcoded-user-text` применяется к обоим scoped-путям
|
|
||||||
(`src/lib/components/**` и `src/routes/**`): хардкод в `+page.svelte` и
|
|
||||||
`+layout.svelte` — такой же production-UI. Исключение одно — `/kit`, витрина с
|
|
||||||
намеренно латинскими подписями; выключается отдельным объектом конфига с
|
|
||||||
`files: ["**/src/routes/kit/**/*.svelte"]`, потому что негативные паттерны в
|
|
||||||
`files` не сужают область действия правила. Опция `allowWords` — brand не
|
|
||||||
хардкодится в правиле.
|
|
||||||
|
|
||||||
## Правила плагина design-tokens
|
|
||||||
|
|
||||||
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
|
|
||||||
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
|
|
||||||
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
|
|
||||||
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
|
|
||||||
color-токен (`--color-*`/`--brand-*`) и наоборот.
|
|
||||||
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
|
|
||||||
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
|
|
||||||
производные от токенов (`var()`, `calc()`, unitless-числа).
|
|
||||||
- `design-tokens/no-undefined-in-svelte` — `var(--x)` в `<style>` должен быть
|
|
||||||
определён в `src/app.css` (словарь токенов) или локально в компоненте. Файл
|
|
||||||
читается один раз и кэшируется (не `glob`-зависим).
|
|
||||||
|
|
||||||
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
|
||||||
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
|
||||||
|
|
||||||
## Плагин i18n (`i18n/`)
|
|
||||||
|
|
||||||
### `i18n/dict-consistency`
|
|
||||||
|
|
||||||
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
|
||||||
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
|
||||||
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
|
|
||||||
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
|
|
||||||
правила.
|
|
||||||
|
|
||||||
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
|
||||||
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
|
||||||
расхождения:
|
|
||||||
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
|
|
||||||
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
|
|
||||||
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
|
|
||||||
пропавшая целая секция репортится один раз (не по каждому потомку);
|
|
||||||
- **пустые значения**: `""` и строка из одних пробелов;
|
|
||||||
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
|
||||||
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
|
|
||||||
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
|
|
||||||
когда отклоняется одна-единственная локаль.
|
|
||||||
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
|
||||||
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
|
||||||
(синтакс, без типов).
|
|
||||||
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
|
||||||
(осознанные отклонения до достижения zero-warn).
|
|
||||||
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
|
|
||||||
отсутствовать только в базовой локали; в текущей конфигурации это тексты
|
|
||||||
страниц `pages.*.title` и `pages.*.description` (EN-строка берётся из
|
|
||||||
реестра). Пустые значения и placeholders для присутствующих ключей всё равно
|
|
||||||
проверяются.
|
|
||||||
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
|
|
||||||
проверяются на missing; в текущей конфигурации это неиспользуемые
|
|
||||||
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
|
|
||||||
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
|
||||||
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
|
||||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
|
||||||
диске.
|
|
||||||
|
|
||||||
### `i18n/no-hardcoded-user-text`
|
|
||||||
|
|
||||||
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
|
|
||||||
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
|
|
||||||
report-only (`warn`), baseline — 0 срабатываний.
|
|
||||||
|
|
||||||
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
|
|
||||||
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
|
|
||||||
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
|
|
||||||
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
|
|
||||||
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
|
|
||||||
скобках (`{expr}`) тоже — они считаются в другом месте.
|
|
||||||
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
|
|
||||||
имена файлов и версии (`result.png`, `v0.1.0`, `v1`), размеры (`512×512`),
|
|
||||||
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
|
|
||||||
`/kit` — витрина компонентов с намеренно латинскими подписями (исключение
|
|
||||||
живёт в `eslint.config.js`, а не в правиле).
|
|
||||||
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
|
|
||||||
продукта передаётся конфигом, а не зашито в правило.
|
|
||||||
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
|
|
||||||
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
|
|
||||||
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
|
|
||||||
|
|
||||||
## Токены-префиксы (целевой словарь дизайна)
|
|
||||||
|
|
||||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
|
||||||
переменные, которым разрешено быть hex/rgb, остальные цвета — только `hct()` и
|
|
||||||
только в app.css.
|
|
||||||
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
|
||||||
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
|
|
||||||
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в app.css,
|
|
||||||
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
|
|
||||||
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
|
|
||||||
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
|
|
||||||
подтягиваются через `@csstools/postcss-global-data`).
|
|
||||||
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
|
|
||||||
|
|
||||||
Нюансы stylelint:
|
|
||||||
|
|
||||||
- Правило `custom-property-pattern` тестирует паттерн **без** `--` (`--x` →
|
|
||||||
`x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
|
|
||||||
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
|
|
||||||
`ignore`, не в `except`): пустые строки между подряд идущими токенами
|
|
||||||
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
|
|
||||||
токены в app.css отдельными блоками.
|
|
||||||
|
|
||||||
## Токен-аудит (`check-tokens.mjs`)
|
|
||||||
|
|
||||||
Лёгкий оркестратор поверх `web/scripts/token-audit/*`. Проверяет:
|
|
||||||
|
|
||||||
- **Parity**: каждый цветовой токен из `:root` обязан иметь пару в
|
|
||||||
`[data-theme="dark"]` и наоборот. Производные токены (значение содержит
|
|
||||||
`var()`, напр. `hct(from var(--...))`) из пары исключены — они наследуют тему
|
|
||||||
автоматически.
|
|
||||||
- **hct-only для цветов**: ЛЮБОЕ цветовое значение в app.css обязано быть
|
|
||||||
`hct(...)` — и литерал, и производное `hct(from var(...) h c t)`. Исключения:
|
|
||||||
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
|
|
||||||
`color-mix(...)` (единственный легальный способ смешать два токена).
|
|
||||||
`oklch()/rgb()/#hex` в `--color-*` запрещены.
|
|
||||||
|
|
||||||
- **Резолв `var()`**: каждая ссылка `var(--x)` в app.css должна указывать на
|
|
||||||
токен, определённый в этом же файле. `var(--x, fallback)` не считается ошибкой
|
|
||||||
— fallback легален без определения. Провал (exit 1), потому что несуществующий
|
|
||||||
токен молча роняет объявление.
|
|
||||||
- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не
|
|
||||||
используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`,
|
|
||||||
поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение
|
|
||||||
оставить проверку неблокирующей — `docs/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,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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Shared harness for testing the custom ESLint rules in eslint-plugins/.
|
|
||||||
//
|
|
||||||
// Two strategies, picked per rule in the test files:
|
|
||||||
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
|
|
||||||
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
|
|
||||||
// - Linter API + fixtures — needed when a rule reads real files, such as
|
|
||||||
// no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
|
|
||||||
// dictionary. `verifyInFixtures` runs a rule with cwd fixed to
|
|
||||||
// `__fixtures__/`, without touching process.cwd() or the real src/ tree.
|
|
||||||
import { Linter } from "eslint";
|
|
||||||
import type { Rule } from "eslint";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export const FIXTURES_DIR = path.resolve(HERE, "../__fixtures__");
|
|
||||||
export const FIXTURES_SRC = path.join(FIXTURES_DIR, "src");
|
|
||||||
|
|
||||||
/** Flat-config shape the Linter.verify accepts. */
|
|
||||||
export type FlatConfig = Parameters<Linter["verify"]>[1];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a rule (or a config) against `code` as if it were the fixture file
|
|
||||||
* `relFile` under `__fixtures__/src`. Returns the lint messages.
|
|
||||||
*/
|
|
||||||
export function verifyInFixtures(
|
|
||||||
config: FlatConfig,
|
|
||||||
code: string,
|
|
||||||
relFile: string,
|
|
||||||
) {
|
|
||||||
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
|
|
||||||
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wrap a script body into a minimal Svelte component (runs in runes mode). */
|
|
||||||
export function svelteComponent(script: string): string {
|
|
||||||
return `<main>Hello</main>\n\n<script lang="ts">\n${script}\n</script>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Narrow an inferred JS rule to the typed `Rule.RuleModule`. The plugin rule
|
|
||||||
* files are plain JS, so TypeScript infers a widened shape (`meta.type: string`)
|
|
||||||
* that `RuleTester.run` rejects; the shape is structurally correct at runtime,
|
|
||||||
* which is why existing rules keep their JS inference and only the tests cast.
|
|
||||||
*/
|
|
||||||
export function asRuleModule(rule: unknown): Rule.RuleModule {
|
|
||||||
return rule as Rule.RuleModule;
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// Tests for the conventions/interface-props rule
|
|
||||||
// (web/eslint-plugins/conventions/interface-props.js).
|
|
||||||
//
|
|
||||||
// The rule is a pure AST check (no filesystem), so it runs through the standard
|
|
||||||
// RuleTester with svelte-eslint-parser + the TS sub-parser. No fixtures needed.
|
|
||||||
import { RuleTester } from "eslint";
|
|
||||||
import svelteParser from "svelte-eslint-parser";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
import { describe, it } from "vitest";
|
|
||||||
import interfaceProps from "../conventions/interface-props.js";
|
|
||||||
import { asRuleModule, svelteComponent } from "./helpers.js";
|
|
||||||
|
|
||||||
RuleTester.describe = describe;
|
|
||||||
RuleTester.it = it;
|
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
|
||||||
languageOptions: {
|
|
||||||
parser: svelteParser,
|
|
||||||
parserOptions: { parser: tseslint.parser },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const frame = (script: string) => ({
|
|
||||||
code: svelteComponent(script),
|
|
||||||
filename: "Component.svelte",
|
|
||||||
});
|
|
||||||
|
|
||||||
ruleTester.run("interface-props", asRuleModule(interfaceProps), {
|
|
||||||
valid: [
|
|
||||||
{ code: "<main>Hello</main>", filename: "Component.svelte" },
|
|
||||||
frame("let x = 1;"),
|
|
||||||
// $state / $derived calls are unrelated, not props.
|
|
||||||
frame("const s = $state(0);"),
|
|
||||||
frame("const doubled = $derived(s * 2);"),
|
|
||||||
frame(
|
|
||||||
"interface Props { label: string }\nlet { label }: Props = $props();",
|
|
||||||
),
|
|
||||||
frame(
|
|
||||||
"interface Props { label: string }\nlet { label = 'x' }: Props = $props();",
|
|
||||||
),
|
|
||||||
frame(
|
|
||||||
"interface Props { value: string }\nlet { value = $bindable() }: Props = $props();",
|
|
||||||
),
|
|
||||||
// Interface declared after the destructuring is still fine.
|
|
||||||
frame(
|
|
||||||
"let { label }: Props = $props();\ninterface Props { label: string }",
|
|
||||||
),
|
|
||||||
frame(
|
|
||||||
"interface Props { id: string; rest?: unknown }\nlet { id, ...rest }: Props = $props();",
|
|
||||||
),
|
|
||||||
frame(
|
|
||||||
"import type { Snippet } from 'svelte';\n" +
|
|
||||||
"interface Props { children?: Snippet }\n" +
|
|
||||||
"let { children }: Props = $props();",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
invalid: [
|
|
||||||
{
|
|
||||||
// Untyped destructuring with a local interface -> autofixable.
|
|
||||||
...frame("interface Props { label: string }\nlet { label } = $props();"),
|
|
||||||
output: svelteComponent(
|
|
||||||
"interface Props { label: string }\nlet { label }: Props = $props();",
|
|
||||||
),
|
|
||||||
errors: [{ messageId: "untypedDestructure" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Untyped destructuring without an interface -> report, no fix.
|
|
||||||
...frame("let { label } = $props();"),
|
|
||||||
errors: [{ messageId: "untypedDestructure" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Binding the whole props object instead of destructuring.
|
|
||||||
...frame("let props = $props();"),
|
|
||||||
errors: [{ messageId: "noDestructure" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Inline generic is banned outright.
|
|
||||||
...frame("const props = $props<{ a: string }>();"),
|
|
||||||
errors: [{ messageId: "inlineGeneric" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A different type name than the local interface Props.
|
|
||||||
...frame(
|
|
||||||
"interface Props { a: string }\nlet { a }: StageProps = $props();",
|
|
||||||
),
|
|
||||||
errors: [{ messageId: "notNamedProps" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Inline object props type instead of interface Props.
|
|
||||||
...frame("let { a }: { a: string } = $props();"),
|
|
||||||
errors: [{ messageId: "inlineObjectType" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Reference to Props without any local declaration.
|
|
||||||
...frame("let { a }: Props = $props();"),
|
|
||||||
errors: [{ messageId: "missingInterface" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// Tests for the i18n/no-hardcoded-user-text rule
|
|
||||||
// (web/eslint-plugins/i18n/no-hardcoded-user-text.js).
|
|
||||||
//
|
|
||||||
// The rule is a pure AST check over the Svelte template, so it runs through the
|
|
||||||
// standard RuleTester with svelte-eslint-parser + the TS sub-parser. The
|
|
||||||
// exclusion list (brand, formats, units, registry source strings) is the whole
|
|
||||||
// point of the pilot, so every exclusion has a case here.
|
|
||||||
import { RuleTester } from "eslint";
|
|
||||||
import svelteParser from "svelte-eslint-parser";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import rule, { hasUserWords } from "../i18n/no-hardcoded-user-text.js";
|
|
||||||
import { asRuleModule } from "./helpers.js";
|
|
||||||
|
|
||||||
RuleTester.describe = describe;
|
|
||||||
RuleTester.it = it;
|
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
|
||||||
languageOptions: {
|
|
||||||
parser: svelteParser,
|
|
||||||
parserOptions: { parser: tseslint.parser },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const OPTIONS = [{ allowWords: ["easy-png-tools"] }];
|
|
||||||
|
|
||||||
ruleTester.run("no-hardcoded-user-text", asRuleModule(rule), {
|
|
||||||
valid: [
|
|
||||||
// Translated text comes from t(), never from a literal.
|
|
||||||
{ code: '<p>{t("ui.reset")}</p>', filename: "Component.svelte" },
|
|
||||||
{
|
|
||||||
code: '<Button label={t("actions.download")} />',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
},
|
|
||||||
// Formats, units and file names are not copy.
|
|
||||||
{ code: "<p>PNG</p>", filename: "Component.svelte" },
|
|
||||||
{ code: "<p>result.png</p>", filename: "Component.svelte" },
|
|
||||||
{ code: "<p>512×512</p>", filename: "Component.svelte" },
|
|
||||||
{ code: '<span class="u">px</span>', filename: "Component.svelte" },
|
|
||||||
{ code: "<p>1.0 s</p>", filename: "Component.svelte" },
|
|
||||||
{ code: "<p>v0.1.0</p>", filename: "Component.svelte" },
|
|
||||||
// Registry source strings and other shouted identifiers.
|
|
||||||
{ code: "<p>BACKGROUND</p>", filename: "Component.svelte" },
|
|
||||||
{ code: '<StepCard type="TRANSFORM" />', filename: "Component.svelte" },
|
|
||||||
// Technical attributes are out of scope.
|
|
||||||
{
|
|
||||||
code: '<Button variant="outline" tone="danger" size="lg" />',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: '<a href="/tools/resize-png">{t("nav.home")}</a>',
|
|
||||||
filename: "x.svelte",
|
|
||||||
},
|
|
||||||
// Values written as expressions are computed elsewhere.
|
|
||||||
{
|
|
||||||
code: "<p>{value}</p>",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "<EmptyState title={copy} description={hint} />",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
},
|
|
||||||
// Brand configured as an allowed word.
|
|
||||||
{
|
|
||||||
code: "<span>easy-png-tools</span>",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
options: OPTIONS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "<footer><span>easy-png-tools</span><span>v0.1.0</span></footer>",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
options: OPTIONS,
|
|
||||||
},
|
|
||||||
// <style> and <script> bodies are code, not copy.
|
|
||||||
{
|
|
||||||
code: '<p>{t("ui.reset")}</p>\n<style>.a { color: red; }</style>',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
options: OPTIONS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: '<script>const hint = "internal message";</script>\n<p>{t("ui.reset")}</p>',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
options: OPTIONS,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
invalid: [
|
|
||||||
{
|
|
||||||
code: "<p>Visible text</p>",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: '<span class="axis-label">From</span>',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: '<Button label="Download result" />',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: '<Button label="outline" variant="outline" />',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: '<ImageCard alt="Preview of the processed image" />',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Mixed content: the text part carries copy, the unit part does not.
|
|
||||||
code: "<p>Result {value} px</p>",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Navigation copy is user text too.
|
|
||||||
code: '<a href="/tools/resize-png">Home</a>',
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Nested elements are walked too.
|
|
||||||
code: "<div><span><b>Delete file</b></span></div>",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Without the option the brand word is reported: the exclusion is
|
|
||||||
// configuration, not a hardcoded constant inside the rule.
|
|
||||||
code: "<span>easy-png-tools</span>",
|
|
||||||
filename: "Component.svelte",
|
|
||||||
errors: [{ messageId: "hardcodedText" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasUserWords", () => {
|
|
||||||
it("accepts technical fragments", () => {
|
|
||||||
for (const value of [
|
|
||||||
"",
|
|
||||||
" ",
|
|
||||||
"px",
|
|
||||||
"512×512",
|
|
||||||
"result.png",
|
|
||||||
"1.0 s",
|
|
||||||
"v0.1.0",
|
|
||||||
"× ·",
|
|
||||||
"BACKGROUND",
|
|
||||||
"·",
|
|
||||||
]) {
|
|
||||||
expect(hasUserWords(value), value).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts configured brand words", () => {
|
|
||||||
expect(hasUserWords("easy-png-tools", ["easy-png-tools"])).toBe(false);
|
|
||||||
expect(hasUserWords("easy-png-tools")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts formats and units next to real copy", () => {
|
|
||||||
expect(hasUserWords("PNG 512×512")).toBe(false);
|
|
||||||
expect(hasUserWords("Result PNG")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Tests for the conventions/no-string-union-alias rule
|
|
||||||
// (web/eslint-plugins/conventions/no-string-union-alias.js).
|
|
||||||
//
|
|
||||||
// The rule is a pure AST check (no filesystem), so it runs through the standard
|
|
||||||
// RuleTester: plain .ts via the tseslint parser, and (because the rule is also
|
|
||||||
// enabled on new .svelte scripts) .svelte via the svelte tester. No fixtures.
|
|
||||||
import { RuleTester } from "eslint";
|
|
||||||
import svelteParser from "svelte-eslint-parser";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
import { describe, it } from "vitest";
|
|
||||||
import noStringUnionAlias from "../conventions/no-string-union-alias.js";
|
|
||||||
import { asRuleModule, svelteComponent } from "./helpers.js";
|
|
||||||
|
|
||||||
RuleTester.describe = describe;
|
|
||||||
RuleTester.it = it;
|
|
||||||
|
|
||||||
const tsTester = new RuleTester({
|
|
||||||
languageOptions: {
|
|
||||||
parser: tseslint.parser,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const svelteTester = new RuleTester({
|
|
||||||
languageOptions: {
|
|
||||||
parser: svelteParser,
|
|
||||||
parserOptions: { parser: tseslint.parser },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const frame = (script: string) => ({
|
|
||||||
code: svelteComponent(script),
|
|
||||||
filename: "Component.svelte",
|
|
||||||
});
|
|
||||||
|
|
||||||
tsTester.run("no-string-union-alias", asRuleModule(noStringUnionAlias), {
|
|
||||||
valid: [
|
|
||||||
{ code: "type X = 1 | 2;", filename: "types.ts" },
|
|
||||||
{ code: "type X = 'a' | number;", filename: "types.ts" },
|
|
||||||
{ code: "type X = string;", filename: "types.ts" },
|
|
||||||
{ code: "const KIND = { a: 1, b: 2 } as const;", filename: "types.ts" },
|
|
||||||
{ code: "function f(x: 'a' | 'b') {}", filename: "types.ts" },
|
|
||||||
{ code: "const x: 'a' | 'b' = 'a';", filename: "types.ts" },
|
|
||||||
{ code: "type X = 'a' | TemplateStrings;", filename: "types.ts" },
|
|
||||||
],
|
|
||||||
invalid: [
|
|
||||||
{
|
|
||||||
code: "type Kind = 'a' | 'b' | 'c';",
|
|
||||||
filename: "types.ts",
|
|
||||||
errors: [{ messageId: "stringUnion" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Nested/parenthesized unions still resolve to string literals.
|
|
||||||
code: "type Kind = ('a' | 'b') | 'c';",
|
|
||||||
filename: "types.ts",
|
|
||||||
errors: [{ messageId: "stringUnion" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "type Lang =\n\t'en'\n\t| 'ru';",
|
|
||||||
filename: "types.ts",
|
|
||||||
errors: [{ messageId: "stringUnion" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// The rule also runs on new .svelte files (their scripts are TS).
|
|
||||||
svelteTester.run(
|
|
||||||
"no-string-union-alias (svelte script)",
|
|
||||||
asRuleModule(noStringUnionAlias),
|
|
||||||
{
|
|
||||||
valid: [frame("type X = 1 | 2;"), frame("const M = { a: 1 } as const;")],
|
|
||||||
invalid: [
|
|
||||||
{
|
|
||||||
...frame("type Kind = 'a' | 'b' | 'c';"),
|
|
||||||
errors: [{ messageId: "stringUnion" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Local ESLint plugin "conventions".
|
|
||||||
*
|
|
||||||
* Cross-cutting code conventions that the recommended rule sets don't enforce
|
|
||||||
* (and plain @typescript-eslint rules cannot express in one shot):
|
|
||||||
*
|
|
||||||
* - interface-props: Svelte 5 props always go through a local `interface
|
|
||||||
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
|
|
||||||
* type imports, no untyped destructuring);
|
|
||||||
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
|
|
||||||
* 'b'`) are banned in favor of a single `as const` object + indexed access,
|
|
||||||
* so literal sets live in exactly one place.
|
|
||||||
*
|
|
||||||
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
|
|
||||||
*/
|
|
||||||
import interfaceProps from "./interface-props.js";
|
|
||||||
import noStringUnionAlias from "./no-string-union-alias.js";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
name: "conventions",
|
|
||||||
version: "0.1.0",
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"interface-props": interfaceProps,
|
|
||||||
"no-string-union-alias": noStringUnionAlias,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
|
|
||||||
//
|
|
||||||
// Convention (AGENTS.md): every typed props of a component is described by a
|
|
||||||
// local `interface Props`, and the props are destructured with the annotation:
|
|
||||||
//
|
|
||||||
// interface Props {
|
|
||||||
// label: string;
|
|
||||||
// accent?: boolean;
|
|
||||||
// children?: Snippet;
|
|
||||||
// }
|
|
||||||
// let { label, accent = false, children }: Props = $props();
|
|
||||||
//
|
|
||||||
// Banned instead:
|
|
||||||
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
|
|
||||||
// type away from the file structure;
|
|
||||||
// - untyped destructuring `let { ... } = $props()`;
|
|
||||||
// - binding the whole props object (`const props = $props()`);
|
|
||||||
// - any props type name other than the local `interface Props`.
|
|
||||||
//
|
|
||||||
// Inline `import('...')` type queries are NOT checked here: they are already
|
|
||||||
// banned by @typescript-eslint/consistent-type-imports.
|
|
||||||
//
|
|
||||||
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
|
|
||||||
// $props() only exists in instance <script> blocks, so no scope/type info is
|
|
||||||
// needed. Interfaces are collected from every <script> (module + instance).
|
|
||||||
|
|
||||||
import { isInsideScriptElement } from "./utils.js";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
type: "suggestion",
|
|
||||||
fixable: "code",
|
|
||||||
docs: {
|
|
||||||
description:
|
|
||||||
"Require a local `interface Props` and `let { ... }: Props = $props()` for Svelte 5 props; ban inline generics, untyped destructuring and non-local props type names.",
|
|
||||||
category: "Svelte conventions",
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
messages: {
|
|
||||||
inlineGeneric:
|
|
||||||
"Avoid the inline generic `$props<T>()`; declare a local `interface Props` and destructure it: `let { ... }: Props = $props()`.",
|
|
||||||
untypedDestructure:
|
|
||||||
"Annotate the props destructuring with a local `interface Props`: `let { ... }: Props = $props()`.",
|
|
||||||
noDestructure:
|
|
||||||
"Destructure props with `let { ... }: Props = $props()` instead of binding the whole `$props()` object.",
|
|
||||||
notNamedProps:
|
|
||||||
"The props type must be the local `interface Props` (found '{{name}}').",
|
|
||||||
inlineObjectType:
|
|
||||||
"Declare a local `interface Props` instead of an inline object props type.",
|
|
||||||
missingInterface:
|
|
||||||
"No local `interface Props` is declared in this component; add one and use it as the props type.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const interfaces = new Set();
|
|
||||||
|
|
||||||
return {
|
|
||||||
Program(node) {
|
|
||||||
for (const child of node.body) {
|
|
||||||
if (child.type !== "SvelteScriptElement") continue;
|
|
||||||
for (const stmt of child.body ?? []) {
|
|
||||||
if (stmt.type !== "TSInterfaceDeclaration") continue;
|
|
||||||
interfaces.add(stmt.id.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
CallExpression(node) {
|
|
||||||
if (
|
|
||||||
node.callee.type !== "Identifier" ||
|
|
||||||
node.callee.name !== "$props"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isInsideScriptElement(node)) return;
|
|
||||||
|
|
||||||
// Inline generic $props<{ ... }>() is banned outright.
|
|
||||||
if (node.typeArguments?.params?.length) {
|
|
||||||
context.report({ node: node.callee, messageId: "inlineGeneric" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let declarator = node.parent;
|
|
||||||
while (declarator && declarator.type !== "VariableDeclarator") {
|
|
||||||
declarator = declarator.parent;
|
|
||||||
}
|
|
||||||
if (!declarator) return;
|
|
||||||
const id = declarator.id;
|
|
||||||
|
|
||||||
if (id.type === "ObjectPattern") {
|
|
||||||
const annotation = id.typeAnnotation?.typeAnnotation ?? null;
|
|
||||||
if (annotation) {
|
|
||||||
if (annotation.type === "TSTypeReference") {
|
|
||||||
const typeName = annotation.typeName;
|
|
||||||
if (!typeName || typeName.type !== "Identifier") return;
|
|
||||||
if (typeName.name !== "Props") {
|
|
||||||
context.report({
|
|
||||||
node: typeName,
|
|
||||||
messageId: "notNamedProps",
|
|
||||||
data: { name: typeName.name },
|
|
||||||
});
|
|
||||||
} else if (!interfaces.has("Props")) {
|
|
||||||
context.report({
|
|
||||||
node: typeName,
|
|
||||||
messageId: "missingInterface",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (annotation.type !== "TSImportType") {
|
|
||||||
context.report({
|
|
||||||
node: annotation,
|
|
||||||
messageId: "inlineObjectType",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context.report({
|
|
||||||
node: id,
|
|
||||||
messageId: "untypedDestructure",
|
|
||||||
...(interfaces.has("Props")
|
|
||||||
? {
|
|
||||||
fix: (fixer) => fixer.insertTextAfter(id, ": Props"),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.type === "Identifier") {
|
|
||||||
context.report({ node: id, messageId: "noDestructure" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
|
|
||||||
//
|
|
||||||
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
|
|
||||||
// duplicates the literal set: the same set is re-typed in many places, and new
|
|
||||||
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
|
|
||||||
// source. Prefer a single `as const` object as the source of truth and derive
|
|
||||||
// the type from it with indexed access.
|
|
||||||
//
|
|
||||||
// const KIND = { a: ..., b: ..., c: ... } as const;
|
|
||||||
// type Kind = (typeof KIND)[keyof typeof KIND];
|
|
||||||
//
|
|
||||||
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
|
|
||||||
// property types (one-off uses) are left alone.
|
|
||||||
|
|
||||||
/** True when the union and all its (possibly nested) members are string literals. */
|
|
||||||
function allStringLiterals(/** @type {any} */ union) {
|
|
||||||
for (const member of union.types) {
|
|
||||||
if (member.type === "TSUnionType") {
|
|
||||||
if (!allStringLiterals(member)) return false;
|
|
||||||
} else if (member.type === "TSLiteralType") {
|
|
||||||
const literal = member.literal;
|
|
||||||
if (!(literal.type === "Literal" && typeof literal.value === "string")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
type: "suggestion",
|
|
||||||
docs: {
|
|
||||||
description:
|
|
||||||
"Ban string-literal union type aliases in favor of an `as const` object plus `(typeof X)[keyof typeof X]`.",
|
|
||||||
category: "TypeScript conventions",
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
messages: {
|
|
||||||
stringUnion:
|
|
||||||
"Prefer one `as const` object over the string-literal union alias '{{name}}' — literal sets duplicated across types or files drift apart. Derive the type instead: `const {{constName}} = {...} as const; type {{name}} = (typeof {{constName}})[keyof typeof {{constName}}]`.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
return {
|
|
||||||
TSTypeAliasDeclaration(node) {
|
|
||||||
const annotation = node.typeAnnotation;
|
|
||||||
if (!annotation || annotation.type !== "TSUnionType") return;
|
|
||||||
if (!allStringLiterals(annotation)) return;
|
|
||||||
context.report({
|
|
||||||
node: node.id,
|
|
||||||
messageId: "stringUnion",
|
|
||||||
data: {
|
|
||||||
name: node.id.name,
|
|
||||||
constName: node.id.name.toUpperCase(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Shared helpers for the conventions plugin (web/eslint-plugins/conventions).
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the node sits somewhere under a Svelte <script> block
|
|
||||||
* (SvelteScriptElement). Used to limit $props() checks to script code.
|
|
||||||
* @param {any} node
|
|
||||||
*/
|
|
||||||
export function isInsideScriptElement(node) {
|
|
||||||
let cursor = node.parent;
|
|
||||||
while (cursor && cursor.type !== "Program") {
|
|
||||||
if (cursor.type === "SvelteScriptElement") return true;
|
|
||||||
cursor = cursor.parent;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// Shared constants for the design-tokens ESLint plugin.
|
|
||||||
// Single source for property lists and regexes so each rule cooks from the
|
|
||||||
// same vocabulary.
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Properties that accept a COLOR.
|
|
||||||
// =====================================================================
|
|
||||||
export const COLOR_PROPS =
|
|
||||||
/^(color|background|background-color|background-image|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Properties that accept a SIZE (px/rem/em).
|
|
||||||
// z-index is handled separately (it's an integer, not a length).
|
|
||||||
// =====================================================================
|
|
||||||
export const SIZE_PROPS =
|
|
||||||
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// SHORTHAND properties that accept BOTH a color and a size. The browser
|
|
||||||
// assigns their sub-properties by value type at runtime (length -> width,
|
|
||||||
// color -> ...-color), so they cannot be category-checked positionally.
|
|
||||||
// border -> border-width + border-color
|
|
||||||
// outline -> outline-width + outline-color
|
|
||||||
// text-decoration -> text-decoration-line/-color/...
|
|
||||||
// column-rule -> column-rule-width + column-rule-color
|
|
||||||
// The longhands they expand to (border-width, border-color, ...) are already
|
|
||||||
// covered individually by SIZE_PROPS / COLOR_PROPS.
|
|
||||||
// =====================================================================
|
|
||||||
export const MIXED_PROPS =
|
|
||||||
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Properties that carry a DURATION (ms/s) — transitions/animations.
|
|
||||||
// =====================================================================
|
|
||||||
export const DURATION_PROPS =
|
|
||||||
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// A hardcoded COLOR literal: hex / color functions / named colors.
|
|
||||||
// Regex legend:
|
|
||||||
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
|
|
||||||
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
|
|
||||||
// - names: common CSS color names on a word boundary
|
|
||||||
// =====================================================================
|
|
||||||
export const COLOR_LITERAL =
|
|
||||||
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// A hardcoded SIZE literal (px/rem/em), fractional allowed: 0.5rem.
|
|
||||||
// =====================================================================
|
|
||||||
export const FORBIDDEN_SIZE_TOKEN = /\d+(?:\.\d+)?(?:px|rem|em)\b/g;
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// A hardcoded DURATION literal (ms/s): 200ms, 0.3s.
|
|
||||||
// =====================================================================
|
|
||||||
export const FORBIDDEN_DURATION_TOKEN = /\d+(?:\.\d+)?(?:ms|s)\b/g;
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Token PREFIXES by category (tests run against the var() name, e.g.
|
|
||||||
// --color-bg, --brand-main, --space-1, --z-header).
|
|
||||||
// =====================================================================
|
|
||||||
export const COLOR_TOKEN = /^--(?:color|brand)-/;
|
|
||||||
export const SIZE_TOKEN = /^--(?:space|size|text|radius|bp|z)-/;
|
|
||||||
export const DURATION_TOKEN = /^--(?:duration|ease|motion)-/;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
|
|
||||||
// A size property (padding, gap, font-size ...) must use a SIZE token
|
|
||||||
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
|
|
||||||
// (color, background, border-color ...) must use a COLOR token
|
|
||||||
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
|
|
||||||
// is a sign the wrong token is being reused.
|
|
||||||
|
|
||||||
import {
|
|
||||||
COLOR_PROPS,
|
|
||||||
COLOR_TOKEN,
|
|
||||||
MIXED_PROPS,
|
|
||||||
SIZE_PROPS,
|
|
||||||
SIZE_TOKEN,
|
|
||||||
} from "./lists.js";
|
|
||||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
|
||||||
|
|
||||||
// Collect the token names referenced by var() in a value.
|
|
||||||
const collectVars = (value) =>
|
|
||||||
Array.from(value.matchAll(/var\(\s*(--[\w-]+)/g), (m) => m[1]);
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
type: "suggestion",
|
|
||||||
docs: {
|
|
||||||
description:
|
|
||||||
"Ban using a color token in size properties and a size token in color properties; each property must use tokens of its own category.",
|
|
||||||
category: "Design tokens",
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
categoryMismatch:
|
|
||||||
"Token '{{token}}' in '{{prop}}' belongs to the {{category}} category; expected a {{expected}} token.",
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const root = getStyleRoot(context);
|
|
||||||
if (!root) return {};
|
|
||||||
const styleNodeLoc = getStyleNodeLoc(context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
"Program:exit"(programNode) {
|
|
||||||
const report = (node, ruleId, data) => {
|
|
||||||
if (!styleNodeLoc) return;
|
|
||||||
const loc = styleNodeLoc(node);
|
|
||||||
context.report({
|
|
||||||
node: programNode,
|
|
||||||
loc,
|
|
||||||
ruleId,
|
|
||||||
messageId: ruleId,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
root.walkDecls((decl) => {
|
|
||||||
const prop = decl.prop ?? "";
|
|
||||||
const value = decl.value ?? "";
|
|
||||||
|
|
||||||
// Mixed shorthands (border, outline, text-decoration,
|
|
||||||
// column-rule) legitimately take BOTH a size and a color.
|
|
||||||
// The browser assigns sub-properties by value type at runtime,
|
|
||||||
// not by position, so skip category checks for them.
|
|
||||||
// Their longhands (border-width, border-color, ...) are still
|
|
||||||
// covered individually.
|
|
||||||
if (MIXED_PROPS.test(prop)) return;
|
|
||||||
|
|
||||||
const isSizeProp = SIZE_PROPS.test(prop);
|
|
||||||
const isColorProp = COLOR_PROPS.test(prop);
|
|
||||||
if (!isSizeProp && !isColorProp) return;
|
|
||||||
|
|
||||||
for (const token of collectVars(value)) {
|
|
||||||
// A size property that holds a COLOR token.
|
|
||||||
if (isSizeProp && COLOR_TOKEN.test(token)) {
|
|
||||||
report(decl, "categoryMismatch", {
|
|
||||||
token,
|
|
||||||
prop,
|
|
||||||
category: "color",
|
|
||||||
expected: "size",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// A color property that holds a SIZE token (z-radius/space/text...).
|
|
||||||
if (isColorProp && SIZE_TOKEN.test(token)) {
|
|
||||||
report(decl, "categoryMismatch", {
|
|
||||||
token,
|
|
||||||
prop,
|
|
||||||
category: "size",
|
|
||||||
expected: "color",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
|
|
||||||
// Everything visual in a Svelte <style> block must come from design tokens
|
|
||||||
// (var(--...)); direct literals are banned. color-mix() is banned too — its
|
|
||||||
// result must be tokenized in the design CSS file.
|
|
||||||
|
|
||||||
import {
|
|
||||||
COLOR_LITERAL,
|
|
||||||
COLOR_PROPS,
|
|
||||||
DURATION_PROPS,
|
|
||||||
FORBIDDEN_DURATION_TOKEN,
|
|
||||||
FORBIDDEN_SIZE_TOKEN,
|
|
||||||
MIXED_PROPS,
|
|
||||||
SIZE_PROPS,
|
|
||||||
} from "./lists.js";
|
|
||||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
|
||||||
|
|
||||||
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
|
|
||||||
// mistaken for literals.
|
|
||||||
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
type: "suggestion",
|
|
||||||
docs: {
|
|
||||||
description:
|
|
||||||
"Ban hardcoded colors, sizes, durations and z-index in Svelte <style> blocks; use CSS variables (design tokens).",
|
|
||||||
category: "Design tokens",
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
hardcodedColor:
|
|
||||||
"Hardcoded color '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
|
|
||||||
hardcodedSize:
|
|
||||||
"Hardcoded size '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
|
|
||||||
hardcodedDuration:
|
|
||||||
"Hardcoded duration '{{value}}' in '{{prop}}'. Use a var(--duration-...) token.",
|
|
||||||
hardcodedZIndex:
|
|
||||||
"Hardcoded z-index '{{value}}'. Use a var(--z-...) token.",
|
|
||||||
hardcodedBreakpoint:
|
|
||||||
"Hardcoded breakpoint '{{value}}' in @media. Use the @custom-media name instead: declare `@custom-media --bp-* (...)` in app.css and write `@media (--bp-*)`.",
|
|
||||||
varInMedia:
|
|
||||||
"var() inside @media '{{value}}'. Custom properties do not resolve in media queries — declare `@custom-media --bp-* (...)` in app.css and use `@media (--bp-*)`.",
|
|
||||||
colorMix:
|
|
||||||
"color-mix() in component ({{value}}). Tokenize the result in the design CSS file.",
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const root = getStyleRoot(context);
|
|
||||||
if (!root) return {};
|
|
||||||
const styleNodeLoc = getStyleNodeLoc(context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
"Program:exit"(programNode) {
|
|
||||||
const report = (node, ruleId, data) => {
|
|
||||||
if (!styleNodeLoc) return;
|
|
||||||
const loc = styleNodeLoc(node);
|
|
||||||
context.report({
|
|
||||||
node: programNode,
|
|
||||||
loc,
|
|
||||||
ruleId,
|
|
||||||
messageId: ruleId,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// 3) NO HARDCODED BREAKPOINTS IN @media
|
|
||||||
// @media conditions must use --bp-* tokens, not raw lengths.
|
|
||||||
// Regex: (max|min)-width followed by a NON-var(() number.
|
|
||||||
// =====================================================================
|
|
||||||
const BREAKPOINT_RE =
|
|
||||||
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
|
|
||||||
|
|
||||||
// Custom properties do not resolve inside @media conditions, so any
|
|
||||||
// var(--bp-*) inside @media is a latent bug — the block never applies.
|
|
||||||
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
|
||||||
|
|
||||||
// Sub-rule 6: color-mix() is banned anywhere in a component style.
|
|
||||||
const COLOR_MIX_RE = /color-mix\s*\(/gi;
|
|
||||||
|
|
||||||
// --- @media breakpoints --- //
|
|
||||||
root.walkAtRules("media", (atRule) => {
|
|
||||||
const params = atRule.params ?? "";
|
|
||||||
let m;
|
|
||||||
BREAKPOINT_RE.lastIndex = 0;
|
|
||||||
while ((m = BREAKPOINT_RE.exec(params)) !== null) {
|
|
||||||
report(atRule, "hardcodedBreakpoint", { value: m[1] });
|
|
||||||
}
|
|
||||||
VAR_IN_MEDIA_RE.lastIndex = 0;
|
|
||||||
while ((m = VAR_IN_MEDIA_RE.exec(params)) !== null) {
|
|
||||||
report(atRule, "varInMedia", { value: m[1] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- declarations --- //
|
|
||||||
root.walkDecls((decl) => {
|
|
||||||
const prop = decl.prop ?? "";
|
|
||||||
const value = decl.value ?? "";
|
|
||||||
|
|
||||||
// Sub-rule 6: color-mix in any property.
|
|
||||||
if (COLOR_MIX_RE.test(value)) {
|
|
||||||
report(decl, "colorMix", { value: value.trim() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-rule 4: durations in transition/animation props.
|
|
||||||
if (DURATION_PROPS.test(prop)) {
|
|
||||||
const withoutVars = stripVars(value);
|
|
||||||
const m = withoutVars.match(FORBIDDEN_DURATION_TOKEN);
|
|
||||||
if (m) {
|
|
||||||
report(decl, "hardcodedDuration", { prop, value: m[0] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-rule 7: z-index must come from a var(--z-...).
|
|
||||||
if (prop === "z-index") {
|
|
||||||
const withoutVars = stripVars(value).trim();
|
|
||||||
if (/^-?\d+$/.test(withoutVars)) {
|
|
||||||
report(decl, "hardcodedZIndex", { value: withoutVars });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only inspect known layout properties from here on.
|
|
||||||
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
|
|
||||||
|
|
||||||
// Sub-rule 2: sizes.
|
|
||||||
// Fire only on a real remaining px/rem/em token — percentages
|
|
||||||
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
|
|
||||||
// The only legal absolute length is a 0px reset line.
|
|
||||||
// MIXED_PROPS (border/outline shorthands) are checked for their
|
|
||||||
// SIZE half too — a border width must come from var(--size-*).
|
|
||||||
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
|
|
||||||
const withoutVars = stripVars(value);
|
|
||||||
const reported = new Set();
|
|
||||||
for (const match of withoutVars.matchAll(FORBIDDEN_SIZE_TOKEN)) {
|
|
||||||
const shown = match[0];
|
|
||||||
if (shown !== "0px" && !reported.has(shown)) {
|
|
||||||
reported.add(shown);
|
|
||||||
report(decl, "hardcodedSize", { prop, value: shown });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-rule 1: colors.
|
|
||||||
// Remaining value must be only allowed keywords
|
|
||||||
// (currentColor/transparent/inherit/none/0); otherwise a color
|
|
||||||
// literal is reported.
|
|
||||||
if (COLOR_PROPS.test(prop)) {
|
|
||||||
const withoutVars = stripVars(value);
|
|
||||||
const withoutKeywords = withoutVars
|
|
||||||
.replace(
|
|
||||||
/\b(?:currentcolor|transparent|inherit|none|initial|unset|revert)\b/gi,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.trim();
|
|
||||||
if (withoutKeywords !== "" && withoutKeywords !== "0") {
|
|
||||||
const reported = new Set();
|
|
||||||
for (const match of value.matchAll(COLOR_LITERAL)) {
|
|
||||||
const shown = match[0].trim();
|
|
||||||
if (!reported.has(shown)) {
|
|
||||||
reported.add(shown);
|
|
||||||
report(decl, "hardcodedColor", { prop, value: shown });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
|
|
||||||
// A custom property may be DEFINED inside a component's <style> block, but its
|
|
||||||
// value must not introduce a design primitive: a raw color literal (hex/rgb/
|
|
||||||
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
|
|
||||||
// (colors/sizes) a single source of truth in app.css, while still allowing
|
|
||||||
// local DERIVED variables built from tokens: var(--...), calc(), unitless
|
|
||||||
// ratios (--ratio: 1.5) — those are legitimate component-local state.
|
|
||||||
|
|
||||||
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
|
|
||||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
|
||||||
|
|
||||||
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
|
|
||||||
// mistaken for literals.
|
|
||||||
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
type: "suggestion",
|
|
||||||
docs: {
|
|
||||||
description:
|
|
||||||
"Ban defining a custom property whose value introduces a raw color or size primitive in a Svelte <style> block; primitives belong in the design CSS file. Derived var()/calc()/unitless values are allowed.",
|
|
||||||
category: "Design tokens",
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
tokenPrimitive:
|
|
||||||
"Custom property '{{prop}}' defines a primitive '{{value}}' in a component. Move it to app.css or derive it from tokens via var()/calc().",
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const root = getStyleRoot(context);
|
|
||||||
if (!root) return {};
|
|
||||||
const styleNodeLoc = getStyleNodeLoc(context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
"Program:exit"(programNode) {
|
|
||||||
const report = (node, ruleId, data) => {
|
|
||||||
if (!styleNodeLoc) return;
|
|
||||||
const loc = styleNodeLoc(node);
|
|
||||||
context.report({
|
|
||||||
node: programNode,
|
|
||||||
loc,
|
|
||||||
ruleId,
|
|
||||||
messageId: ruleId,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only inspect custom property DEFINITIONS (--foo: value).
|
|
||||||
root.walkDecls((decl) => {
|
|
||||||
const prop = decl.prop ?? "";
|
|
||||||
if (!prop.startsWith("--")) return;
|
|
||||||
|
|
||||||
const value = decl.value ?? "";
|
|
||||||
if (value.trim() === "") return;
|
|
||||||
|
|
||||||
const primitive = stripVars(value);
|
|
||||||
|
|
||||||
// A color literal in the definition → primitive.
|
|
||||||
const colorHit = primitive.match(COLOR_LITERAL);
|
|
||||||
if (colorHit) {
|
|
||||||
report(decl, "tokenPrimitive", {
|
|
||||||
prop,
|
|
||||||
value: colorHit[0].trim(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An absolute size (px/rem/em) in the definition → primitive.
|
|
||||||
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
|
|
||||||
if (sizeHit) {
|
|
||||||
report(decl, "tokenPrimitive", {
|
|
||||||
prop,
|
|
||||||
value: sizeHit[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||