Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081ee6bae1 | ||
|
|
d24ee0a448 | ||
|
|
44cfca92ce | ||
|
|
d0857c5d45 | ||
|
|
c2f3838485 | ||
|
|
49d0a9444e | ||
|
|
5167b0a9b1 | ||
|
|
b3e2735fc3 | ||
|
|
371387ce81 | ||
|
|
08c9fbce3b | ||
|
|
4de42b1e40 | ||
|
|
372babc3b7 | ||
|
|
4b44bc439c | ||
|
|
6c88a23e48 | ||
|
|
a4c85c0814 | ||
|
|
e06970997e | ||
|
|
1097186487 | ||
|
|
abfcbbfac2 | ||
|
|
9a27388119 | ||
|
|
3c10147ad8 | ||
|
|
a71487d30b | ||
|
|
71b5c90a4a | ||
|
|
ce33201302 | ||
|
|
dda5711a33 | ||
|
|
939038ffc0 | ||
|
|
de2a229545 | ||
|
|
9fdd960d81 | ||
|
|
7aefdc3848 | ||
|
|
5cd2a586ce | ||
|
|
ec585e1ee6 | ||
|
|
f2829de216 | ||
|
|
0c2de3f191 | ||
|
|
35df076732 | ||
|
|
05165031c3 | ||
|
|
0b107c7d93 | ||
|
|
24cd9f78c6 | ||
|
|
d14e14ac1a | ||
|
|
b91c1ab7ee | ||
|
|
2be8871930 | ||
|
|
adb578540f | ||
|
|
a7a42d0091 | ||
|
|
e1c3d0a95f | ||
|
|
ceee9adead | ||
|
|
d94c5d1297 | ||
|
|
ee5f660572 | ||
|
|
4826fc6ad8 | ||
|
|
1e47fdaf4a | ||
|
|
8451769efd | ||
|
|
cda88547c4 | ||
|
|
d8a612fbc4 | ||
|
|
fa5f7660c7 | ||
|
|
87bd4482a3 | ||
|
|
1651167a57 | ||
|
|
098897cddd | ||
|
|
75f6891e7c | ||
|
|
0160b316c9 | ||
|
|
33e3f3c4f0 | ||
|
|
1e5e096d5d | ||
|
|
f8a1da6ff7 | ||
|
|
f7623ab3bb | ||
|
|
45dc54b6aa | ||
|
|
85effce7d9 | ||
|
|
e970b47720 | ||
|
|
b6318f03ab | ||
|
|
b36a865a0c | ||
|
|
3834dadd03 | ||
|
|
bf2f8e57c8 | ||
|
|
a91347ca81 | ||
|
|
50a9276113 | ||
|
|
d6706abea2 | ||
|
|
bea010dd90 | ||
|
|
2937974fe7 | ||
|
|
1e85fe36a6 | ||
|
|
60c7d61dab | ||
|
|
6b0b0646d5 | ||
|
|
9dccdd99a0 | ||
|
|
b6db1c8f19 | ||
|
|
ba8a518861 | ||
|
|
292e53c9c7 | ||
|
|
e9bc59f818 | ||
|
|
84230b17ff | ||
|
|
64e35f4fdf | ||
|
|
46da54140c | ||
|
|
420d03f58c | ||
|
|
3f21f1c024 | ||
|
|
9a7b7c178b | ||
|
|
e3d50f2128 | ||
|
|
2d24f51fd5 | ||
|
|
5a4fbe7ac6 | ||
|
|
6f1771cb07 | ||
|
|
987f4535c8 | ||
|
|
20e07fae85 | ||
|
|
a71252dc0c | ||
|
|
751e8d97a0 | ||
|
|
a1438e6f7f | ||
|
|
5884f4bd82 | ||
|
|
e5b76ca3e1 | ||
|
|
a49785764b | ||
|
|
c0cebdc81f | ||
|
|
37a4a0576c | ||
|
|
b3d460a951 | ||
|
|
6be9d10c42 | ||
|
|
cc70750727 | ||
|
|
e2735c52ab | ||
|
|
d383cf29e3 | ||
|
|
bfad0c7d02 | ||
|
|
6750d5a373 | ||
|
|
6cbb9f95f0 | ||
|
|
e57faddfde | ||
|
|
e662d1df24 | ||
|
|
d48262e482 | ||
|
|
cad9bf6cfd | ||
|
|
b99ccf5d78 | ||
|
|
0a7c4a84c9 | ||
|
|
f24ccf9240 | ||
|
|
0c28925673 | ||
|
|
743ae817ee | ||
|
|
19f2e13b88 | ||
|
|
4a13bfbd16 | ||
|
|
f286044ab9 | ||
|
|
2601aa5b5c | ||
|
|
1e9b35406d | ||
|
|
520a1e1d37 | ||
|
|
a19d3db929 | ||
|
|
58e75f2334 | ||
|
|
1c746299c9 | ||
|
|
d1a386f92f | ||
|
|
1813383724 | ||
|
|
1ae4b36c9c | ||
|
|
a57acd1ff2 | ||
|
|
6ec3e75df4 | ||
|
|
a24a182d18 | ||
|
|
1e63eaa03b | ||
|
|
e9de7a4bf8 | ||
|
|
7e94205866 | ||
|
|
2f758a88af | ||
|
|
991fe69381 | ||
|
|
11c4ccece4 | ||
|
|
8a6f87938d | ||
|
|
3d0f8ebb10 | ||
|
|
28ff9d3ee1 | ||
|
|
ef74cabf1e | ||
|
|
c590bdf91a | ||
|
|
4794523084 | ||
|
|
98a4bacd23 | ||
|
|
1310c51543 | ||
|
|
191ca71553 | ||
|
|
d38d51c7d9 | ||
|
|
749b97cb4f | ||
|
|
d73b9056d7 | ||
|
|
1907fb9106 | ||
|
|
80def1f380 | ||
|
|
95f1eeeecc | ||
|
|
9cd86104fc | ||
|
|
0b13d4c9e8 | ||
|
|
ecc8efcc45 | ||
|
|
586c66a159 | ||
|
|
b7d66f0430 | ||
|
|
bbd5532529 | ||
|
|
6cefd5c0a1 | ||
|
|
8eff9a9483 | ||
|
|
2c70c445d5 | ||
|
|
6c8f7b2d8b | ||
|
|
86b1c813e7 | ||
|
|
b0f0b79ee0 | ||
|
|
11b4c87486 | ||
|
|
87dbb200d9 | ||
|
|
2d72848e8e | ||
|
|
453a5908e6 | ||
|
|
dbeffac082 | ||
|
|
2ef4709038 | ||
|
|
1831c3d100 | ||
|
|
9ef6ae03c6 | ||
|
|
b695f5d58c | ||
|
|
7578a185b6 | ||
|
|
d29ec73322 | ||
|
|
9ff732f2cf | ||
|
|
b19ddbe45d | ||
|
|
795c03ac87 | ||
|
|
0d95acba89 | ||
|
|
6959abb159 | ||
|
|
86e7b7250d | ||
|
|
c0f48ecd97 | ||
|
|
d30deddce0 | ||
|
|
7aa50eee6c | ||
|
|
ada96b3534 | ||
|
|
10278981ba | ||
|
|
5093b50f5c | ||
|
|
65f12c931b | ||
|
|
40eee984da | ||
|
|
d373cc0daf | ||
|
|
a16a7b92c7 | ||
|
|
f990ad6767 | ||
|
|
f4216e6868 | ||
|
|
bcdfc4cf12 | ||
|
|
adcc255107 | ||
|
|
73118ea393 | ||
|
|
e9578fc15e | ||
|
|
06d185add6 | ||
|
|
8437c95a28 | ||
|
|
ac75665deb | ||
|
|
bc5a5d13c4 | ||
|
|
0acfa3aeb9 | ||
|
|
418a091505 | ||
|
|
f08af41568 | ||
|
|
dfb2311e32 | ||
|
|
84534b2f58 | ||
|
|
83d3ce1198 | ||
|
|
501d2dda4e | ||
|
|
2b67414e79 | ||
|
|
56ebe5b53b | ||
|
|
3c1c5b2b3a | ||
|
|
84cc56cc6c | ||
|
|
d65adcd16c | ||
|
|
553601acc1 | ||
|
|
5c37eb2e7b | ||
|
|
db02cbd165 | ||
|
|
11fc160997 | ||
|
|
116d7c72aa | ||
|
|
b277f3c66d | ||
|
|
54af7626d6 | ||
|
|
38e7cd20d2 | ||
|
|
2e980fb115 | ||
|
|
6e0647dfbf | ||
|
|
148e4b1957 | ||
|
|
d70cbad0d2 | ||
|
|
365af6ef95 | ||
|
|
5e512f0bdd | ||
|
|
f1f5bd4409 | ||
|
|
5b22409d41 | ||
|
|
96b8c00129 | ||
|
|
6d770dc642 | ||
|
|
b296b6fa20 | ||
|
|
486b99dbb8 | ||
|
|
c04a1fc1dc | ||
|
|
6787201425 | ||
|
|
396036802c | ||
|
|
74a18d91a7 | ||
|
|
2301b0e165 | ||
|
|
8255d33098 | ||
|
|
3c0ffa23bc | ||
|
|
d64eaa0e93 | ||
|
|
ebc3c33c12 | ||
|
|
8745812a5f | ||
|
|
0909e14cde | ||
|
|
7ef4b908e2 | ||
|
|
b6e157e5da | ||
|
|
a2dd288851 | ||
|
|
cb5ccf6da8 | ||
|
|
b434bf3ef3 | ||
|
|
200e971df5 | ||
|
|
5e44a45457 | ||
|
|
76f1770286 | ||
|
|
68476c3b8c | ||
|
|
702b96261d | ||
|
|
95ee598875 | ||
|
|
46af693082 | ||
|
|
9fbb90b641 | ||
|
|
c14301130f | ||
|
|
fd5236c19b | ||
|
|
3d7371492f | ||
|
|
1b3bad46d1 | ||
|
|
474c1156aa | ||
|
|
91b3492231 | ||
|
|
3a5acfbc45 | ||
|
|
306c26a360 | ||
|
|
c93a8e493e | ||
|
|
ce7f5ff680 | ||
|
|
8532eb98c3 | ||
|
|
23c985bde9 | ||
|
|
8632e2e1cf | ||
|
|
6fae98b25c | ||
|
|
f9a4973b47 | ||
|
|
7fa2c52bb8 | ||
|
|
17b0c68eb5 | ||
|
|
3b18d0c6f8 | ||
|
|
b48a8adfa7 | ||
|
|
96c37aabc5 | ||
|
|
7b799969c4 | ||
|
|
3701fc8045 | ||
|
|
7e391a420b | ||
|
|
aa5372b771 | ||
|
|
d0ee4ec77f | ||
|
|
5560435b55 | ||
|
|
dcc7c346c4 | ||
|
|
da92815b3a | ||
|
|
caf71ec8f0 | ||
|
|
f576701baa | ||
|
|
4c5b52a9ad | ||
|
|
7afd07e8c1 | ||
|
|
06c6716cf5 | ||
|
|
a9234d27de | ||
|
|
c7b56bbda7 | ||
|
|
15e2e010ec | ||
|
|
3620c5fc30 | ||
|
|
c90f887d7d | ||
|
|
cd158b188a | ||
|
|
da55328491 | ||
|
|
4b7086693c | ||
|
|
08038ede0d | ||
|
|
3ec11999d8 | ||
|
|
c0225552e4 | ||
|
|
719bda2e22 | ||
|
|
9ccc9f94ac | ||
|
|
4ec2b418fd | ||
|
|
85f595f14b | ||
|
|
d71f8648f5 | ||
|
|
6c9f9470c8 | ||
|
|
72ad64010a | ||
|
|
fdf7e2a3cd |
@@ -0,0 +1,64 @@
|
||||
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:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ^11.20.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
run: pnpm build
|
||||
env:
|
||||
BASE_PATH: /easy-png-tools
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: web/build
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
|
||||
# Reference source (legacy Next.js design in refs/): branding icons are not needed
|
||||
refs/public/icon.svg
|
||||
refs/public/icon-dark-32x32.png
|
||||
refs/public/icon-light-32x32.png
|
||||
refs/public/apple-icon.png
|
||||
refs/public/placeholder-logo.svg
|
||||
refs/public/placeholder-logo.png
|
||||
refs/public/placeholder.svg
|
||||
web/audit/cdp-audit*.txt
|
||||
css-lint.txt
|
||||
|
||||
# Локальные планы с приватной информацией (выбранное имя домена) — не коммитить
|
||||
docs/plan-domain.local.md
|
||||
@@ -0,0 +1,7 @@
|
||||
# Точечные исключения, без allowlist-`/*`: корневой `/*` глушил формат-он-сейв
|
||||
# (весь `web/**` резолвился как ignored, дочерние конфиги не успевали примениться).
|
||||
# Дочерние каталоги управляются своими конфигами/игнорами (web/, refs/).
|
||||
node_modules
|
||||
.git
|
||||
refs-html
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"proseWrap": "always"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# AGENTS.md
|
||||
|
||||
Инструкции для агентов (и IDE-ассистентов) по работе с этим репозиторием.
|
||||
|
||||
## Стек
|
||||
|
||||
- **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes).
|
||||
- Менеджер пакетов: **pnpm**. Запускать команды из `web/`, либо через обёртки в
|
||||
корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать
|
||||
npm.
|
||||
- Сборка: `pnpm --dir web build`, проверка типов:
|
||||
`pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, тесты:
|
||||
`pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
|
||||
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт
|
||||
4173).
|
||||
- Форматирование: `pnpm format` в корне (оба прогона) или
|
||||
`pnpm --dir web format` (только код web/). Проверка: `pnpm check:docs`
|
||||
(docs) + `pnpm --dir web exec prettier --check .` (код).
|
||||
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
|
||||
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
|
||||
выполняется, даже если предыдущий упал. Текущие «ожидаемые долги» (hardcoded
|
||||
размеры в kit до C19, не-Prefix токены в `app_v1.css`) — техдолг: чинить
|
||||
только по заведённым tasks, не игнорировать правилом.
|
||||
|
||||
## Правила кода
|
||||
|
||||
Синтаксические ограничения описывать не нужно — они проверяются линтером
|
||||
(весь список правил и токены-префиксы в `web/eslint-plugins/README.md`,
|
||||
прогон: `pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то,
|
||||
что линтер не умеет.
|
||||
|
||||
### Svelte 5
|
||||
|
||||
Типизация пропсов через локальный `interface Props` закреплена правилом
|
||||
`conventions/interface-props`; руками его нигде дублировать не надо.
|
||||
|
||||
### Сначала искать
|
||||
|
||||
Новая константа или тип не заводится, пока не произведён поиск существующего
|
||||
определения (по ключу в соседних модулях и по всему `web/src/`). Это правило
|
||||
в первую очередь для ИИ-агентов: дублирующее определение ухудшает правки в
|
||||
нескольких местах и запутывает. (Синтаксис самих определений — под линтером:
|
||||
строка `conventions/no-string-union-alias` и т.д.)
|
||||
|
||||
### Дизайн
|
||||
|
||||
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
||||
которые линтер не проверяет:
|
||||
|
||||
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
|
||||
`web/src/app_v1.css` (маршруты `/v1/*`).
|
||||
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
||||
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
||||
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
||||
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
|
||||
|
||||
### Изоляция веток old ↔ new
|
||||
|
||||
Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
|
||||
каждый импорт до файла и запрещает смешивание.
|
||||
|
||||
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
|
||||
Изменения делать небольшими (< ~500 строк), атомарными.
|
||||
@@ -0,0 +1,21 @@
|
||||
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,7 +1,92 @@
|
||||
# Project renamed
|
||||
# easy-png-tools
|
||||
|
||||
"Easy png tools" now named "Easy image tools" and moved to a new domain.
|
||||
Browser-based PNG utility toolkit — no server, no data upload, everything runs locally.
|
||||
|
||||
[New repository link](https://github.com/Ku6epXBOCTuK/easy-image-tools)
|
||||
Inspired by online services like onlinepngtools.com, but fully offline-capable and open source.
|
||||
|
||||
[New website link](https://easy-image-tools.com)
|
||||
## Features
|
||||
|
||||
- **Pixel-level image processing** — format conversion, transparency/alpha channel, color transformations, geometry, filters/convolutions, morphology, quantization & palettes, image generation, text/watermarks, analysis & checks
|
||||
- **Pipeline system (workspace)** — load image → chain multiple tool steps → sequential application → download result; pipelines saved to localStorage, exportable as JSON
|
||||
- **Web Worker execution** — heavy operations run off the main thread with automatic fallback to direct calls
|
||||
- **i18n** — full Russian and English localization with search matching
|
||||
- **Dark/Light themes** — persisted to localStorage
|
||||
- **Pure TypeScript core** — `lib/core/` operates on `ImageData`/`Uint8ClampedArray` with no DOM dependency
|
||||
- **~30+ tools** across categories: transparency, color, geometry, filters, morphology, palettes, generation, text, analysis
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **SvelteKit** (Svelte 5, runes mode) with static adapter — pure static export, no server
|
||||
- **Vite** + **TypeScript** (strict)
|
||||
- **Vitest** for unit tests
|
||||
- **ESLint** + **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/`.
|
||||
|
||||
### Other Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------ | ------------------------- |
|
||||
| `pnpm --dir web test` | Run unit tests (Vitest) |
|
||||
| `pnpm --dir web check` | Type-check (svelte-check) |
|
||||
| `pnpm --dir web lint` | Lint (ESLint) |
|
||||
| `pnpm --dir web format` | Format code (Prettier) |
|
||||
| `pnpm format:docs` | Format docs (Prettier) |
|
||||
| `pnpm --dir web preview` | Preview production build |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```txt
|
||||
easy-png-tools/
|
||||
├── web/ # Main SvelteKit application
|
||||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── core/ # Pure TS image processing (ImageData-based)
|
||||
│ │ │ ├── components/ # UI components
|
||||
│ │ │ ├── tools/ # Pipeline, executor, overlay store
|
||||
│ │ │ ├── i18n/ # Localization (ru/en)
|
||||
│ │ │ └── registry.ts # Tool registry (source of truth)
|
||||
│ │ └── routes/ # SvelteKit routes
|
||||
│ └── build/ # Static export output
|
||||
├── docs/ # Architecture docs, roadmap, plans
|
||||
└── refs/ # Design reference (Next.js, not part of runtime)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The project is built around a **tool registry** (`registry.ts`) — each tool is a self-contained entry with `{id, title, description, category, params, run}`. Pages, forms, and pipelines are generated from this registry.
|
||||
|
||||
Core image processing lives in `lib/core/` and operates directly on `ImageData` objects with no browser API dependencies, making it portable to other environments (WASM, CLI) in the future.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [docs/roadmap.md](docs/roadmap.md) for the full development plan. Current phases:
|
||||
|
||||
1. **Phase 1** — TypeScript core + web UI (in progress)
|
||||
2. **Phase 2** — Rust/WASM core for performance-critical operations
|
||||
3. **Phase 3** — CLI tool
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# Анализ: набор PNG-утилит для браузера
|
||||
|
||||
Проект: **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` = один компонент с разными подписями/дефолтами). Не плодить
|
||||
дублирующий код.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Demo fidelity: /preview/demo vs refs-html/demo.html
|
||||
|
||||
> ⚠️ **УТРАТИЛ АКТУАЛЬНОСТЬ — архивная копия.** Актуальный план фикса верности и
|
||||
> текущий статус пунктов B/F — в `docs/plan-design-fix.md` (Этап 1). Аудит
|
||||
> верности теперь автоматизирован (`pnpm audit` → `web/audit/audit-report.md`;
|
||||
> пиксель-дифф не используется); темы в `refs-html` переключаются (скрипт
|
||||
> инжектится в `scripts/extract-static.mjs`). Ниже — исходный текст на момент
|
||||
> написания.
|
||||
|
||||
Метод: Playwright, viewport 1440×900, скриншоты обеих страниц + замер
|
||||
вычисленных стилей ключевых элементов + пиксель-дифф (без чтения картинки
|
||||
моделью). Пиксель-дифф: **24.9%** (наш скролл 1731px, реф 1579px). Зоны различий
|
||||
размазаны по всей высоте → править надо глобально, не точечно.
|
||||
|
||||
Теперь процесс автоматизирован: `pnpm audit` (Playwright) пишет
|
||||
`web/audit/audit-report.md` — light/dark токены + поэлементный дифф стилей по
|
||||
тексту. Пиксель-дифф убран (неудобен для работы). Фиксы можно делать в любой
|
||||
момент и перепроверять аудитом.
|
||||
|
||||
## Баги (объективно, правим в любом случае)
|
||||
|
||||
- **B1. Кнопки/инпуты рендерятся шрифтом Arial, а не IBM Plex Sans.**
|
||||
Доказательство: пробы `Reset pipeline` → `fontFamily: Arial`. Причина: у
|
||||
`button/input` не задан `font-family: inherit`. Фикс: добавить в reset
|
||||
`design2.css` `button, input, select, textarea { font-family: inherit; }`.
|
||||
Влияет на все страницы. (P1)
|
||||
- **B2. Двойной топбар.** Страница демо рендерит свой `.topbar` (brand / AUTO
|
||||
PIPELINE / help / theme / lang), который перекрывается глобальным `fixed`
|
||||
Header из `preview/+layout.svelte` (в нём только theme-toggle). В рефе — один
|
||||
топбар. Контент демо-топбара (brand, статус, язык) визуально спрятан за
|
||||
фикс-хедером. Фикс: убрать кастомный `.topbar` из демо и сделать хедер лейаута
|
||||
реф-стайл топбаром (brand + version + status + help + theme + lang) — улучшит
|
||||
и C9/C10. (P1)
|
||||
|
||||
## Расхождения дизайна (нужно решение)
|
||||
|
||||
- **F1. Фон холста.** Реф: плоский серый `#EEF1F4`. Наш: белый +
|
||||
blueprint-сетка. Решение: совпасть с рефом (убрать сетку на демо) или оставить
|
||||
сетку как сигнатуру нового дизайна.
|
||||
- **F2. Бейдж типа шага** (BACKGROUND/TRANSFORM/STYLE). Реф: приглушённый серый
|
||||
`#6D787F`. Решено C16: `StepCard` рендерит `type` как приглушённый моно-текст
|
||||
(не синий бейдж); индекс шага (`step-index`) — синий `#1769D2`, как в рефе.
|
||||
- **F3. Подписи превью-тайлов** (SOURCE / STEP 01 / FINAL OUTPUT). Реф: синий
|
||||
`#1769D2`, weight 400. Решено C16: `PreviewTile` label — синий `#1769D2`.
|
||||
- **F4. Раскладка preview-stack.** Реф: одна колонка (5 строк, выше). Наш: 2
|
||||
колонки (grid). Решение: одна колонка (как реф) или оставить 2 колонки как
|
||||
выбор нового дизайна.
|
||||
- **F5. Вес лейблов контролов.** Реф: 400. Наш (`MonoLabel`): 500. Мелко.
|
||||
- **F6. Мета-подписи** (DIMENSIONS/FORMAT/SIZE): реф fs 9px, наш 10px. Мелко.
|
||||
- **F7. Копирайт:** `Add tool` у нас `+ Add tool`; кнопка `Download result` —
|
||||
сверить стиль (в рефе синяя с белым текстом 478×42). Пробы
|
||||
`Download result`/`Add tool`/`DIRECTION` вернули NOT FOUND — скорее артефакт
|
||||
замера (вложенный текст/чип размера), проверить визуально.
|
||||
- **F8. h1 line-height** чуть выше (наш 150px vs реф 115px) — мелкий зазор.
|
||||
- **F9. Вертикальный ритм:** контент на ~152px выше, зазоры между секциями
|
||||
отличаются (pipeline-head y 434 vs 400 и т.д.) — подровнять паддинги под реф.
|
||||
- **F10. Внутренние отступы preview-панели** (Hide intermediate x 907 vs 1218) —
|
||||
паддинг панели.
|
||||
|
||||
## Темы (отложено — F1-F4)
|
||||
|
||||
Серый фон `#EEF1F4` — это **светлая** тема. Уточнение по F1: наш токен
|
||||
`--background` уже равен `#EEF1F4` (совпадает с рефом); видимое отличие — только
|
||||
blueprint-сетка поверх фона, а не «белый vs серый». То есть F1 = «сетка против
|
||||
плоского», не цвет фона.
|
||||
|
||||
Перед тем как сверять цвета бейджей/тайлов/раскладок (F2-F4), нужно сначала
|
||||
зафиксировать палитры light/dark как единый источник токенов. Сейчас они есть в
|
||||
`web/src/lib/styles/design2.css` (`:root` + `[data-theme="dark"]`), но не
|
||||
сверены с рефом.
|
||||
|
||||
**Как проверить темы в refs-html:**
|
||||
|
||||
- Открыть `refs-html/demo.html` (и соседние `gradient.html`,
|
||||
`background-remover.html`) через Playwright/браузер.
|
||||
- Нажать theme-toggle в топбаре рефа (иконка луны/солнца) — переключает тему
|
||||
(скорее всего меняет атрибут/класс на `<html>` или `.page` и переопределяет
|
||||
CSS-переменные).
|
||||
- Снять скриншоты светлой и тёмной темы, замерить computed-стиль `:root`
|
||||
переменных рефа (через
|
||||
`getComputedStyle(document.documentElement).getPropertyValue('--...')`) и
|
||||
сопоставить с нашими токенами в `design2.css`.
|
||||
- Рефы, похоже, не доделаны по тёмной теме — зафиксировать, какие именно токены
|
||||
меняются и где расхождения. На основе этого выровнять наши токены, затем
|
||||
вернуться к F2 (бейдж типа), F3 (тайлы), F4 (раскладка preview-stack).
|
||||
|
||||
Решение по F1-F4 принимается только после сверки токенов светлой/тёмной темы с
|
||||
рефом.
|
||||
|
||||
**Состояние тем (уточнение):** наша dark-тема реализована в `design2.css`
|
||||
(`[data-theme="dark"]`), но пока подключена только на preview-маршрутах (старые
|
||||
страницы грузят `app.css`). Этого достаточно для сверки: токены, отличные от
|
||||
light, соберём по всем `refs-html/*.html` (не только `demo.html`), остальные
|
||||
токены не меняются. Light-токены уже совпадают с рефом (`--background`,
|
||||
`--panel`, `--foreground`, `--muted`, `--line`, `--blue` равны).
|
||||
|
||||
## Состояние (C16)
|
||||
|
||||
Цветовые расхождения против `refs-html/demo.html` устранены: стек шрифтов сверен
|
||||
с рефом (`"IBM Plex Sans", sans-serif` / `"IBM Plex Mono", monospace`), бренд
|
||||
топбара — `--foreground`, индекс шага — синий, тип шага и подпись плитки — как в
|
||||
рефе, `Segmented` — 10px/32px. Аудит: токены light 10 / dark 16, элементы 47.
|
||||
Остаток — sub-pixel (10px→9px лейблы, letter-spacing) и структурные
|
||||
(`display`/`rect` от наших flex/grid раскладок против статичного мока);
|
||||
визуально незначимы, правим точечно при желании. Старые тесты (583) — без
|
||||
регрессий.
|
||||
|
||||
## План действий (предлагаю)
|
||||
|
||||
> Актуальный пошаговый план фикса вынесен в `docs/plan-design-fix.md` (Этапы
|
||||
> 0–1: рефакторинг + верность). Ниже — исходные наброски.
|
||||
|
||||
1. Сразу поправить B1 + B2 (чёткие баги, улучшают все страницы).
|
||||
2. По F1–F4 — решение за тобой (совпадать с рефом дословно ИЛИ оставить как
|
||||
сигнатуру нового дизайна).
|
||||
3. F5–F10 — мелочи, подчистить в том же проходе, если решаем «совпадать с
|
||||
рефом».
|
||||
4. Переснять скриншоты и перепроверить пиксель-дифф.
|
||||
@@ -0,0 +1,140 @@
|
||||
# План: единая дизайн-система
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-22.**
|
||||
|
||||
## 1. Цели и принципы
|
||||
|
||||
1. **Один источник правды** — все визуальные константы живут в токенах
|
||||
`web/src/app.css`; ни один компонент не пишет «магические» цвета/размеры
|
||||
напрямую.
|
||||
2. **Никакого дублирования** — повторяющийся визуальный рецепт оформляется один
|
||||
раз: либо токен, либо глобальный утилитарный класс, либо компонент в `ui/`.
|
||||
3. **Простые компоненты важнее умных** — если проще сделать отдельные
|
||||
`SliderField` и `TextField`, делаем два маленьких компонента, а не один
|
||||
конфигурируемый монстр.
|
||||
4. **Scoped-стили — только для раскладки** конкретного компонента (сетка,
|
||||
отступы между блоками). Внешний вид контролов и текстов — из дизайн-системы.
|
||||
|
||||
## 2. Аудит: что дублируется прямо сейчас
|
||||
|
||||
- **«Приглушённый текст»** (`--text-muted` + размер 0.85–0.95rem) — повторён в
|
||||
семи местах: `ToolPage` (.description, .hint), главная (.lead, .card-desc),
|
||||
лейаут (.nav-link, footer), `Preview` (.dims), `ParamForm` (label),
|
||||
`InfoPanel` (dt).
|
||||
- **«Заголовок секции капсом»** (uppercase, letter-spacing, muted) — точная
|
||||
копипаста в `ToolPage` и на главной.
|
||||
- **Базовые инпуты** (padding, border, radius) — живут только внутри
|
||||
`ParamForm`; любые новые типы контролов (слайдеры, пипетка цвета) пришлось бы
|
||||
копировать вручную.
|
||||
- **Блок «label + отступы»** — дублировался бы в каждом `*Field`; решается
|
||||
обёрткой `Field.svelte`.
|
||||
- **«Панель»** (surface + border + radius) — три ручные реализации одного
|
||||
паттерна: `DropZone`, карточки главной, строки `InfoPanel`.
|
||||
- **Кнопки** — глобальные классы `.primary`/`.secondary` без компонента;
|
||||
состояния «занят/недоступен» каждый описывает заново.
|
||||
- **Ошибка-баннер** — только в `ToolPage`, а понадобится минимум ещё раз в
|
||||
workspace.
|
||||
|
||||
## 3. Токены (расширение `app.css`)
|
||||
|
||||
Существующие остаются; добавляются недостающие группы:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* типографика */
|
||||
--text-xs: 0.8rem;
|
||||
--text-s: 0.85rem;
|
||||
--text-m: 0.95rem;
|
||||
--text-l: 1.1rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xxl: 1.75rem;
|
||||
--leading-tight: 1.25;
|
||||
|
||||
/* тени */
|
||||
--shadow-card: 0 4px 14px rgb(37 99 235 / 12%);
|
||||
|
||||
/* фокус */
|
||||
--focus-ring: 2px solid var(--accent);
|
||||
|
||||
/* контрольные размеры контролов */
|
||||
--control-height: 2.4rem;
|
||||
--control-max-width: 16rem;
|
||||
}
|
||||
```
|
||||
|
||||
Правило именования: семантика, не значение (`--text-muted`, а не `--gray-500`).
|
||||
|
||||
## 4. Глобальные утилиты (`app.css`)
|
||||
|
||||
Только то, что не требует JS и не является интерактивным:
|
||||
|
||||
- `.text-muted` — приглушённый текст (размер наследуется);
|
||||
- `.text-caption` — мелкий текст `--text-s`;
|
||||
- `.heading-section` — заголовок секции капсом (вместо двух копипаст);
|
||||
- `.panel` — поверхность с бордером/радиусом (DropZone, карточки,
|
||||
InfoPanel-строки);
|
||||
- `.error-banner` — баннер ошибки;
|
||||
- `.visually-hidden` — скрытый, но читаемый скринридерами.
|
||||
|
||||
Элементные стили из текущего `app.css` (голые `input, select { ... }`) убрать —
|
||||
внешний вид контролов переезжает в компоненты `ui/`.
|
||||
|
||||
## 5. Состав UI-компонентов (`web/src/lib/components/ui/`)
|
||||
|
||||
Все — тонкие обёртки над нативными элементами, без состояний кроме своих:
|
||||
|
||||
| Компонент | Пропы | Заменяет |
|
||||
| ---------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `Field.svelte` | `id`, `label`, `hint?`, слот контрола | label + раскладка поля; единая точка для hint'ов и ошибок валидации |
|
||||
| `Button.svelte` | `variant: 'primary' \| 'secondary'`, `disabled`, `busy`, слот текста | глобальные `.primary`/`.secondary`, локальный busy в `DownloadButton` |
|
||||
| `TextField.svelte` | `id`, `label`, `type: 'number' \| 'text'`, `min/max/step`, `value = $bindable()` | number/text ветки `ParamForm` |
|
||||
| `SliderField.svelte` | `id`, `label`, `min/max/step`, `value = $bindable()`, показ значения рядом | ничего — готовая база для будущих слайдеров (яркость, качество, порог) |
|
||||
| `SelectField.svelte` | `id`, `label`, `options`, `value = $bindable()` | select-ветка `ParamForm` |
|
||||
| `CheckboxField.svelte` | `id`, `label`, `checked = $bindable()` | чекбокс `ParamForm` |
|
||||
| `ColorField.svelte` | `id`, `label`, `value = $bindable()` | color-ветка `ParamForm`; точка будущего расширения пипеткой |
|
||||
| `EmptyState.svelte` | `title`, `hint`, слот действия | «Обработка…», будущие пустые панели исходника/результата |
|
||||
|
||||
`Field` — чисто раскладочная обёртка
|
||||
(`<div class="field"><label for>…</label><slot /></div>`), ничего не знает о
|
||||
типе контрола. На ней построены `TextField`, `SliderField`, `SelectField`,
|
||||
`ColorField`. Исключение — `CheckboxField`: у чекбокса label оборачивает контрол
|
||||
(кликабельная строка), поэтому у него своя раскладка.
|
||||
|
||||
## 6. Миграция существующего
|
||||
|
||||
1. `ParamForm` — переписывается на `Field` + `*Field`-компоненты; собственная
|
||||
стилизация контролов и label удаляется.
|
||||
2. `DownloadButton` и кнопки `ToolPage` — на `Button`.
|
||||
3. `DropZone`, карточки главной, строки `InfoPanel` — на `.panel` + свои
|
||||
раскладочные стили.
|
||||
4. `ToolPage`, главная, лейаут — приглушённые тексты на `.text-*`, заголовки
|
||||
секций на `.heading-section`, баннер на `.error-banner`.
|
||||
5. Из `app.css` удалить глобальные классы кнопок и голые стили инпутов после
|
||||
перехода.
|
||||
|
||||
## 7. Порядок работ
|
||||
|
||||
| Этап | Содержимое | Проверка |
|
||||
| ------------ | ----------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| A. Фундамент | Токены + утилиты в `app.css` | `pnpm check` |
|
||||
| B. Примитивы | Восемь компонентов в `components/ui/` | `pnpm check`; каждый используется хотя бы раз после этапа C |
|
||||
| C. Миграция | Переделка пяти файлов из §6 | Визуально: страницы выглядят как раньше; `test/check/build` зелёные |
|
||||
| D. Чистка | Удаление устаревших классов и дублей из scoped-стилей | grep: в `components/` нет hex-цветов и `var(--space…)` вне раскладки |
|
||||
|
||||
## 8. Критерии готовности
|
||||
|
||||
- Ни один компонент не задаёт цвет/радиус/шрифт мимо токенов или классов
|
||||
системы.
|
||||
- Все интерактивные контролы на страницах — экземпляры `ui/*`.
|
||||
- `grep -E '#[0-9a-f]{3,6}' web/src/lib/components` не находит ничего, кроме
|
||||
токенов в `app.css`.
|
||||
- Визуальной регрессии нет (ручная сверка всех страниц до/после), тесты и билд
|
||||
зелёные.
|
||||
|
||||
## 9. Что сознательно не делаем
|
||||
|
||||
- Никаких CSS-фреймворков и препроцессоров — обычный CSS, как решено на старте.
|
||||
- Никаких тем/тёмного режима — но токены сразу семантические, чтобы тема
|
||||
добавилась заменой значений `:root`.
|
||||
- Никаких сложных составных компонентов (модалки, таблицы) — они появятся, когда
|
||||
потребуется реальная задача.
|
||||
@@ -0,0 +1,237 @@
|
||||
# План начальной реализации: минимальный рабочий MVP
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-22.**
|
||||
>
|
||||
> **Что сделано по плану:**
|
||||
>
|
||||
> - Шаг 0 — каркас: SvelteKit + adapter-static, design-токены на CSS, лейаут с
|
||||
> навигацией по категориям ✔
|
||||
> - Шаг 1 — ядро: `PixelImage`, декодирование файлов, кодирование PNG/JPEG/WebP,
|
||||
> скачивание ✔
|
||||
> - Шаг 2 — операции ядра: color / geometry / alpha / analyze, чистые функции
|
||||
> без DOM ✔
|
||||
> - Шаг 3 — реестр из 11 инструментов + санитизация параметров ✔
|
||||
> - Шаг 4 — универсальные UI-компоненты (DropZone, Preview, ParamForm,
|
||||
> DownloadButton, InfoPanel, ToolPage) ✔
|
||||
> - Шаг 5 — маршруты: `/tools/[id]` с prerender всех страниц, главная-каталог по
|
||||
> категориям ✔
|
||||
> - Шаги 6–7 — обработка ошибок, состояния, полировка; верификация ✔
|
||||
>
|
||||
> **Checkpoint пройден:** все 11 инструментов вручную проверены в браузере
|
||||
> (загрузка → применение → скачивание), включая найденный и исправленный баг
|
||||
> resize с сохранением пропорций. Тесты 75/75, svelte-check 0 ошибок,
|
||||
> статический экспорт собирается.
|
||||
>
|
||||
> **Попутные улучшения сверх плана:** корневой скрипт `pnpm dev`;
|
||||
> `sanitizeParams` против пустых/невалидных значений полей; семантика «вписать в
|
||||
> размеры» для resize.
|
||||
|
||||
## 0. Зафиксированные решения
|
||||
|
||||
- **Объём MVP:** только одиночные инструменты «загрузил → настроил → скачал».
|
||||
Пайплайн-workspace (`/workspace`) вне скоупа этого плана.
|
||||
- **Набор инструментов первого среза:** ~11 базовых EASY (см. §3). Достаточно,
|
||||
чтобы проверить насквозь ядро → реестр → универсальную страницу.
|
||||
- **Стили:** обычный CSS без фреймворка — design-токены на CSS custom
|
||||
properties, ноль зависимостей.
|
||||
- **Стек:** SvelteKit + `@sveltejs/adapter-static` + TypeScript, пакетный
|
||||
менеджер pnpm (уже зафиксирован в корневом `package.json`). Весь код сайта — в
|
||||
`web/`.
|
||||
- **Ядро без DOM:** все пиксельные операции — чистые функции над `PixelImage`;
|
||||
DOM/canvas допускается только в модуле IO и компонентах превью.
|
||||
|
||||
---
|
||||
|
||||
## 1. Шаг 0 — Каркас проекта
|
||||
|
||||
1. Создать SvelteKit-приложение в `web/`: `npx sv create web` (шаблон minimal,
|
||||
TypeScript, без лишних аддонов).
|
||||
2. `pnpm install` внутри `web/`.
|
||||
3. `svelte.config.js`: заменить адаптер на `@sveltejs/adapter-static`.
|
||||
4. `src/routes/+layout.ts`: `export const prerender = true;` — весь сайт
|
||||
prerenderится в статику.
|
||||
5. `src/app.css`: дизайн-токены (палитра, типографика, отступы, радиусы через
|
||||
CSS custom properties) + минимальный сброс стилей.
|
||||
6. `src/routes/+layout.svelte`: шапка с названием сайта и навигацией по
|
||||
категориям инструментов (категории — константа из реестра), футер.
|
||||
|
||||
**Проверка:** `pnpm dev` открывает пустой каркас; `pnpm build && pnpm preview`
|
||||
отдаёт статический экспорт.
|
||||
|
||||
---
|
||||
|
||||
## 2. Шаг 1 — Ядро: типы и ввод-вывод
|
||||
|
||||
Файлы в `web/src/lib/core/`:
|
||||
|
||||
- `types.ts`
|
||||
|
||||
```ts
|
||||
export type PixelImage = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
}; // RGBA, как ImageData
|
||||
```
|
||||
|
||||
- `io.ts` — единственное место ядра, где разрешён canvas/DOM:
|
||||
- `decodeFile(file: File): Promise<PixelImage>` — `createImageBitmap(file)` →
|
||||
отрисовка на canvas → `getImageData`;
|
||||
- `encode(img: PixelImage, mime: 'image/png' | 'image/jpeg' | 'image/webp', quality?: number): Promise<Blob>`
|
||||
— `putImageData` → `canvas.toBlob`;
|
||||
- `downloadBlob(blob: Blob, filename: string): void`.
|
||||
|
||||
**Проверка:** юнит-смоук — декодировать фикстурный PNG, закодировать обратно,
|
||||
размеры совпадают.
|
||||
|
||||
---
|
||||
|
||||
## 3. Шаг 2 — Операции ядра (чистые функции)
|
||||
|
||||
Файлы в `web/src/lib/core/`, каждая функция: `(img, params) => new PixelImage`,
|
||||
без мутации входа:
|
||||
|
||||
- `color.ts`: `grayscale(img)` (luma), `invert(img)`,
|
||||
`brightnessContrast(img, brightness −100..100, contrast −100..100)`.
|
||||
- `geometry.ts`: `flip(img, axis)`, `rotate90(img, turns)`,
|
||||
`crop(img, x, y, w, h)` — индексная математика; `resize(img, w, h)` —
|
||||
билинейная интерполяция на TS (не `drawImage`, чтобы ядро осталось DOM-free и
|
||||
воспроизводимым бит-в-бит).
|
||||
- `alpha.ts`: `removeColorToAlpha(img, hex, tolerancePercent)` — цветовое
|
||||
расстояние → альфа 0.
|
||||
- `analyze.ts`: `imageInfo(img)` → `{ width, height, hasAlpha, colorCount }`.
|
||||
|
||||
**Проверка:** vitest на маленьких матрицах пикселей (2×2, 3×3): flip, rotate,
|
||||
crop, grayscale — ожидаемые значения руками.
|
||||
|
||||
---
|
||||
|
||||
## 4. Шаг 3 — Реестр инструментов
|
||||
|
||||
`web/src/lib/registry.ts` — источник истины для страниц, форм и навигации:
|
||||
|
||||
```ts
|
||||
export type ParamDef =
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "number";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "select";
|
||||
options: { value: string; label: string }[];
|
||||
default: string;
|
||||
}
|
||||
| { id: string; label: string; type: "checkbox"; default: boolean }
|
||||
| { id: string; label: string; type: "color"; default: string };
|
||||
|
||||
export type ToolEntry = {
|
||||
id: string; // slug маршрута
|
||||
title: string;
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
params: ParamDef[];
|
||||
run: (
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<PixelImage> | PixelImage;
|
||||
};
|
||||
```
|
||||
|
||||
Категории (константа там же): `convert`, `alpha`, `color`, `geometry`,
|
||||
`analyze`.
|
||||
|
||||
### Состав первого среза — 11 инструментов
|
||||
|
||||
| # | id | Категория | Параметры |
|
||||
| --- | -------------------------------- | --------- | ----------------------------------------------------- |
|
||||
| 1 | `resize-png` | geometry | width, height, checkbox «сохранять пропорции» |
|
||||
| 2 | `crop-png` | geometry | x, y, width, height |
|
||||
| 3 | `rotate-png` | geometry | select: 90 / 180 / 270 |
|
||||
| 4 | `flip-png` | geometry | select: horizontal / vertical |
|
||||
| 5 | `grayscale-png` | color | — |
|
||||
| 6 | `invert-colors-png` | color | — |
|
||||
| 7 | `adjust-brightness-contrast-png` | color | brightness −100..100, contrast −100..100 |
|
||||
| 8 | `convert-png-to-jpg` | convert | background color (подложка под альфу), quality 0..100 |
|
||||
| 9 | `convert-png-to-webp` | convert | quality 0..100 |
|
||||
| 10 | `remove-color-from-png` | alpha | hex color, tolerance % |
|
||||
| 11 | `png-info` | analyze | — особый случай: не картинка, а текстовая сводка |
|
||||
|
||||
Для `png-info` в записи реестра добавляется флаг `resultType: 'info'`; остальные
|
||||
— `'image'`.
|
||||
|
||||
**Проверка:** импорт реестра в тесте — все id уникальны, у каждого `params`
|
||||
имеют дефолты, `run` определён.
|
||||
|
||||
---
|
||||
|
||||
## 5. Шаг 4 — Универсальные UI-компоненты
|
||||
|
||||
`web/src/lib/components/`:
|
||||
|
||||
- `DropZone.svelte` — drag&drop + клик; эмитит `File`; проверка MIME
|
||||
(PNG/JPEG/WebP/GIF/BMP).
|
||||
- `Preview.svelte` — рендер `PixelImage` на canvas с fit-масштабированием;
|
||||
шахматный фон под прозрачность.
|
||||
- `ParamForm.svelte` — генерирует контролы из `ParamDef[]`, двусторонний
|
||||
`bind:values`.
|
||||
- `DownloadButton.svelte` — encode выбранного формата + `downloadBlob`, имя
|
||||
`<tool-id>.<ext>`.
|
||||
- `InfoPanel.svelte` — текстовый вывод для `resultType: 'info'`.
|
||||
- `ToolPage.svelte` — оркестратор страницы инструмента; состояния
|
||||
`idle → loaded → processing → done | error`; раскладка: слева DropZone, справа
|
||||
превью + форма параметров + кнопки «Применить» / «Скачать».
|
||||
|
||||
**Правило:** новый инструмент = новая запись в реестре (+ функция операции).
|
||||
Компоненты и маршрут не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## 6. Шаг 5 — Маршруты
|
||||
|
||||
- `/` (`src/routes/+page.svelte`) — сетка карточек инструментов, сгруппированных
|
||||
по категориям; данные из реестра.
|
||||
- `/tools/[id]` — универсальная страница:
|
||||
- `+page.ts`: `entries()` возвращает все `id` из реестра (обязательное условие
|
||||
prerender при adapter-static);
|
||||
- `load()` возвращает запись реестра по `id`, неизвестный id → 404;
|
||||
- `+page.svelte` — тонкая обёртка над `ToolPage`.
|
||||
|
||||
**Проверка:** `pnpm build` — в `build/tools/` лежат все 11 страниц; главная
|
||||
содержит ссылки на каждую.
|
||||
|
||||
---
|
||||
|
||||
## 7. Шаг 6 — Ошибки и минимальная полировка
|
||||
|
||||
- Обработка ошибок: неподдерживаемый формат, ошибка декода, некорректные
|
||||
параметры crop (выход за границы — клампы или понятная ошибка).
|
||||
- Состояния: «перетащите файл», спиннер во время обработки, disabled-кнопки до
|
||||
загрузки.
|
||||
- Тексты описаний для всех 11 инструментов (title/description уже в реестре).
|
||||
|
||||
---
|
||||
|
||||
## 8. Шаг 7 — Верификация и checkpoint
|
||||
|
||||
Команды:
|
||||
|
||||
```bash
|
||||
cd web && pnpm dev # разработка
|
||||
cd web && pnpm build # статический экспорт должен проходить без ошибок
|
||||
cd web && pnpm test # vitest: ядро (геометрия/цвет) + целостность реестра
|
||||
```
|
||||
|
||||
Ручной smoke-тест каждого из 11 инструментов: загрузка PNG → применить → скачать
|
||||
→ открыть результат.
|
||||
|
||||
**Checkpoint MVP:** любой из 11 инструментов доступен по прямому URL с главной
|
||||
страницы; PNG загружается drag&drop; параметры применяются; результат
|
||||
скачивается; всё работает офлайн со статического хостинга; ни одной серверной
|
||||
зависимости.
|
||||
@@ -0,0 +1,213 @@
|
||||
# План: аудит верности `preview/*` против `refs-html` (DOM → CSS)
|
||||
|
||||
> **СТАТУС: РЕАЛИЗОВАН (2026-09-07) — устарел.** Скрипты
|
||||
> `web/scripts/audit-dom.mjs`, `audit-css.mjs`, `audit-cdp.mjs`,
|
||||
> `audit-cdp-responsive.mjs` и npm-скрипты `refs-dom-audit` / `refs-css-audit` /
|
||||
> `refs-cdp-audit` на месте; старый текстовый `audit.mjs` удалён. Актуальная
|
||||
> доводка верности и статус пунктов F — в `plan-design-fix.md`.
|
||||
|
||||
> Статус: к выполнению. Ветка `feat/redesign`. Источник правды по пикселям:
|
||||
> `refs-html/*.html` (статический экспорт рефа). Актуальный рабочий аудит сейчас
|
||||
> — `pnpm refs-audit` (`scripts/audit.mjs`), но он **сломан** (см. «Почему
|
||||
> текущий аудит не работает») и подлежит замене.
|
||||
|
||||
## Почему текущий аудит не работает
|
||||
|
||||
`scripts/audit.mjs` сопоставляет элементы **только по тексту** и берёт в
|
||||
сравнение **только листовые узлы** (у которых нет детей). Контейнеры/карточки/
|
||||
панели, несущие `border-radius`, отступы и позиционирование, в сравнение
|
||||
**вообще не попадают** (`byText` кладёт в мапу только элементы с непустым
|
||||
`text`). Следствия:
|
||||
|
||||
- разница радиусов у карточек невидима (карточка — родитель без своего текста);
|
||||
- позиция кнопки (`Download result`) не ловится — это структурная разница;
|
||||
- `onlyOurs / onlyRef` — это просто счётчик текстовых строк на одной стороне,
|
||||
**не метрика верности**.
|
||||
|
||||
Плюс он не делает пиксельного сравнения, то есть принципиально не может сказать
|
||||
«выглядит по-другому». Это текстовый дифф стилей листовых текстов — и всё.
|
||||
|
||||
## Решения (подтверждены автором)
|
||||
|
||||
1. **Копирайт превью — через i18n `t()` с EN-базой.** Сейчас RU-литералы
|
||||
хардкодом только в `preview/+page.svelte:26-27` и
|
||||
`preview/list-tools/+page.svelte:31` (эти страницы не используют i18n, в
|
||||
отличие от `(old)/` и компонентов `DownloadButton`/`ChainToolBlock`/…).
|
||||
Заводим их через `t()` — совпадёт с рефом (EN).
|
||||
2. **Имена классов НЕ сводим к рефу.** Матчинг делаем по **структурному пути**,
|
||||
а не по классу. Компоненты `kit/` остаются идиоматичными, Stage 0 не
|
||||
откатываем. Требуется только эквивалентность _структуры_ (теги, вложенность,
|
||||
порядок сиблингов, наличие узлов, текст).
|
||||
3. **Пиксельный дифф (Playwright + pixelmatch) пока НЕ делаем.** DOM+CSS-аудита
|
||||
достаточно; добавим как сеть позже, если стили что-то упустят.
|
||||
4. **Порядок прогона маршрутов:** `demo` → `list-tools` → затем 2 инструмента
|
||||
(`tools/linear-gradient-png` ↔ `gradient.html`, `tools/remove-background-png`
|
||||
↔ `background-remover.html`).
|
||||
|
||||
## Принципы
|
||||
|
||||
- Никаких правок старого дизайна `(old)/` и старого `ui/`.
|
||||
- Коммиты атомарные (< ~500 строк), по 1–2 компонента/скрипта; после каждого —
|
||||
`lint` + `svelte-check` + `build` + соответствующий аудит (diff не должен
|
||||
расти без причины).
|
||||
- Коммиты делает автор (агент не коммитит).
|
||||
- Шрифты уже совпадают: `design2.css:1-6` грузит IBM Plex Sans/Mono через
|
||||
`@fontsource`, токены `:34-35` — `"IBM Plex Sans"/"IBM Plex Mono"` (как в рефе
|
||||
`demo.html:67-68`). Пиксельный шум от шрифтов не предвидится.
|
||||
|
||||
## Подход: две фазы
|
||||
|
||||
Корень проблемы — нет **стабильного ключа сопоставления**. Разбиваем на две
|
||||
фазы: сначала доводим DOM до структурного равенства с рефом (фаза A), затем на
|
||||
стабильном DOM сравниваем стили по тому же пути (фаза B). Фаза A сама по себе
|
||||
ловит именно то, из-за чего возникли претензии (кнопка не в том родителе,
|
||||
лишние/недостающие обёртки, перепутанный порядок) — и без эстетических решений.
|
||||
|
||||
---
|
||||
|
||||
## Фаза A — DOM/структурный аудит (`scripts/audit-dom.mjs`)
|
||||
|
||||
**Цель:** Svelte рендерит структуру, идентичную `refs-html` (дерево тегов,
|
||||
вложенность, порядок сиблингов, наличие узлов, текст). Имена классов могут
|
||||
отличаться.
|
||||
|
||||
### Нормализация узла (ключ сравнения)
|
||||
|
||||
Для каждого элемента (пропускаем `SCRIPT`/`STYLE`/`NOSCRIPT` и скрытые
|
||||
поддеревья, игнорируем служебное):
|
||||
|
||||
- **тег** (`tagName` в lower-case);
|
||||
- **классы** — как отсортированный набор (для инфо, не для матчинга);
|
||||
- **нормализованный текст** — `trim`, `collapse-whitespace`, без SVG-иконок
|
||||
внутри (текст иконок игнорируем);
|
||||
- **порядок детей** — позиция среди сиблингов (`nth-child`).
|
||||
|
||||
Матчинг — по **структурному пути** от корня сравнения:
|
||||
`tag:nth-child(i) > tag:nth-child(j) …`. Не по классу и не по тексту.
|
||||
|
||||
### Полное соответствие, без IGNORE
|
||||
|
||||
Оболочка (наш `TopBar`/`AppShell`/`preview-footer` vs рефовый
|
||||
`.topbar`/`.footer`/`.language`) тоже сводим к рефу — **IGNORE-список не
|
||||
заводим**. Сравниваем всё дерево от `<body>` (без пропуска служебного, кроме
|
||||
Svelte-атрибутов и комментов гидрации), чтобы получить полное структурное
|
||||
соответствие, а не только контента. Шапка/футер/переключатель языка — часть
|
||||
верности; позже, после полного фикса дизайна, всё равно планируется рефакторинг
|
||||
компонентов, где оболочку приведём в порядок и унифицируем. Аудит на этом этапе
|
||||
просто показывает все расхождения, включая оболочечные.
|
||||
|
||||
### Дифф
|
||||
|
||||
Для каждого маршрута — набор дельт:
|
||||
|
||||
- `added` — у нас, нет в рефе;
|
||||
- `removed` — в рефе, нет у нас;
|
||||
- `moved` — тот же текст/тег, другой путь (перепутанный порядок/родитель);
|
||||
- `textMismatch` — нормализованный текст различается (в т.ч. RU vs EN).
|
||||
|
||||
Класс-мисматч — **только информационно**, не блокирует (классы свои по решению
|
||||
№2). Счётчики: `added / removed / moved / textMismatch`.
|
||||
|
||||
### Рендеринг
|
||||
|
||||
- **наше:** dev-сервер на `5179` (как сейчас в `audit.mjs`).
|
||||
- **реф:** `pathToFileURL` к `refs-html/<ref>.html` (`file://`). Реф —
|
||||
самодостаточный (инлайн `<style>`), рендерится полностью. Шрифты для фазы A не
|
||||
важны (сравниваем структуру/текст, не растеризацию).
|
||||
- Переиспользуем из текущего `audit.mjs`: `discoverRoutes()`, `EXCLUDE`
|
||||
(`/preview`), `DYNAMIC` (`tools/[id]` → `gradient.html` /
|
||||
`background-remover.html`), `refNameFor()`.
|
||||
|
||||
### Шаги (на каждом маршруте)
|
||||
|
||||
1. Прогнать `pnpm refs-dom-audit`, получить дельты.
|
||||
2. Поправить структуру Svelte под реф (добавить/убрать/переставить узлы, вынести
|
||||
тексты в `t()` с EN-базой).
|
||||
3. Перезапустить, пока `added/removed/moved/textMismatch` ≃ 0 (кроме `IGNORE`).
|
||||
|
||||
---
|
||||
|
||||
## Фаза B — CSS-аудит по стабильному DOM (`scripts/audit-css.mjs`)
|
||||
|
||||
**Цель:** на совпадающем дереве сравнить вычисленные стили по тому же
|
||||
структурному пути и получить «элемент по пути X: свойство → наше/реф».
|
||||
|
||||
### Сравниваемые свойства (computed)
|
||||
|
||||
`borderRadius`, позиция/размер (`getBoundingClientRect`: x/y/w/h), `padding`
|
||||
(все стороны), `margin`, `color`, `backgroundColor`,
|
||||
`borderTop/Right/Bottom/Left Width|Color`, `fontFamily`, `fontSize`,
|
||||
`fontWeight`, `letterSpacing`, `lineHeight`, `gap`, `display`, `boxShadow`.
|
||||
|
||||
### Дифф
|
||||
|
||||
Для каждого состыкованного по пути элемента — список свойств с дельтой (наше
|
||||
значение → реф). Элементы, не совпавшие по пути (остаток из фазы A), идут в
|
||||
`unmatched`. Отчёт: элемент → свойство → наше/реф. Правится одной строкой CSS,
|
||||
без брутфорса.
|
||||
|
||||
### Шаги
|
||||
|
||||
1. Прогнать `pnpm refs-css-audit` (только после того, как фаза A по маршруту
|
||||
дала ≃ 0 структурных расхождений).
|
||||
2. Поправить стили в `design2.css` / стилях компонентов.
|
||||
3. Перезапустить, пока delta% по свойствам ≃ 0.
|
||||
|
||||
---
|
||||
|
||||
## Порядок маршрутов и особенности
|
||||
|
||||
1. **`/preview/demo`** (↔ `demo.html`) — уже ближе всего; идеальная «обкатка»
|
||||
тулинга фаз A и B. Здесь же проверим, что `Download result` стоит в шапке
|
||||
`.preview-top` (как в рефе), а не внизу панели.
|
||||
2. **`/preview/list-tools`** (↔ `list-tools.html`) — объёмнее; **зависит от
|
||||
`docs/plan-list-tools.md`**: реальный аудит имеет смысл только после того,
|
||||
как страница переписана на `CatalogHeader`/`CatalogToolbar`/`CatalogGroup`
|
||||
(шаги 1–5 плана list-tools). То есть фаза A/B для list-tools стартует после
|
||||
landing этих шагов.
|
||||
3. **`tools/linear-gradient-png`** (↔ `gradient.html`) и
|
||||
**`tools/remove-background-png`** (↔ `background-remover.html`) —
|
||||
**последние**. Известные расхождения: наши инструменты могут иметь **другие
|
||||
наборы параметров / UI**, чем конкретные рефовые тулзы. Ожидаются структурные
|
||||
и стилевые дельты, которые не сводятся к «подровнять паддинг». Для них —
|
||||
триаж после прогона:
|
||||
- реальный разрыв верности → фиксим;
|
||||
- принципиальное отличие (параметр, которого нет в реф-тулзе; иной контрол) →
|
||||
либо аккуратно приводим наш UI параметра к рефу, либо **документируем как
|
||||
допустимое расхождение / заносим в allowlist** конфига аудита (селектор +
|
||||
причина). «Скипы/фиксы» для инструментов оформляем явно, чтобы аудит не
|
||||
шумел на осознанных отличиях.
|
||||
|
||||
---
|
||||
|
||||
## Структура скриптов
|
||||
|
||||
- `scripts/audit-dom.mjs` — фаза A (нормализация + структурный дифф).
|
||||
- `scripts/audit-css.mjs` — фаза B (матчинг по пути + дифф стилей).
|
||||
- `package.json`: `refs-dom-audit`, `refs-css-audit` (опционально `refs-audit`,
|
||||
прогоняющий обе фазы).
|
||||
- Переиспользуем из старого `audit.mjs`: `discoverRoutes`, `EXCLUDE`, `DYNAMIC`,
|
||||
`refNameFor`, `waitFor` (fast-fail на 4xx/5xx).
|
||||
- Отчёты: `web/audit/dom-report.{md,json}` и `web/audit/css-report.{md,json}`
|
||||
(старый `audit-report.*` заменяем).
|
||||
|
||||
## Верификация (после каждого коммита)
|
||||
|
||||
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`, `pnpm build`
|
||||
— без ошибок.
|
||||
- `pnpm refs-dom-audit` (фаза A) / `pnpm refs-css-audit` (фаза B) по затронутому
|
||||
маршруту: счётчики расхождений не растут без причины.
|
||||
|
||||
## Риски / заметки
|
||||
|
||||
- **Стабильность матчинга:** нужна аккуратная нормализация (сортировка классов,
|
||||
игнор svelte-атрибутов/`data-*`, игнор whitespace-текстовых узлов, игнор
|
||||
внутренностей SVG-иконок). Подстраивается по первым прогонам demo.
|
||||
- **Оболочка** (TopBar/footer/язык) сейчас отличается по дизайну — сводим к рефу
|
||||
в рамках полного соответствия (без IGNORE); финальная унификация компонентов —
|
||||
отдельным рефакторингом позже.
|
||||
- **Динамические маршруты:** `DYNAMIC` уже есть; оставляем.
|
||||
- **Hover / адаптив / тёмная тема** — вне объёма статического аудита (initial
|
||||
state, 1440×900, light). Отмечаем как будущие прогоны.
|
||||
- **list-tools** зависит от landing `plan-list-tools.md` (шаги 1–5) — не
|
||||
аудируем вслепую.
|
||||
@@ -0,0 +1,114 @@
|
||||
# План C17: перенос preview → корень + (old) → /v1/
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН (2026-09-11) — устарел.** Перенос завершён:
|
||||
> preview-страницы на реальных маршрутах, старый дизайн на `/v1/*`,
|
||||
> `app.css`/`app_v1.css`, isolation-плагин на v1-путях, аудит-скрипты в
|
||||
> `scripts/archive/`. C18 (правки импортов/редиректов, проверка билда) тоже
|
||||
> выполнен. Следующий этап — C19–C21 (`plan-redesign.md`, §10).
|
||||
>
|
||||
> Оригинальный статус до выполнения: план к выполнению. Предпосылки: tech debt
|
||||
> закрыт, backlog №14 (старые файлы → lib/old/) выполнен. Домен НЕ блокер (см.
|
||||
> plan-redesign.md §10, plan-seo.md §6).
|
||||
|
||||
## Цель
|
||||
|
||||
Новый дизайн становится основным на корневых маршрутах (`/`, `/tools/[id]`,
|
||||
`/list-tools`). Старый дизайн переезжает на `/v1/*` как архив для справки.
|
||||
Удаление старого кода (C19) откладывается до полной приёмки нового дизайна.
|
||||
|
||||
## Структура маршрутов (до → после)
|
||||
|
||||
**До:** `(old)/` даёт URLs `/`, `/demo`, `/list-tools`, `/tools/[id]`.
|
||||
`preview/` даёт `/preview`, `/preview/list-tools`, `/preview/tools/[id]`,
|
||||
`/preview/kit`.
|
||||
|
||||
**После:** `v1/` даёт `/v1/`, `/v1/demo`, `/v1/list-tools`, `/v1/tools/[id]`.
|
||||
Корень даёт `/`, `/list-tools`, `/tools/[id]`. Витрина kit — на реальном
|
||||
маршруте `/kit` (дизайн в стадии тестирования).
|
||||
|
||||
## Решения
|
||||
|
||||
1. **`v1/` — реальная папка, НЕ route group** — group `(v1)/` не меняет URL, а
|
||||
нам нужно чтобы старые страницы жили по `/v1/*`.
|
||||
2. **Корневой layout: shell нового дизайна** — TopBar, Footer, тема, хлебные
|
||||
крошки, импорт `app.css`, `@layer app, design;`.
|
||||
3. **`v1/+layout.svelte` — только импорт `app_v1.css`** — без
|
||||
header/footer/shell.
|
||||
4. **Тема:** использовать `theme.svelte.ts` (не дублировать локальный `$state`).
|
||||
5. **`@layer app, design;`** остаётся — design выигрывает у app везде.
|
||||
6. **`noindex` остаётся** на `tools/[id]/+page.svelte` до покупки домена.
|
||||
7. **CSS-файлы переименовываются:** `preview.css` → `app.css`, `old.css` →
|
||||
`app_v1.css`. Все ссылки обновляются.
|
||||
8. **`kit/` витрина → `/kit`** — переносится на реальный маршрут вместе с
|
||||
остальными preview-страницами; отдельный `preview/+layout` удаляется (`/kit`
|
||||
наследует корневой shell).
|
||||
|
||||
## Что делать (шаги)
|
||||
|
||||
### 1. Rename `(old)/` → `v1/`
|
||||
|
||||
Переименовать папку. Обновить `v1/+layout.svelte`: убрать «Preview v2» ссылку,
|
||||
обновить пути навигации, поправить импорт CSS.
|
||||
|
||||
### 2. Новый корневой `+layout.svelte` + rename CSS
|
||||
|
||||
Перезаписать текущий нейтральный layout содержимым из `preview/+layout.svelte`
|
||||
(с правками: тема через `theme.svelte.ts`, путь к CSS). Переименовать
|
||||
`src/preview.css` → `src/app.css`, `src/old.css` → `src/app_v1.css`. Обновить
|
||||
все ссылки на эти файлы в проекте ( layout, лайнтеры, eslint-плагины, скрипты,
|
||||
тесты).
|
||||
|
||||
### 3. Move preview pages → корень
|
||||
|
||||
Переместить страницы из `preview/` в корень `routes/`: `+page` → `/`,
|
||||
`list-tools` → `/`, `tools/[id]` → `/`, витрину `kit` → `/kit`. Удалить
|
||||
`preview/+layout.svelte` (корневой shell заменяет его).
|
||||
|
||||
### 4. Update hardcoded `/preview/` URLs
|
||||
|
||||
В компонентах с захардкоженными путями `/preview/` заменить на корневые.
|
||||
|
||||
### 5. Flatten `kit/` → `components/`
|
||||
|
||||
Перенести компоненты из `lib/components/kit/` в `lib/components/`, обновить все
|
||||
импорты, удалить пустую `kit/`.
|
||||
|
||||
### 6. Flatten `preview/` → `lib/`
|
||||
|
||||
Перенести код из `lib/previw/` в `lib/`, обновить все импорты, удалить пустую
|
||||
`preview/`.
|
||||
|
||||
### 7. Перенести `old/` → `v1/`
|
||||
|
||||
- `$lib/old/` — переименовать в `lib/v1/`, обновить все импорты
|
||||
|
||||
### 8. E2E тесты + playwright config
|
||||
|
||||
Заменить `/preview/...` на `/...` во всех e2e файлах и конфиге playwright.
|
||||
|
||||
### 9. ESLint config + AGENTS.md
|
||||
|
||||
Обновить glob-паттерны: `routes/preview/**` → `routes/**`, `components/kit/**` →
|
||||
`components/**`. Обновить `AGENTS.md`.
|
||||
|
||||
### 10. Isolation plugin
|
||||
|
||||
Обновить `DEFAULT_OLD`: `routes/(old)/**` → `routes/v1/**`. Убрать
|
||||
`routes/preview/**` из `DEFAULT_NEW`.
|
||||
|
||||
### 11. Archive audit scripts
|
||||
|
||||
Перенести скрипты аудита в `scripts/archive/`, убрать вызовы из манифестов.
|
||||
|
||||
## Что НЕ меняется
|
||||
|
||||
- `routes/+layout.ts` — prerender = true остаётся
|
||||
- Слаги инструментов (`*-png`) — без изменений
|
||||
- `/kit` — витрина дизайна остаётся (в стадии тестирования)
|
||||
|
||||
## Риски
|
||||
|
||||
- **v1/ без shell** — старый дизайн без header/footer.
|
||||
- **`@layer` взаимодействие** — проверить что design-токены перекрывают
|
||||
app-токены на обоих маршрутах.
|
||||
- **Isolation: shared-классификация** — маршруты нового дизайна shared до C19.
|
||||
@@ -0,0 +1,593 @@
|
||||
# План: составные типы параметров и полная типизация pipeline
|
||||
|
||||
> **СТАТУС: ЗАКРЫТ (2026-09-07).** Типизация pipeline, составные типы
|
||||
> (dimension/color-pair/offset/position9/font-style/plate/gradient),
|
||||
> `schema.layout`, изоляция веток и вся миграция инструментов (121/125,
|
||||
> остальные 4 — отложены решениями) выполнены. Ключевые архитектурные решения
|
||||
> (параллельная сборка, правило копий, изоляция old↔preview) закреплены в
|
||||
> `AGENTS.md`. **Незакрытое из этого плана:** шаг 37 — физический переезд старых
|
||||
> файлов в папки `old/` (перенесён в `backlog.md`), и удаление старого кода — по
|
||||
> `plan-redesign.md` (C17–C21). Отложенные инструменты — `watermark-image-png`
|
||||
> (overlay), `png-info`, `reduce-to-size` — в `backlog.md`. Ниже — полное
|
||||
> состояние на момент закрытия.
|
||||
|
||||
> Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент в
|
||||
> preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены все
|
||||
> одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension` полностью
|
||||
> переведён** (12 инструментов: create-empty, single-color, random-noise,
|
||||
> linear-gradient, color-spectrum, random-colors, draw-grid, placeholder,
|
||||
> fit-on-background, change-canvas-size, resize, crop); **составной тип
|
||||
> `color-pair` полностью переведён** (4 инструмента: blend-two, step-colors,
|
||||
> linear-gradient, two-colors); **составной тип `offset` полностью переведён**
|
||||
> (4 инструмента: circle-mask, square-mask, star-mask, wavy-mask); **составной
|
||||
> тип `position9` — переведены add-text-png и date-stamp-png** (водяной
|
||||
> знак-картинка — отдельный шаг: требует overlay-механику в новом превью);
|
||||
> **составной тип `font-style` полностью переведён** (text-to-png, add-text,
|
||||
> date-stamp); **составной тип `plate` полностью переведён** (add-text,
|
||||
> date-stamp); **составной тип `gradient` полностью переведён**
|
||||
> (linear-gradient): вместо горизонтального/ вертикального select — **угол
|
||||
> 0..360°** (новый виджет `AngleControl`: slider + кнопки-пресеты
|
||||
> 0°/90°/180°/270°, переиспользуемый компонент); preview научен применять
|
||||
> **генераторы** (`executeGenerate`, кнопка Generate) и рендерить все kinds
|
||||
> схемы (slider/number/color/select/checkbox/dimension/
|
||||
> color-pair/offset/position9/font-style/plate/gradient). **Пер-инструмент
|
||||
> раскладка `schema.layout` реализована** (шаг 32): тип `ToolSchemaLayout`
|
||||
> {groups: [{title?, cols?, fields}]}, рендер групп в `SchemaFields.svelte`
|
||||
> (заголовок группы + сетка колонок, неупомянутые поля — в общей группе), пилот
|
||||
> — `add-text` (группы Text/Placement/Plate). **UI-макеты расставлены по
|
||||
> переведённым инструментам** (шаг 33): geometry (fit-on-background,
|
||||
> change-canvas-size, resize, crop), маски alpha (Shape/Position), generate
|
||||
> (create-empty, linear-gradient, color-spectrum, random-colors, draw-grid,
|
||||
> step-colors, placeholder, text-to-png), text (add-text, date-stamp), filters
|
||||
> (randomize-pixels, add-noise) — везде, где канвас отделён от параметров
|
||||
> эффекта либо фигура от позиции. Следующее закрыто в
|
||||
> `archive/plan-migrate-remaining.md` (2026-09-07): перевод оставшихся
|
||||
> инструментов выполнен (всего 121/125 в preview; 4 хвоста —
|
||||
> `watermark-image-png`, `png-info`, `reduce-to-size-png`, `compress-png` —
|
||||
> отложены решениями в `backlog.md`). Незакрытые продолжения: переезд старых
|
||||
> компонентов в папки `old/` (шаг 37 ниже), закрытие долгов и сбор фидбека — см.
|
||||
> также `plan-design-fix.md`.
|
||||
>
|
||||
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||
>
|
||||
> - `web/src/lib/preview/categories.ts`, `web/src/lib/registry-schema.ts` (kind
|
||||
> `dimension`), `kit/fields/DimensionField.svelte`,
|
||||
> `preview/executor/index.ts` (`executeGenerate`), `SchemaFields.svelte`
|
||||
> (полный рендер kinds). Старый `web/src/lib/registry.ts` разбит по категориям
|
||||
> в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
|
||||
|
||||
## Ключевая стратегия: параллельная сборка, старый UI не трогаем
|
||||
|
||||
Старый UI (группа `(old)/`, `ParamForm.svelte` + старый pipeline) **продолжает
|
||||
работать как сейчас, без рефакторинга**. Новый `ToolSchema<P>` строится рядом и
|
||||
служит источником для **нового UI**. Инструменты переписываются под новый
|
||||
registry по мере нужды — для нового UI.
|
||||
|
||||
Это та же логика, что в `plan-redesign.md` применена к дизайну: параллельная
|
||||
сборка, изоляция от старого, затем новый становится основным и старый удаляется
|
||||
вместе со старым дизайном.
|
||||
|
||||
**Правило копий (важно!):** если для нового UI/registry нужно внести изменения в
|
||||
файл, который **прямо или косвенно** уже используется `(old)` веткой, — этот
|
||||
файл **НЕ трогаем**. Вместо этого делаем **копию** в новом месте (например, в
|
||||
`lib/preview/`) и правим копию. Это приводит к дублированию, но **гарантированно
|
||||
не задевает старую ветку сайта**. Пример: категории — новый
|
||||
`lib/preview/categories.ts` (object as const) копирует и заменяет собой
|
||||
`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и
|
||||
остаётся без изменений. После перехода (Фаза 5) копия становится основной, а
|
||||
исходник продолжает обслуживать `(old)` UI (он не удаляется — см. шаг 35).
|
||||
|
||||
**Разделение схем: две независимые схемы.**
|
||||
|
||||
| Схема | Источник | Использование |
|
||||
| --------------------- | ------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| Старый `ParamDef[]` | В `ToolEntry.params` — **остаётся без изменений** | Старый UI, старый pipeline (`ParamForm.svelte`, executor, chain) |
|
||||
| Новый `ToolSchema<P>` | Новое поле `ToolEntry.schema` — добавляется рядом | Новый UI (kit/`ParamControl`, новый pipeline) |
|
||||
|
||||
- Старая и новая схемы **не выводятся друг из друга** — это два независимых
|
||||
описания инструмента.
|
||||
- Пока новый UI не готов — `schema` просто отсутствует у большинства
|
||||
инструментов, старый UI ничего не замечает.
|
||||
- Удаление старого UI (после перехода) попутно удаляет и старый `ParamDef[]`
|
||||
|
||||
### Разделение registry по UI (новое решение, Фаза 2+)
|
||||
|
||||
Помимо двух схем, registry тоже разделён по UI (по факту миграции):
|
||||
|
||||
- **Старый registry** (`web/src/lib/registry.ts` + `registry/` +
|
||||
`registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]`. **Не
|
||||
трогаем**; остаётся обслуживать `(old)/` маршруты (см. шаг 35).
|
||||
- **Новый registry** (`web/src/lib/registry-new/`) — строится **с нуля «как
|
||||
надо»**: `ToolEntry<P>` с обязательным `schema`, типизированный `run`, **без**
|
||||
`ParamDef[]` и **без** связи со старым. Импортирует core-функции
|
||||
(`expandCanvas`, `strokeImage`, …) напрямую. Наполняется **по-инструментно**
|
||||
по мере миграции (не разовым переводом всех 130). Preview показывает только
|
||||
переведённые инструменты.
|
||||
|
||||
Preview (`catalog.ts`, маршруты `preview/**`, `SchemaToolView`, `SchemaFields`)
|
||||
импортируют из `$lib/registry-new`. После удаления старого UI `registry-new/`
|
||||
переименуется в `registry`.
|
||||
|
||||
**Executor/worker для нового UI (осознанное дублирование):** общий
|
||||
`executor.worker.ts` резолвит инструменты по `id` в **старом** registry — для
|
||||
новых инструментов их там нет. Поэтому `executor.ts` + `executor.worker.ts`
|
||||
**скопированы** в `web/src/lib/preview/` и переписаны под `registry-new` +
|
||||
`sanitizeSchemaParams`. Старый executor/worker под старый UI остаются
|
||||
нетронутыми; `SchemaToolView` использует `$lib/preview/executor`. Это
|
||||
дублирование необходимо до ухода старого UI (после — preview-executor заменяет
|
||||
общий).
|
||||
|
||||
## Проблема (текущая)
|
||||
|
||||
- `ParamDef` — плоский union из 6 примитивов
|
||||
(`number | slider | select | checkbox | color | text`). Параметры инструмента
|
||||
— отдельные записи в плоском массиве.
|
||||
- `run()`/`generate()` получают `Record<string, unknown>`; значения достаются
|
||||
через `num(p, "fromColor")` — runtime string lookup **без type safety**, легко
|
||||
напутать id.
|
||||
- Поля инструмента **слабо связаны** (например «ширина» и «цвет» рамки — два
|
||||
независимых поля без общего контракта) → общий генерализованный рендер, нет
|
||||
возможности сделать идеальный пер-инструмент UI.
|
||||
- Preview-система (`tool-views.ts`) **дублирует** registry (FieldDef vs
|
||||
ParamDef) — одни и те же инструменты описаны дважды.
|
||||
|
||||
## Решения (принятые в обсуждении)
|
||||
|
||||
1. **Полная типизация.** `interface Params` (runtime-тип) объявляется явно в
|
||||
каждом инструменте. `run`/`generate` типизируются прямо на него, а не на
|
||||
`Record<string, unknown>`.
|
||||
2. **Отдельная типизированная схема для UI/дефолтов/валидации.** Два понятия на
|
||||
инструмент: runtime-тип + схема. Схема проверяется компилятором на
|
||||
соответствие `Params` (чтобы не рассинхронизировались).
|
||||
3. **Общий рендер,** но с пер-инструмент layout: общие компоненты рисуют поля по
|
||||
схеме; схема инструмента задаёт layout и группировку. Без отдельного
|
||||
Svelte-компонента на каждый инструмент.
|
||||
4. **Составные типы** (`gradient`, `color-pair`, `dimension`, `offset`,
|
||||
`position9`, `font-style`, `plate`) остаются в плане — это способ выразить в
|
||||
схеме связанную группу полей и переиспользовать её на нескольких инструментах
|
||||
и в UI.
|
||||
5. Рано или поздно `tool-views.ts` поглощается registry (preview/lede/layout
|
||||
переезжают в meta инструмента) — но это отдельный шаг, см. ниже.
|
||||
6. **Старый UI не рефакторим.** Две независимые схемы: старый `ParamDef[]`
|
||||
остаётся источником для старого UI без изменений; новый `ToolSchema<P>`
|
||||
строится рядом для нового UI (см. «Ключевая стратегия» выше).
|
||||
|
||||
## Целевая архитектура (на примере «добавить рамку»)
|
||||
|
||||
Цель: «добавить рамку: ширина и цвет» — **один интерфейс**, а не два слабо
|
||||
связанных поля.
|
||||
|
||||
```ts
|
||||
// 1. Runtime-тип — что получает run/generate
|
||||
interface AddBorderParams {
|
||||
thickness: number; // рамка: ширина
|
||||
color: string; // рамка: цвет
|
||||
}
|
||||
|
||||
// 2. Типизированная UI-схема, проверяемая компилятором против Params
|
||||
// Ключи схемы обязаны совпадать с полями Params; default совместим с типом.
|
||||
const addBorderSchema = toolSchema<AddBorderParams>({
|
||||
thickness: field.slider({ min: 1, max: 500, default: 5 }),
|
||||
color: field.color({ default: "#000000" }),
|
||||
layout: { group: "border", cols: 2 }, // пер-инструмент раскладка
|
||||
});
|
||||
|
||||
// 3. Инструмент в registry
|
||||
const addBorder: ToolEntry<AddBorderParams> = {
|
||||
id: "add-border-png",
|
||||
title: "Add border to PNG",
|
||||
category: "geometry",
|
||||
schema: addBorderSchema,
|
||||
run: (img, p) => expandCanvas(img, 4x..., p.color), // p.thickness — type safe
|
||||
};
|
||||
```
|
||||
|
||||
### Что даёт
|
||||
|
||||
- `p.thickness`, `p.color` — IDE autocomplete + compile-time ошибка при ошибке.
|
||||
- `run()` нельзя передать лишний/чужой ключ.
|
||||
- Схема — единый источник для дефолтов, валидации и рендера.
|
||||
- Схема одного инструмента может задавать layout, которого нет в общем рендере.
|
||||
|
||||
## Типовая система (набросок)
|
||||
|
||||
```ts
|
||||
// ─── Поле схемы ───
|
||||
interface NumberField { kind: "number"; default: number; min?: number; max?: number; step?: number }
|
||||
interface SliderField { kind: "slider"; default: number; min: number; max: number; step?: number }
|
||||
interface ColorField { kind: "color"; default: string }
|
||||
interface SelectField { kind: "select"; default: string; options: { value: string; label: string }[] }
|
||||
interface TextField { kind: "text"; default: string; placeholder?: string }
|
||||
interface CheckboxField { kind: "checkbox"; default: boolean }
|
||||
|
||||
type FieldDef =
|
||||
| NumberField | SliderField | ColorField | SelectField | TextField | CheckboxField;
|
||||
|
||||
// ─── «поле» описывает тип значения + схему вместе, чтобы не рассинхронизировать ───
|
||||
interface Field<T> {
|
||||
schema: FieldDef;
|
||||
// T — ожидаемый runtime-тип этого поля (number|string|boolean)
|
||||
}
|
||||
|
||||
// ─── Builder: field.slider({...}) возвращает Field<number> с типизированной схемой ───
|
||||
const field = {
|
||||
slider: (s: SliderField): Field<number> => ({ schema: s }),
|
||||
number: (s: NumberField): Field<number> => ({ schema: s }),
|
||||
color: (s: ColorField): Field<string> => ({ schema: s }),
|
||||
select: <V extends string>(s: SelectField & { options: {value: V}[] }): Field<V> => ({ schema: s }),
|
||||
text: (s: TextField): Field<string> => ({ schema: s }),
|
||||
checkbox: (s: CheckboxField): Field<boolean> => ({ schema: s }),
|
||||
};
|
||||
|
||||
// ─── toolSchema<P>: проверяет, что ключи схемы === полям P и типы сходятся ───
|
||||
type FieldsOf<T> = { [K in keyof T]: Field<T[K]> } & {
|
||||
layout?: { group?: string; cols?: number };
|
||||
};
|
||||
function toolSchema<P>(fields: FieldsOf<P>): ToolSchema<P> { ... }
|
||||
// TS выдаст ошибку, если schema содержит ключ, отсутствующий в Params, или
|
||||
// если type поля не совпадает с типом Params[K].
|
||||
```
|
||||
|
||||
Ключевая идея связки: **builder `field.x<T>()` связывает runtime-тип поля с его
|
||||
схемой на этапе компиляции**, а `toolSchema<P>` заставляет ключи схемы совпадать
|
||||
с ключами `P`. Это и есть защита от рассинхрона.
|
||||
|
||||
## Составные типы (для переиспользования и группировки)
|
||||
|
||||
| Тип | Под-поля | Инструментов |
|
||||
| ------------ | -------------------------- | ------------ |
|
||||
| `dimension` | width + height | 12 |
|
||||
| `color-pair` | from + to | 4 |
|
||||
| `offset` | x + y | 4 |
|
||||
| `gradient` | from + to + dir (+ type?) | 1 |
|
||||
| `position9` | position (3×3 grid) | 3 |
|
||||
| `font-style` | font + size + bold + color | 3-4 |
|
||||
| `plate` | enabled + color + opacity | 2-3 |
|
||||
|
||||
Составной тип = группы полей, которые в интерфейсе `Params` могут
|
||||
разворачиваться во вложенный объект или плоские ключи — решается на этапе
|
||||
реализации. Пример для `gradient`:
|
||||
|
||||
```ts
|
||||
interface GradientParams {
|
||||
from: string;
|
||||
to: string;
|
||||
dir: string;
|
||||
}
|
||||
// схема: field.color(from), field.color(to), field.select(dir)
|
||||
// плюс annotation в схеме: { group: "gradient" } → UI рисует как цветовой переход
|
||||
```
|
||||
|
||||
## Инвентарь: какие инструменты мигрировать
|
||||
|
||||
### dimension (12)
|
||||
|
||||
resize-png*, crop-png*, fit-on-background-png, create-empty-png,
|
||||
single-color-png, random-noise-png, linear-gradient-png, color-spectrum-png,
|
||||
random-colors-png, draw-grid-png, placeholder-png, change-canvas-size-png
|
||||
|
||||
(\* у resize/crop — не просто dimension, есть специфика: keepAspect, x/y и т.д.)
|
||||
|
||||
### color-pair (4)
|
||||
|
||||
blend-two-png, step-colors-png, linear-gradient-png, two-colors-png
|
||||
|
||||
### offset (4)
|
||||
|
||||
circle-mask-png, square-mask-png, star-mask-png, wavy-mask-png
|
||||
|
||||
### position9 (3)
|
||||
|
||||
add-text-png, date-stamp-png, watermark-image-png
|
||||
|
||||
### font-style (3-4)
|
||||
|
||||
text-to-png, add-text-png, date-stamp-png
|
||||
|
||||
### plate (2-3)
|
||||
|
||||
add-text-png, date-stamp-png
|
||||
|
||||
## Пошаговый план перевода инструментов
|
||||
|
||||
> Каждый шаг — **атомарное, маленькое изменение** (в идеале 1 инструмент = 1
|
||||
> diff, укладывается в правило «коммиты < ~500 строк»). После каждого шага —
|
||||
> ревью; параллельно можно делать UI нового инструмента.
|
||||
>
|
||||
> **Принцип малых шагов:** перевод инструмента не меняет поведение старой схемы
|
||||
> (`params` остаётся), не ломает старый UI. Инструмент получает ДОПОЛНИТЕЛЬНО
|
||||
> новое поле `schema` + `interface Params`, старое `run`/`params` не трогаем.
|
||||
> Это гарантирует, что старый UI продолжает работать без регресса.
|
||||
>
|
||||
> **Новый подход (решение пользователя):** сначала делаем **один полноценный
|
||||
> рабочий инструмент в preview** — не эталон «на бумаге», а реально юзабельный,
|
||||
> чтобы руками оценить, насколько это рабочее решение и какие возникнут
|
||||
> сложности. Оцениваем, при необходимости правим инфраструктуру, и только потом
|
||||
> переводим остальные инструменты тем же проверенным паттерном.
|
||||
|
||||
### Фаза 0 — фундамент ✔ (сделано)
|
||||
|
||||
Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` — в
|
||||
`web/src/lib/registry-schema.ts`. `ToolEntry<P>` generic + опциональное поле
|
||||
`schema`. Новые `defaultSchemaParams`/`sanitizeSchemaParams`. Старый
|
||||
`ParamDef[]`/pipeline не тронуты. Проверено: compile-time-защита
|
||||
(лишний/неверный/отсутствующий ключ ловит TS), тесты, svelte-check без новых
|
||||
ошибок.
|
||||
|
||||
### Фаза 1 — ОДИН полноценный рабочий инструмент в preview (срез) ✔ (сделано)
|
||||
|
||||
Инструмент переехал в `registry-new` и рендерится через `SchemaToolView`
|
||||
(`SchemaFields`/`SchemaPreview`); применение реально работает end-to-end до
|
||||
картинки. Паттерн подтверждён. `find-contour-png` — следующий в этой фазе-2.
|
||||
|
||||
5. **Инструмент «добавить рамку» (`add-border-png`)** — сделать полностью
|
||||
рабочим в preview:
|
||||
- `interface AddBorderParams { thickness: number; color: string }`
|
||||
- `addBorderSchema = toolSchema<AddBorderParams>(...)` + `schema: ...` в
|
||||
entry (старый `params`/`run` остаются — старый UI не регрессит).
|
||||
- **Новый рендер в preview** (`kit/ToolView` → `ParamControl` или новый
|
||||
компонент схемы): читает `ToolSchema<P>`, рисует поля рамки, применение
|
||||
реально работает (end-to-end до картинки).
|
||||
- Дефолты/валидация/значения — из схемы (`defaultSchemaParams`,
|
||||
`sanitizeSchemaParams`).
|
||||
- **Ревью-оценка:** удобно ли это, что сломалось/усложнилось, что поправить в
|
||||
инфраструктуре до масштабирования. Принимается макет/решение.
|
||||
|
||||
Результат: один реально рабочий инструмент на новой системе = доказываем
|
||||
паттерн, прежде чем переводить остальных.
|
||||
|
||||
### Фаза 2 — простые инструменты без составных типов (1 инструмент = 1 шаг)
|
||||
|
||||
Одиночные/небольшие инструменты, где `interface Params` + `schema` не требуют
|
||||
составных типов. Каждый — отдельный маленький diff (~15-30 строк), тем самым
|
||||
проверенным в Фазе 1 паттерном. **Переезжают в `registry-new`.**
|
||||
|
||||
Переведено: `add-border-png` (Фаза 1), `add-stroke-png`, `find-contour-png`,
|
||||
плюс весь блок простых инструментов ниже (пункты 8-22). Следующее — Фаза 3.
|
||||
|
||||
6. ~~add-stroke-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
7. ~~find-contour-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
8. ~~convert-png-to-jpg (color + slider)~~ → переведён в `registry-new` ✔
|
||||
9. ~~convert-png-to-webp (slider)~~ → переведён в `registry-new` ✔
|
||||
10. ~~remove-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
11. ~~extract-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
12. ~~add-noise-png (slider + select + number)~~ → переведён в `registry-new` ✔
|
||||
13. ~~randomize-pixels-png (slider + number)~~ → переведён в `registry-new` ✔
|
||||
14. ~~pixelate-png (slider)~~ → переведён в `registry-new` ✔
|
||||
15. ~~vignette-png (slider)~~ → переведён в `registry-new` ✔
|
||||
16. ~~gamma-png (slider)~~ → переведён в `registry-new` ✔
|
||||
17. ~~temperature-png (slider)~~ → переведён в `registry-new` ✔
|
||||
18. ~~tint-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
19. ~~quantize-png (slider)~~ → переведён в `registry-new` ✔
|
||||
20. ~~custom-palette-png (text)~~ → переведён в `registry-new` ✔
|
||||
21. ~~dithering-png (slider + select)~~ → переведён в `registry-new` ✔
|
||||
22. ~~jpeg-artifacts-png (slider)~~ → переведён в `registry-new` ✔
|
||||
|
||||
> **Замечание (конвертеры):** старые `convert-png-to-jpg`/`convert-png-to-webp`
|
||||
> несли `output`-метаданные (`mime`/`ext`/`qualityParamId`) для выбора формата
|
||||
> вывода. В новом `ToolEntry<P>` поля `output` пока нет, поэтому при переводе
|
||||
> эти метаданные не перенесены — preview пока отдаёт результат как PNG. Механика
|
||||
> выбора формата/качества в новом UI — отдельный шаг (не блокирует Фазу 2).
|
||||
|
||||
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
|
||||
|
||||
24. `dimension` — **переведены все 12 инструментов** ✔ (`create-empty-png`,
|
||||
`single-color-png`, `random-noise-png`, `linear-gradient-png`,
|
||||
`color-spectrum-png`, `random-colors-png`, `draw-grid-png`,
|
||||
`placeholder-png` — генераторы в `registry-new/generate.ts`;
|
||||
`fit-on-background-png`, `change-canvas-size-png`, `resize-png`, `crop-png`
|
||||
— в `registry-new/geometry.ts`). Составной тип во всех видах: вложенный
|
||||
объект `size: { width, height }` в Params + `field.dimension`, виджет
|
||||
`kit/fields/DimensionField.svelte`. Preview: генераторы применяются через
|
||||
`executeGenerate` (кнопка Generate), `SchemaFields` рендерит все kinds схемы
|
||||
(slider/number/color/select/ checkbox/dimension). Тесты: 592 passed.
|
||||
25. `color-pair` — **все 4 инструмента переведены** ✔ (`blend-two-png`,
|
||||
`step-colors-png`, `linear-gradient-png` — генераторы в
|
||||
`registry-new/generate.ts`, `two-colors-png` — run в
|
||||
`registry-new/color.ts`). Составной тип во всех видах: вложенный объект
|
||||
`pair: { from, to }` + `field.colorPair`, виджет
|
||||
`kit/fields/schema/ColorPairControl.svelte`, kind `color-pair` в схеме
|
||||
(default/sanitize). Тесты: +4 (596 passed).
|
||||
26. `offset` — **все 4 инструмента переведены** ✔ (`circle-mask-png`,
|
||||
`square-mask-png`, `star-mask-png`, `wavy-mask-png` — run в
|
||||
`registry-new/alpha.ts`). Составной тип во всех видах: вложенный объект
|
||||
`offset: { x, y }` + `field.offset`, виджет
|
||||
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
||||
(default/sanitize). Тесты: +4 (600 passed).
|
||||
27. `position9` — **переведены `add-text-png` и `date-stamp-png`** ✔ (run в
|
||||
`registry-new/text.ts`, оба `domOnly`). Составной тип во всех видах:
|
||||
значение — строка `Position9`, kind `position9` в схеме (default/sanitize по
|
||||
`POSITION9_VALUES`), виджет 3×3 `kit/fields/schema/PositionControl.svelte`.
|
||||
В `ToolEntry` добавлен флаг `domOnly` (превью-executor держит такие
|
||||
инструменты вне worker). `watermark-image-png` — отдельный под-шаг: ему
|
||||
нужен overlay-source (`getOverlay`/store), которого в новом превью пока нет.
|
||||
Тесты: +2 (602 passed). `font-style` и `plate` на этих инструментах сводятся
|
||||
в шаги 28-29.
|
||||
28. `font-style` — **все 3 инструмента переведены** ✔ (`text-to-png` — генератор
|
||||
в `registry-new/generate.ts` (domOnly), `add-text-png`/ `date-stamp-png` —
|
||||
рефакторинг в `registry-new/text.ts`). Составной тип во всех видах:
|
||||
вложенный объект `style: { font, size, bold, color }` + `field.fontStyle`,
|
||||
виджет `kit/fields/schema/FontStyleControl.svelte`, kind `font-style` в
|
||||
схеме (default/sanitize, clamp размера к min/max). Тесты: +1 (603 passed).
|
||||
29. `plate` — **все 2 инструмента переведены** ✔ (`add-text-png`,
|
||||
`date-stamp-png` — рефакторинг в `registry-new/text.ts`). Составной тип во
|
||||
всех видах: вложенный объект `plate: { enabled, color, opacity }` +
|
||||
`field.plate`, виджет `kit/fields/schema/PlateControl.svelte` (чекбокс +
|
||||
цвет + слайдер непрозрачности, деактивируется при выключенной плашке), kind
|
||||
`plate` в схеме (default/sanitize, clamp opacity к 0..100). Покрытие тестов
|
||||
расширено (дефолты/sanitize plate в существующих тестах).
|
||||
30. `gradient` — **переведён** ✔ (linear-gradient-png). Направление градиента —
|
||||
не горизонталь/вертикаль, а **произвольный угол**: новый переиспользуемый
|
||||
виджет `kit/fields/schema/AngleControl.svelte` (слайдер 0..360° + кнопки
|
||||
стандартных углов 0°/90°/180°/270° с активным состоянием). Составной тип
|
||||
`gradient: { from, to, angle }` во всех видах (`field.gradient`, kind
|
||||
`gradient` в схеме — default/sanitize, clamp угла к 0..360, валидация
|
||||
цветов; виджет `kit/fields/schema/GradientControl.svelte` — пара цветов +
|
||||
AngleControl). Рендер по углу — локальный `angleGradient` в
|
||||
`registry-new/generate.ts` (core-`gradientImage` не трогали: он умеет только
|
||||
horizontal/vertical и используется старым UI). Семантика: 0° слева направо,
|
||||
90° сверху вниз, рост угла по часовой (ось Y вниз); нормализация по полному
|
||||
диапазону проекции на ось (180°/270° разворачивают градиент). Тесты:
|
||||
дефолты, направление по углу (0°/90°/180°), sanitize/кламп.
|
||||
|
||||
### Фаза 4 — масштаб UI на остальные инструменты
|
||||
|
||||
31. По мере перевода инструментов в Фазе 2-3 — рендер схемы (из Фазы 1)
|
||||
покрывает их автоматически; составные виджеты (dimension, color-pair,
|
||||
offset, position9, font-style, plate, gradient) — по одному, каждый с ревью.
|
||||
32. Пер-инструмент layout (`schema.layout`) — **реализован** ✔.
|
||||
`ToolSchemaLayout.groups: { title?, cols?, fields[] }` в registry-schema;
|
||||
`SchemaFields.svelte` рендерит группы (подпись + сетка `1..n` колонок,
|
||||
неупомянутые поля — в общей группе в конце, без заголовка). Пилот —
|
||||
`add-text-png` (Text: text+style; Placement: position+margin; Plate). Тесты:
|
||||
schema-layout в registry-schema.test.ts; полные запуски чисты.
|
||||
33. Для каждого переведённого инструмента — UI-макет **расставлен** ✔.
|
||||
Layout-группы заданы там, где группировка содержательна (канвас отдельно от
|
||||
параметров эффекта, фигура отдельно от позиции; составные виджеты — внутри
|
||||
групп целиком):
|
||||
- geometry: fit-on-background (Canvas/Background), change-canvas-size
|
||||
(Canvas/Anchor), resize (Canvas/Scaling), crop (Offset/Crop area);
|
||||
- alpha-маски: circle/square/star/wavy (Shape — с 2 колонками где плотно,
|
||||
Position — offset);
|
||||
- generate: create-empty (Canvas/Fill), linear-gradient (Canvas/Colors),
|
||||
color-spectrum (Canvas/Spectrum), random-colors (Canvas/Random), draw-grid
|
||||
(Canvas/Grid), step-colors (Colors/Output), placeholder
|
||||
(Canvas/Colors/Text), text-to-png (Text/Background/Padding);
|
||||
- text: add-text, date-stamp (Text/Placement/Plate);
|
||||
- filters: randomize-pixels (Blocks), add-noise (Noise/Seed). Инструменты с
|
||||
1–3 простыми полями остались без layout (одна общая группа).
|
||||
|
||||
### Фаза 5 — изоляция старого UI
|
||||
|
||||
34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента) ✔.
|
||||
Отдельного `tool-views.ts` в репо нет: preview сразу строился на
|
||||
`registry-new`. Meta инструмента живёт в `ToolEntry` (`title`,
|
||||
`description`, `category`, `run`/`generate`), layout — в `schema.layout`
|
||||
(шаг 32), рендер — `SchemaToolView`/`SchemaFields`/`SchemaPreview`.
|
||||
Дублирующей системы нет.
|
||||
35. Старый UI **не удаляется**, а выносится в `(old)/`-маршруты и остаётся там
|
||||
временно (посмотреть, как работает, сравнить с новым; старые тесты
|
||||
продолжают проходить):
|
||||
- маршруты `(old)/{+page,demo,list-tools,tools/[id]}` — старый дизайн, тянет
|
||||
`old.css` (не `design2.css`), старый header/footer;
|
||||
- старый `registry.ts`/`registry/` + `ParamDef[]` обслуживают только
|
||||
`(old)/`-инструменты — не удаляются, не рефакторятся;
|
||||
- удаление происходит позже, отдельным решением (когда новый UI покроет все
|
||||
инструменты и ревью завершено).
|
||||
36. **Линтер-изоляция веток** (гарантия, что old и preview не смешиваются) ✔.
|
||||
Кастомный ESLint-плагин `web/eslint-plugins/isolation/no-mixed-imports`
|
||||
Резолвит каждый импорт (и `$lib/...`, и относительные `./`/`../`) до
|
||||
реального файла, классифицирует источник и цель по фактическому пути и
|
||||
ругается на old→new и new→old. Конфигурация (`old`/`new` glob-паттерны,
|
||||
`root`, `alias`) вынесена в настройки правила — единая точка правды:
|
||||
- старое: `routes/(old)/**`, `lib/registry.ts`, `lib/registry/**`,
|
||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||
`lib/components/**` (кроме `kit/`);
|
||||
- новое: `routes/preview/**`, `lib/registry-new/**`, `lib/preview/**`,
|
||||
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`,
|
||||
`lib/components/kit/**`;
|
||||
- общее (разрешено обоим): всё прочее — `core/`, `i18n/`, `theme`,
|
||||
`assets/`, корневой `lib` (`index.ts`, тесты). Достигнутая полная изоляция
|
||||
(одиночные пересечения устранены):
|
||||
- старые пилоты `registry/geometry.ts` (`add-border`) и `registry/alpha.ts`
|
||||
(`add-stroke`) получали `schema` из нового `registry-schema` — убрано; оба
|
||||
инструмента работают в старом UI через `params: ParamDef[]`, в preview —
|
||||
через свои schema-версии в `registry-new/`;
|
||||
- `registry.ts` больше не импортирует `ToolSchema` из `registry-schema`;
|
||||
- preview `list-tools` тянул `TOOL_ICONS` из старого `lib/tools/tool-icons`
|
||||
→ создана копия `lib/preview/tool-icons.ts` (правило копий).
|
||||
37. Перенос всех старых компонентов/библиотек в папки `old/` (**следующее**):
|
||||
переезд не трогает плагин — достаточно дописать один glob-паттерн в
|
||||
настройку правила (например `lib/old/**`, `lib/components/old/**`), а сама
|
||||
проверка работает по фактическим путям автоматически.
|
||||
|
||||
### Как ревьюить каждый шаг
|
||||
|
||||
- Diff **только один инструмент** (или фундамент без инструментов).
|
||||
- Старый `params`/`run`/`defaultParams`/`sanitizeParams` — **не изменены**
|
||||
(кроме добаление `schema`, если это пилот).
|
||||
- Старый UI и старые тесты продолжают работать — запустить `pnpm --dir web test`
|
||||
и `pnpm --dir web exec svelte-check`.
|
||||
- Порядок полей в `interface Params` и в `schema` совпадает; дефолты в сeme
|
||||
равны старым `default` из `params`.
|
||||
- Пилот из Фазы 1 проверяется **руками в preview**: применяется рамка,
|
||||
дефолты/валидация/смена значений работают end-to-end.
|
||||
|
||||
## Поток данных (runtime)
|
||||
|
||||
### Старый pipeline (не трогаем)
|
||||
|
||||
Всё как сейчас: старое `defaultParams`/`sanitizeParams`/executor/chain работают
|
||||
на `ParamDef[]` и `Record<string, unknown>`. Никаких изменений.
|
||||
|
||||
### Новый pipeline (строим рядом)
|
||||
|
||||
- **Хранилище значений** — типизировано на схему; у нового UI значения полей
|
||||
собираются в `Params` по `ToolSchema<P>`.
|
||||
- **defaultParams / sanitizeParams (новые)** — выводятся из `ToolSchema<P>`,
|
||||
дефолты лежат в схеме. Старые функции не трогаем, добавляем новые рядом.
|
||||
- **Executor/worker (новые)** — получает `Record<string, unknown>`, собирает
|
||||
`Params` по схеме, вызывает `run`. Остаётся serializable.
|
||||
- **Pipeline/chain (новый)** — шаги хранят значения, типизируются по схеме.
|
||||
|
||||
## Изменения в UI-компонентах
|
||||
|
||||
- **Старый UI** (`ParamForm.svelte`, группа `(old)/`) — **не трогаем.** Работает
|
||||
на `ParamDef[]` как раньше.
|
||||
- **Новый UI** (kit/`SchemaToolView` + `SchemaFields` + `SchemaPreview`, читает
|
||||
`ToolSchema<P>`) — сейчас рендерит поля по схеме (number/slider/color).
|
||||
- `tool-views.ts` отсутствует — preview/lede/layout уже живут в meta инструмента
|
||||
(`ToolEntry` + `schema.layout`), дублирования нет.
|
||||
|
||||
## Оценка трудозатрат
|
||||
|
||||
| Часть | Сложность | Время |
|
||||
| ----------------------------------------------------- | ------------------------ | ---------------------- |
|
||||
| Typed field builders + toolSchema\<P\> | Средняя | ~2ч |
|
||||
| Новые defaultParams/sanitizeParams под схему | Средняя | ~2-3ч |
|
||||
| ToolEntry\<P\> generic + поле schema (рядом с params) | Низкая-Средняя | ~1-2ч |
|
||||
| Новый executor/worker/pipeline под схему | Средняя | ~2-3ч |
|
||||
| Миграция инструментов на interface Params + схемы | Механическая, но крупная | ~8-12ч |
|
||||
| Новый рендер (kit/ParamControl) + составные виджеты | Средняя | ~4-5ч |
|
||||
| Поглощение tool-views.ts | Средняя | ~1-2ч |
|
||||
| Вынос старого UI в (old)/ + адаптация маршрутов | Средняя-Низкая | ~2-3ч |
|
||||
| **Итого** | | **~22-32ч** (поэтапно) |
|
||||
|
||||
> Оценка выросла по сравнению с ранней версией плана, потому что принят путь
|
||||
> «явный interface Params + схема + общий рендер с пер-инструмент layout» — это
|
||||
> полный рефакторинг pipeline, а не только добавление составных типов.
|
||||
>
|
||||
> Старый UI/`ParamDef[]`/старый pipeline в смету **не входят** — они не
|
||||
> рефакторятся, а продолжают работать на `(old)/`-маршрутах до перехода (затем
|
||||
> убираются отдельным решением — см. шаг 35).
|
||||
|
||||
## Порядок реализации (кратко)
|
||||
|
||||
Детальный пошаговый план с атомарными изменениями и порядком ревью — в разделе
|
||||
«Пошаговый план перевода инструментов» выше. Здесь краткая сводка фаз:
|
||||
|
||||
1. **Фаза 0** — фундамент: builders + `toolSchema<P>` + `ToolEntry<P>.schema` +
|
||||
новые default/sanitize. Ни одного инструмента не изменено. ✔
|
||||
2. **Фаза 1** — **один полноценный рабочий инструмент в preview** («добавить
|
||||
рамку»): schema + рендер + end-to-end применение. Ручная оценка
|
||||
работоспособности решения/сложностей.
|
||||
3. **Фаза 2** — простые/одиночные инструменты (проверенный паттерн Фазы 1), 1
|
||||
инструмент = 1 диф.
|
||||
4. **Фаза 3** — инструменты с составными типами (dimension → color-pair → offset
|
||||
→ position9 → font-style → plate → gradient), пилот каждого типа отдельно.
|
||||
5. **Фаза 4** — масштаб UI на остальные: составные виджеты, пер-инструмент
|
||||
layout (для каждого инструмента — UI-макет).
|
||||
6. **Фаза 5** — изоляция: старый UI на `(old)/`-маршруты (не удаляется, остаётся
|
||||
для ревью); preview на `registry-new` полностью; линтер-изоляция веток
|
||||
(плагин `isolation`), затем перенос старых компонентов/библиотек в папки
|
||||
`old/`.
|
||||
|
||||
## Зависимости
|
||||
|
||||
- **UI сперва доделать** — текущая активная работа; типизация params ожидает
|
||||
готовности нового дизайна.
|
||||
- Ветка redesign (`plan-redesign.md`) — параллельная, учесть при миграции
|
||||
kit/`ParamControl.svelte` и `overlay-store`.
|
||||
- Старый pipeline остаётся рабочим и шippable на всём протяжении перехода
|
||||
(аналогично стратегии `plan-redesign.md`).
|
||||
@@ -0,0 +1,139 @@
|
||||
# План: доводка дизайна до верности (фикс по refs)
|
||||
|
||||
> **СТАТУС: ЗАКРЫТ (2026-09-07).** Актуальный дизайн (`preview/*`) ушёл вперёд
|
||||
> от референсов `refs-html/` — сверка с ними больше не информативна (новые
|
||||
> компоненты `SchemaToolView`/`SchemaPreview`/каталог не имеют рефовой копии).
|
||||
> Аудит против рефа прекращается; качество дизайна дальше гоняется ревью руками
|
||||
> и специализированными проверками (`lint:all` —
|
||||
> stylelint/design-tokens/токен-аудит). Порядок удаления старого и перехода — по
|
||||
> `plan-redesign.md` (C17–C21).
|
||||
|
||||
> Статус: к выполнению. Ветка `feat/redesign`. Источники:
|
||||
> `docs/archive/demo-fidelity.md` (архивная копия старого отчёта по верности,
|
||||
> неактуальна), `docs/plan-redesign.md` (общая стратегия, фазы 1–4 выполнены до
|
||||
> C16). Актуальный аудит верности — `pnpm refs-cdp-audit` /
|
||||
> `pnpm refs-css-audit` / `pnpm refs-dom-audit` → `web/audit/audit-report.md`
|
||||
> (текущий список скриптов — в `web/package.json`, см. `refs-*audit`).
|
||||
|
||||
## Контекст
|
||||
|
||||
- Новый дизайн собран параллельно под `preview/*` (см. `plan-redesign.md`),
|
||||
старый сайт `(old)/` не трогаем до C17.
|
||||
- `refs/` и `refs-html/` приведены к консистентному виду — текущая «правда по
|
||||
пикселям». Механизм синхронизации ref→код не создаётся (см. §9 plan-redesign).
|
||||
- Аудит верности автоматизирован: `pnpm refs-cdp-audit` (1440×900,
|
||||
`scripts/audit-cdp.mjs`) пишет `web/audit/audit-report.md` — light/dark
|
||||
токены + поэлементный дифф стилей; пиксель-дифф не используется. Палитры
|
||||
токенов уже сверены с рефом (light совпадает, dark собран). Темы в `refs-html`
|
||||
переключаются — скрипт инжектится в `scripts/extract-static.mjs`.
|
||||
- Исходный отчёт `demo-fidelity.md` перенесён в `docs/archive/` как утративший
|
||||
актуальность; его данные актуализированы ниже (Этап 1).
|
||||
- Зафиксированные решения (подтверждены автором):
|
||||
- **F1 — плоский фон холста**, без blueprint-сетки (как в рефе).
|
||||
- **F4 — preview-stack в одну колонку** (как в рефе).
|
||||
|
||||
## Принципы
|
||||
|
||||
- Никаких правок старого дизайна `(old)/` и старого `ui/`.
|
||||
- Компоненты пишем в `kit/` (переименование в `ui/` — на C19 по plan-redesign).
|
||||
- Коммиты атомарные (< ~500 строк), по 1–2 компонента; после каждого —
|
||||
`svelte-check` + `lint` + `build` + `refs-cdp-audit` (diff% не должен расти
|
||||
без причины).
|
||||
- Коммиты делает автор (агент не коммитит).
|
||||
|
||||
## Этап 0 — Рефакторинг (SRP / дизайн-обёртки), без изменения визуала
|
||||
|
||||
Гигантские страницы (`demo` ~513, `tools/[id]` ~451 строк) дробим на компоненты
|
||||
единой ответственности; убираем дублирующиеся inline-стили.
|
||||
|
||||
Новые обёртки в `src/lib/components/kit/`:
|
||||
|
||||
- `FieldGrid` — сетка полей (заменяет `.controls` / `.controls.compact`).
|
||||
- `ToggleRow` — лейбл + `Toggle` (дублировался в `demo` и `tools/[id]`).
|
||||
- `PreviewStack` — контейнер превью-плиток, **одна колонка** (реализует F4 в
|
||||
одном месте).
|
||||
- `SectionLabel` — label + strong + actions (вместо `.pipeline-head` /
|
||||
`.preview-top`).
|
||||
- `FileChip` — пилюля файла.
|
||||
- `PipelineFooter` — reset + auto-note.
|
||||
- `WorkspaceLayout` — двухпанельная сетка (settings | preview) со sticky +
|
||||
брейкпоинт 800px.
|
||||
|
||||
Декомпозиция страниц:
|
||||
|
||||
- `tools/[id]`: вынести `ParamControl` (диспетчер полей по `ParamDef`),
|
||||
`SettingsPanel` (левая панель), `PreviewPanel` (правая панель) — колоцированно
|
||||
в `routes/preview/tools/[id]/`. Страница сжимается до состояния + глёв.
|
||||
- `demo`: переписать на те же обёртки + `WorkspaceHeader` (eyebrow + h1 + lede +
|
||||
file-chip).
|
||||
|
||||
Побочный баг, устраняемый тут же: в `demo` поля сейчас обёрнуты в
|
||||
`<div class="control-block"><MonoLabel>…</MonoLabel><SliderField label>` —
|
||||
**двойной лейбл**, т.к. `ColorField`/`SliderField`/`SelectField`/`NumberField`
|
||||
уже содержат `Field` (MonoLabel) изнутри. При рефакторе убираем внешний
|
||||
`MonoLabel`, оставляем само поле.
|
||||
|
||||
Критерий Этапа 0: визуал идентичен (audit diff% без регрессии), старые тесты
|
||||
зелёные.
|
||||
|
||||
## Этап 1 — Верность (по refs-html; статус на момент плана)
|
||||
|
||||
Аудит: `pnpm refs-cdp-audit` (1440×900) пишет `web/audit/audit-report.md` —
|
||||
light/dark токены + поэлементный дифф стилей. Пиксель-дифф не используется.
|
||||
Палитры токенов уже сверены с рефом: light совпадает (`--background`, `--panel`,
|
||||
`--foreground`, `--muted`, `--line`, `--blue`), dark собран (16 токенов). Темы в
|
||||
`refs-html` переключаются (скрипт инжектится в `extract-static.mjs`).
|
||||
|
||||
Открытые / решённые пункты (исходник — `docs/archive/demo-fidelity.md`):
|
||||
|
||||
- **B1** (открыт, P1): `button, input, select, textarea` рендерятся Arial —
|
||||
добавить в reset `design2.css` `font-family: inherit`. Влияет на все страницы.
|
||||
- **B2** (решён — проверить): двойного топбара нет. `preview/+layout.svelte`
|
||||
рендерит ровно один `TopBar`; `demo` свой `.topbar` не рендерит. Подтвердить
|
||||
аудитом/визуально при Этапе 1.
|
||||
- **F1** (решено — плоский фон): наш `--background` уже `#EEF1F4` (как реф);
|
||||
видимое отличие было в blueprint-сетке поверх фона. Фикс: убрать
|
||||
`BlueprintGrid` из `AppShell`.
|
||||
- **F2** (сделано C16): тип шага (`StepCard`) — приглушённый mono-текст, не
|
||||
синий бейдж; индекс шага — синий `#1769D2`.
|
||||
- **F3** (сделано C16): подпись превью-тайла (`PreviewTile`) — синяя `#1769D2`.
|
||||
- **F4** (решено — одна колонка): реализуется в `PreviewStack` (Этап 0), колонка
|
||||
= `1fr`.
|
||||
- **F5** (открыт): вес `MonoLabel` 500 → 400 (как в рефе).
|
||||
- **F6** (открыт): мета-подписи (MetaRow/MetaList, DIMENSIONS/FORMAT/SIZE) fs
|
||||
10px → 9px.
|
||||
- **F7** (низкий): копирайт/стиль — у нас `+ Add tool` (реф `Add tool`); кнопка
|
||||
`Download result` — сверить с рефом (синяя, ~478×42). Проверить визуально
|
||||
(замеры NOT FOUND — артефакт вложенного чипа размера).
|
||||
- **F8** (низкий): `h1` line-height чуть выше рефа (150px vs 115px).
|
||||
- **F9** (открыт): вертикальный ритм — подровнять паддинги секций под реф
|
||||
(pipeline-head и пр.).
|
||||
- **F10** (открыт): внутренние отступы preview-панели (паддинг панели).
|
||||
|
||||
После Этапа 1 — повторный `refs-cdp-audit`, зафиксировать остаточный diff
|
||||
(sub-pixel / структурный) и обновить статус пунктов выше прямо в этом файле.
|
||||
|
||||
## Этап 2 — plan-redesign.md (C17–C21), ОТЛОЖЕНО
|
||||
|
||||
Выполняется **только после** того, как новый дизайн доведён до приемлемого
|
||||
уровня верности (Этапы 0–1). Сами пункты — в `plan-redesign.md` §10 Шаг 5–6:
|
||||
|
||||
- **C17**: `preview/*` → реальные маршруты, удалить `(old)/`, корневой
|
||||
`+layout.svelte` ← `design2.css`, поправить `newCode` в `eslint.config.js`
|
||||
(`src/routes/preview/**` → `src/routes/**`).
|
||||
- **C18**: правки импортов/редиректов, проверка билда.
|
||||
- **C19**: удалить старое `ui/` + `app.css`, `kit/` → `ui/` (или оставить),
|
||||
раскрыть ESLint `recommended` на весь код.
|
||||
- **C20**: брейкпоинты 1200/1100/800/480; build без предупреждений; `grep` по
|
||||
старым токенам/классам пуст.
|
||||
- **C21**: опц. удалить `refs/` и `refs-html/` из репо.
|
||||
|
||||
## Порядок коммитов (Этап 0)
|
||||
|
||||
1. `kit/`: `FieldGrid`, `ToggleRow`, `PreviewStack`, `SectionLabel`, `FileChip`,
|
||||
`PipelineFooter`, `WorkspaceLayout` — по 1–2 на коммит.
|
||||
2. `tools/[id]`: `ParamControl` → `SettingsPanel` + `PreviewPanel` → тонкая
|
||||
страница.
|
||||
3. `demo`: переписать на обёртки.
|
||||
|
||||
После каждого коммита: `svelte-check` + `lint` + `build` + `refs-cdp-audit`.
|
||||
@@ -0,0 +1,99 @@
|
||||
# План: вторая волна EASY-инструментов
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Принципы отбора
|
||||
|
||||
- Максимум переиспользования: готовые контракты записей (`run`, `preview`,
|
||||
формат вывода, info-режим) и существующие операции ядра.
|
||||
- Новые механизмы ядра — минимальные, чистые, покрытые тестами до подключения
|
||||
инструментов.
|
||||
- Canvas/DOM-зависимые утилиты (SVG-вход, надписи и водяные знаки, эффекты через
|
||||
фильтры контекста) — вне этого пакета, отдельная волна.
|
||||
- Целевой объём волны: около 37 новых инструментов, каталог вырастает с 11 до
|
||||
~48.
|
||||
|
||||
## 2. Новые механизмы
|
||||
|
||||
- **Генераторы без входного файла**: у записи появляется признак «источник не
|
||||
нужен»; страница такого инструмента не показывает левую панель и зону
|
||||
перетаскивания — только параметры и результат.
|
||||
- **Текстовый ввод и вывод**: часть утилит принимает вставленный текст вместо
|
||||
файла и выдаёт текстовый результат (панель с полем только для чтения, кнопки
|
||||
«копировать» и «скачать как текст»). Распространяется на base64, data-uri,
|
||||
hex.
|
||||
- **BMP-энкодер в ядре**: 24-битный, чистая функция из пикселей в байты, с
|
||||
тестами на маленьких матрицах (заголовок, порядок каналов BGR, паддинг
|
||||
строки).
|
||||
|
||||
## 3. Состав пакета
|
||||
|
||||
**Конвертация — 6** `jpg-to-png`, `webp-to-png`, `gif-to-png` (первый кадр),
|
||||
`bmp-to-png`, `ico-to-png` — всё через уже умеющий декодировать ввод, сохранение
|
||||
в PNG; плюс `png-to-bmp` на новом энкодере.
|
||||
|
||||
**Текстовые представления — 6** `png-to-base64`, `base64-to-png`,
|
||||
`png-to-data-uri`, `data-uri-to-png`, `png-to-hex`, `hex-to-png`.
|
||||
|
||||
**Цвет — 9** `change-opacity` (множитель прозрачности), `sepia`, `change-hue`
|
||||
(сдвиг тона), `extract-channel` (выбор R/G/B в оттенках серого), `swap-channels`
|
||||
(перестановка парой), `threshold-black-white` (порог яркости), `posterize`
|
||||
(число уровней), `two-colors` (два цвета + порог), `invert-alpha`.
|
||||
|
||||
**Прозрачность и холст — 7** `remove-alpha-channel` (подложка белым),
|
||||
`set-alpha-channel` (константная прозрачность), `extract-alpha-mask` (альфа в
|
||||
чёрно-белую маску), `add-padding` (отступ + цвет/прозрачность), `add-border`
|
||||
(толщина + цвет), `round-corners` (радиус), `fit-on-background` (вписать на
|
||||
полотно цвета с полями).
|
||||
|
||||
**Композиция — 2** `tile` (повтор по сетке), `center-by-alpha` (центрирование по
|
||||
непрозрачному содержимому).
|
||||
|
||||
**Генераторы — 4** `create-empty` (размер + цвет или прозрачность),
|
||||
`single-color`, `random-noise` (с фиксированным зерном для воспроизводимости),
|
||||
`linear-gradient` (два цвета, направление).
|
||||
|
||||
**Анализ — 3** `is-grayscale`, `is-transparent`, `orientation`
|
||||
(портрет/ландшафт/квадрат) — текстовые сводки в info-режиме.
|
||||
|
||||
## 4. Новые операции ядра (все — чистые функции с тестами)
|
||||
|
||||
- Холст: расширение полотна (общая база для padding/border/fit), тайлинг, центр
|
||||
по bounding-box альфы, скругление углов маской радиуса.
|
||||
- Цвет: сепия, сдвиг тона через HSL, множитель альфы, извлечение/перестановка
|
||||
каналов, пороги (яркость и двухцветный), постеризация уровней, инверсия альфы.
|
||||
- Генерация: заливка цветом, шум с зерном, линейный градиент.
|
||||
- Форматы: BMP-энкодер; кодеки текстовых представлений (base64/data-uri/hex в
|
||||
обе стороны).
|
||||
- Анализ: проверка монохромности, полной непрозрачности, ориентации.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Механизмы** — генераторный режим страницы, текстовый ввод/вывод,
|
||||
BMP-энкодер. Проверка: `test/check/build`.
|
||||
- **B. Конвертация и текстовые представления** — 12 инструментов. Проверка:
|
||||
ручной прогон каждой конвертации туда-обратно.
|
||||
- **C. Цвет** — 9 инструментов. Проверка: юнит-тесты операций + визуально.
|
||||
- **D. Прозрачность, холст, композиция** — 9 инструментов. Проверка:
|
||||
юнит-тесты + визуально.
|
||||
- **E. Генераторы и анализ** — 7 инструментов. Проверка: детерминизм шума (одно
|
||||
зерно — одно изображение), своды анализа.
|
||||
- **F. Итог** — полный прогон, смоук всех новых инструментов «настроить →
|
||||
скачать», регресс старых одиннадцати.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Каталог содержит ~48 рабочих инструментов, каждый доступен страницей с формы
|
||||
из реестра.
|
||||
- Все новые операции ядра покрыты тестами; обратимые конвертации проверены
|
||||
круговым прогоном (например PNG → base64 → PNG даёт те же пиксели).
|
||||
- Генераторы работают без загрузки файла; текстовые представления копируются и
|
||||
скачиваются.
|
||||
- `test/check/build` зелёные.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
- SVG-вход, рисование текста и водяных знаков, эффекты через фильтры контекста,
|
||||
GIF/APNG-энкодинг, пакетная обработка — последующие волны.
|
||||
- Семейства logo/icon/signature/stamp как отдельные страницы — они будут
|
||||
конфигурациями тех же операций после этой волны.
|
||||
@@ -0,0 +1,87 @@
|
||||
# План: третья волна EASY — трансформации, свет, SVG
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 24.08.2026.**
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Одиннадцать новых инструментов: свободные трансформации формы (наклон, поворот
|
||||
на произвольный угол, зум, сдвиг), светокоррекция (гамма, автоконтраст,
|
||||
температура, тонирование), виньетка, эффект «артефактов сжатия» и вход SVG.
|
||||
Каталог вырастает с 58 до ~69.
|
||||
|
||||
Текстовые инструменты и водяные знаки в волну не входят — им нужен рендер текста
|
||||
на канвасе, это отдельная четвёртая волна.
|
||||
|
||||
## 2. Новое ядро
|
||||
|
||||
- **Аффинные трансформации** (`core/affine.ts`): единая функция обратного
|
||||
отображения — для каждого пикселя результата вычисляется исходная координата
|
||||
матрицей и семплируется билинейно (общий семплер, вынесенный из `resize`).
|
||||
Поверх неё: наклон по осям, поворот на произвольный угол (с расширением холста
|
||||
под новые габариты), зум к центру, сдвиг.
|
||||
- **Свет и цвет** (`core/color.ts` дополнения): гамма-коррекция через LUT,
|
||||
автоконтраст (растяжка по гистограмме яркости с отсечкой хвостов), температура
|
||||
(множители тёплых/холодных каналов), тонирование (умножение RGB на цвет с
|
||||
сохранением яркостного баланса).
|
||||
- **Виньетка** (`core/effects.ts`): радиальное затемнение от центра, сила
|
||||
слайдером; чистая математика без canvas.
|
||||
- **IO-расширения** (`core/io.ts`): декодирование SVG из текста/файла через
|
||||
blob-URL и `Image`; раундтрип «закодировать JPEG с качеством Q → декодировать
|
||||
обратно» для эффекта артефактов сжатия.
|
||||
|
||||
## 3. Инструменты — 11
|
||||
|
||||
- **Геометрия:** `skew-png` (углы X/Y), `rotate-free-png` (любой угол, холст
|
||||
расширяется), `zoom-png` (масштаб ≥100 % к центру), `shift-png` (сдвиг со
|
||||
смещением содержимого).
|
||||
- **Фильтры:** `vignette-png` (сила затемнения), `jpeg-artifacts-png` (имитация
|
||||
пережатия).
|
||||
- **Цвет:** `gamma-png` (0.1–3.0), `auto-contrast-png` (без параметров),
|
||||
`temperature-png` (−100 тёплый…+100 холодный или наоборот — подпись честная),
|
||||
`tint-png` (цвет + сила).
|
||||
- **Конвертация:** `svg-to-png` (текст SVG слева, как base64-режим).
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — семплер, аффинный модуль, свет/цвет, эффекты + точные тесты:
|
||||
поворот квадрата на 90° совпадает с существующим `rotate90`, зум ×2 удваивает
|
||||
размеры и центр остаётся центром, гамма 1 — идентичность, автоконтраст
|
||||
растягивает известный диапазон, виньетка симметрична и не трогает центр при
|
||||
нулевой силе. Проверка: `pnpm test/check`.
|
||||
- **B. IO** — SVG-декод и JPEG-раундтрип. Проверка: сборка; живой прогон на
|
||||
этапе D.
|
||||
- **C. Реестр** — одиннадцать записей, иконки, тесты целостности. Проверка:
|
||||
`test/build`, страницы в экспорте.
|
||||
- **D. Итог** — полный прогон, смоук §7.
|
||||
|
||||
## 5. Смоук-чеклист
|
||||
|
||||
1. Наклон X/Y по отдельности и вместе — форма искажается предсказуемо, без
|
||||
чёрных полос при малых углах.
|
||||
2. Поворот на 30° — холст вырос, углы прозрачны, содержимое повернуто.
|
||||
3. Зум 200 % — содержимое увеличено к центру, холст прежнего размера, края
|
||||
обрезаны.
|
||||
4. Сдвиг уводит содержимое за край, противоположный край заполняется фоном.
|
||||
5. Гамма 0.5 темнее, 2.0 светлее, 1.0 — без изменений.
|
||||
6. Автоконтраст на тусклом фото вытягивает диапазон.
|
||||
7. Температура ±50 заметно теплеет/холодеет.
|
||||
8. Тонирование красным даёт равномерный оттенок, белые области окрашиваются
|
||||
сильнее всего.
|
||||
9. Виньетка затемняет только края, центр не трогает.
|
||||
10. Артефакты: качество 10 даёт видимые квадраты JPEG.
|
||||
11. SVG вставленный слева декодируется в результат справа; битый SVG — баннер.
|
||||
12. Все новые инструменты работают в цепочке и скачиваются из своих секций.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Одиннадцать инструментов работают и покрыты юнит-тестами ядра (кроме
|
||||
IO-дом-части).
|
||||
- Аффинные операции опираются на общий билинейный семплер; дублирования
|
||||
сэмплинга нет.
|
||||
- Каталог ~69 инструментов; `test/check/build` зелёные, смоук §5 пройден.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
- Рендер текста, водяные знаки, штампы — четвёртая волна (DOM-текст).
|
||||
- Субпиксельная точность аффинных трансформаций сверх билинейного семплинга.
|
||||
- Пакетное применение и пресеты.
|
||||
@@ -0,0 +1,97 @@
|
||||
# План: закрытие EASY/MEDIUM-пробелов из сравнения с onlinepngtools
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН (2026-09-07) — устарел.** Все волны W1–W9 закрыты. Идеи из
|
||||
> раздела «Вне очереди» (region-инструменты, мультифайловый вывод, анимационные,
|
||||
> HARD-серия) перенесены в `backlog.md`.
|
||||
|
||||
> Статус: черновик на ревью
|
||||
|
||||
## Принципы
|
||||
|
||||
- Волны собираются вокруг общего математического ядра: одна волна = один
|
||||
движок + N инструментов на нём.
|
||||
- Каждая волна оформляется отдельным планом при старте; здесь — очередь и
|
||||
состав.
|
||||
- Порядок: сначала кластеры, дающие много инструментов малой кровью, затем
|
||||
одиночные эффекты, MEDIUM-пакеты в конце.
|
||||
- Полный список параметров каждого инструмента живёт в карте инструментов
|
||||
(tools-map.md); здесь только состав и ядро.
|
||||
|
||||
## Очередь волн
|
||||
|
||||
### W1. Палитры — ВЫПОЛНЕНА (11 инструментов)
|
||||
|
||||
Ядро: RGB↔HSL + гармонии цветового круга. Вывод: свотч-полотно (генератор).
|
||||
Состав: color-wheel, complementary, monochromatic, analogous, triadic, tetradic,
|
||||
similar-shades, sort-colors. Плюс утилиты смешения тем же ядром: mix-colors,
|
||||
average-color, blend-two, step-between (+4, итого 12).
|
||||
|
||||
### W2. Каналы и пространства — ВЫПОЛНЕНА (6 инструментов)
|
||||
|
||||
Ядро: матрицы преобразования RGB→(HSL/HSV/HSI/CMYK/YCbCr/LAB) + визуализация
|
||||
выбранного компонента серым или окрашенно. Общий select «компонент» + select
|
||||
«режим отображения».
|
||||
|
||||
### W3. Маски по свойствам пикселей — ВЫПОЛНЕНА (7 инструментов)
|
||||
|
||||
Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом).
|
||||
Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask,
|
||||
unique-color-mask, extract-by-color.
|
||||
|
||||
### W4. Фигурные маски — ВЫПОЛНЕНА (4 инструмента)
|
||||
|
||||
Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами.
|
||||
Состав: circle-mask, square-mask, star-mask, wavy-mask.
|
||||
|
||||
### W5. Геометрия-добивки — ВЫПОЛНЕНА (5 инструментов)
|
||||
|
||||
Ядро: bbox по альфе (trim) переиспользуется тремя инструментами источника.
|
||||
Состав: trim-empty-space (закрывает remove border/space), change-canvas-size,
|
||||
change-aspect-ratio, landscape↔portrait, symmetric-copy.
|
||||
|
||||
### W6. Эффекты лёгкие — ВЫПОЛНЕНА (6 инструментов; color-blocks покрыт pixelate)
|
||||
|
||||
Состав: pixelate, color-blocks, randomize-pixels (seed), add-noise,
|
||||
feather-edges, clean-edges, silhouette. Shadow/glow — сюда же, если потянет
|
||||
этап: оба = размытая альфа + смещение + цвет (ядро blur уже есть).
|
||||
|
||||
### W7. Конвертеры и генераторы — ВЫПОЛНЕНА (11 инструментов)
|
||||
|
||||
PNG↔bytes, PNG↔rgb-values (текстовые результаты), verify-is-png (анализ
|
||||
сигнатуры), text-to-png (domText без входной картинки), emoji-to-png,
|
||||
placeholder-png, color-spectrum, colorful-random (seed), draw-grid.
|
||||
multi-color-gradient — если успеем новый тип параметра «список цветов», иначе
|
||||
перенос.
|
||||
|
||||
### W8. Цветовые MEDIUM — ВЫПОЛНЕНА (4 инструмента; median-cut + Floyd–Steinberg/Bayer, custom-palette через text-параметр)
|
||||
|
||||
Ядро: квантование (median-cut или k-means). Состав: quantize (k),
|
||||
decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список
|
||||
цветов — нужен тип параметра «список»), dithering (Floyd–Steinberg/Bayer поверх
|
||||
квантования).
|
||||
|
||||
### W9. Сжатие — ВЫПОЛНЕНА (3 инструмента; optimize/change-quality осознанно не выделены — покрыты пресетами compress и W8)
|
||||
|
||||
Без wasm честное управление размером PNG ограничено: реальный рычаг —
|
||||
квантование (W8) + обрезка метаданных + итеративный подбор под целевой KB.
|
||||
Состав: compress (пресеты усилий), reduce-to-size (целевой KB, бинарный поиск по
|
||||
k), optimize (re-encode), change-quality/low-quality — переосмыслить как пресеты
|
||||
W8; jpeg-artifacts уже покрывает «испортить».
|
||||
|
||||
## Вне очереди
|
||||
|
||||
- Region-инструменты
|
||||
(censor/erase/pixelate-area/blur-area/sharpen-area/reverse-area) — ждут UI
|
||||
выделения области на превью; отдельное решение.
|
||||
- Мультифайловый вывод (split-parts, gif-frames, separate-colors) — ждут
|
||||
механизм «результат = набор файлов».
|
||||
- Анимационные (slow-reveal/fade/scrolling) — выход не PNG; отдельное решение о
|
||||
формате.
|
||||
- HARD: glitch, barcode, signature-extract, handwritten-digital.
|
||||
- Нишевые серии logo/icon/stamp/signature — не копируем.
|
||||
|
||||
## Оценка масштаба
|
||||
|
||||
W1–W7 дают **~47 новых инструментов**, почти все EASY. W8 добавляет ядро
|
||||
квантования (открывает custom-palette/dithering и улучшает W9). Суммарно каталог
|
||||
достигает ~120+ при текущих 71.
|
||||
@@ -0,0 +1,83 @@
|
||||
# План: герой-поиск v2 — иконки, популярность, карточки
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Выдача поиска на главной превращается из списка строк в сетку карточек: слева
|
||||
иконка инструмента, справа название и описание. Инструменты получают два новых
|
||||
поля реестра — `popularity` (приоритет в выдаче) и `icon` (идентификатор
|
||||
иконки). Иконки переиспользуются в каталоге `/list-tools` и в легендах шагов
|
||||
цепочки. Поиск работает по-прежнему: нечёткий скоринг, клавиатура ↑↓ Enter Esc.
|
||||
|
||||
## 2. Данные реестра
|
||||
|
||||
- **`popularity?: number`** (0–100, по умолчанию 50) — приоритет внутри равного
|
||||
скоринга запроса. Значения проставляются вручную осознанно: базовые операции
|
||||
(обрезка, размер, удаление фона, конвертации JPG/PNG) — 80–95; цветовые и
|
||||
геометрия — 40–70; узкие и генераторы — 10–30.
|
||||
- **`icon?: string`** — имя иконки из набора Lucide. Явный словарь-маппинг для
|
||||
всех инструментов в одном месте; отсутствие записи даёт нейтральную заглушку
|
||||
(Wrench).
|
||||
- Тест целостности расширяется: популярность в диапазоне, иконка — непустая
|
||||
строка из разрешённого набора.
|
||||
|
||||
## 3. Иконки
|
||||
|
||||
Зависимость `lucide-svelte` (MIT) — один пакет, иконки рендерятся компонентами,
|
||||
дерево не раздувается за счёт выборочного импорта.
|
||||
|
||||
Маппинг ведётся одним объектом `tool-icons.ts` рядом с реестром: 51 запись вида
|
||||
`'crop-png': Crop`. Подбор по смыслу: обрезка — Crop, размер — Scaling, поворот
|
||||
— RotateCw, отражение — FlipHorizontal2, оттенок — Palette, размытие — Droplets,
|
||||
резкость — Focus, удаление фона — Scissors, анализ — Info и так далее; для
|
||||
семейств конвертаций — FileInput/FileOutput варианты.
|
||||
|
||||
Иконка появляется в трёх местах: карточка выдачи, карточка каталога
|
||||
`/list-tools`, легенда шага цепочки («Шаг N: [icon] Название»).
|
||||
|
||||
## 4. Карточки выдачи
|
||||
|
||||
- Сетка: одна колонка на узком экране, две — начиная со среднего героя.
|
||||
- Композиция карточки: квадрат иконки слева, справа название (полужирное) и
|
||||
однострочное описание с обрезкой.
|
||||
- Состояния hover и активной с клавиатуры — тот же акцент подсветки, что сейчас.
|
||||
- Сортировка выдачи: скоринг запроса ↓, затем популярность ↓, затем название.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Реестр** — поля `popularity` и `icon`, словарь иконок на все 51,
|
||||
расширение тестов целостности. Проверка: `pnpm test/check`.
|
||||
- **B. Карточки поиска** — сетка выдачи с иконками и учётом популярности,
|
||||
клавиатура без изменений. Проверка: визуально, `check/build`.
|
||||
- **C. Распространение** — иконки в каталоге `/list-tools` и легендах звеньев.
|
||||
Проверка: визуально, `build`.
|
||||
- **D. Итог** — полный прогон и смоук §8.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Пустой запрос на герое показывает популярные первыми после первого символа.
|
||||
2. Запрос «обрез» поднимает карточку обрезки выше прочих совпадений.
|
||||
3. У каждой карточки есть иконка; у большинства — разные, соответствующие
|
||||
смыслу.
|
||||
4. Наведение и стрелки подсвечивают карточку; Enter открывает инструмент.
|
||||
5. Открытый инструмент работает как раньше: параметры в колонках, результат,
|
||||
скачивание.
|
||||
6. Цепочка: легенда каждого шага показывает иконку выбранного инструмента.
|
||||
7. `/list-tools`: карточки категорий с иконками, ссылки работают.
|
||||
8. Регресс: старые сценарии (загрузка файла, цепочка, скачивание) не изменились.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Все инструменты имеют иконку и ненулевую популярность; тесты это гарантируют.
|
||||
- Выдача поиска — сетка карточек с корректной сортировкой и живой клавиатурой.
|
||||
- Иконки присутствуют в поиске, каталоге и легендах шагов.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Автоматический расчёт популярности из статистики использования — значения
|
||||
ручные.
|
||||
- Собственный набор SVG-иконок — берём готовый открытый.
|
||||
- Переводы названий и поиск на других языках — отдельная i18n-волна.
|
||||
- Изображения-превью в карточках поиска — только иконка и текст.
|
||||
@@ -0,0 +1,111 @@
|
||||
# План: i18n — русский и английский
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский
|
||||
> — перевод в словаре; строки инструментов в реестре переведены на английский в
|
||||
> рамках этапа C.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Сайт работает на двух языках: русский (базовый) и английский. Переключатель в
|
||||
шапке, выбор сохраняется в localStorage, тег `html lang` обновляется на клиенте.
|
||||
Переведено всё: каркас интерфейса, карточки инструментов, цепочки, поиск, тексты
|
||||
ошибок. Поиск находит инструменты по запросу на любом из двух языков независимо
|
||||
от активной локали.
|
||||
|
||||
Каталог и поведение не меняются: ни новых инструментов, ни изменений логики
|
||||
существующих.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на
|
||||
клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц
|
||||
для поисковиков — не в этой волне.
|
||||
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами
|
||||
(например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в
|
||||
слое исполнителя/UI.
|
||||
- Английский — базовая локаль и фолбэк: строки инструментов в реестре записаны
|
||||
по-английски. Переводы живут в секции tools словарей по ключу id инструмента
|
||||
(title, description, подписи параметров, подписи опций select,
|
||||
тексты-результаты анализаторов); сейчас перевод — русский в ru.ts. Доступ из
|
||||
компонентов только через хелперы
|
||||
toolTitle/toolDescription/paramLabel/optionLabel. Так каждая строка имеет
|
||||
единственный источник: en — реестр, переводы — словари.
|
||||
|
||||
## 3. Структура i18n-модуля
|
||||
|
||||
- web/src/lib/i18n/locale.svelte.ts — состояние активной локали (runes), персист
|
||||
в localStorage (ключ locale, guard на SSR), обновление
|
||||
document.documentElement.lang.
|
||||
- web/src/lib/i18n/t.ts — функция перевода с фолбэком на базовый язык и
|
||||
интерполяцией переменных вида {n}.
|
||||
- web/src/lib/i18n/ru.ts и en.ts — словари одного типа Dict; тип экспортируется
|
||||
из ru.ts, en.ts обязан ему соответствовать — пропущенные ключи ловятся на
|
||||
компиляции.
|
||||
- Секции словаря: header, home, catalog, toolPage, chain, sourceCard,
|
||||
resultCard, paramsCard, textInput, textResult, download, infoPanel, dropZone,
|
||||
search, ui (aria кнопок слайдера и пипетки), categories, errors, tools.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и
|
||||
интерполяцией, словари-скелеты со всеми секциями (заполнены header и
|
||||
categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты:
|
||||
фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
||||
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в
|
||||
словари: layout (шапка, футер), главная (герой, «вернуть последний», title),
|
||||
каталог, карточки источника/результата/параметров, текстовый вход и результат,
|
||||
«Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText),
|
||||
InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU,
|
||||
ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой
|
||||
результат поиска, страницы инструмента (title, 404). Обёрточные сообщения
|
||||
исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
||||
- C. Контент реестра — переводы 69 инструментов
|
||||
(title/description/labels/options) в tools-секции словаря en; компоненты
|
||||
получают строки через хелперы toolTitle/toolDescription/paramLabel/optionLabel
|
||||
с фолбэком на реестр. Тест целостности: у каждого инструмента есть непустые
|
||||
перевод title/description, полный набор param-ключей и подписи всех опций
|
||||
select; лишние ключи словарей отсутствуют.
|
||||
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е,
|
||||
снятие диакритики через NFD; скоринг против активной и базовой локали сразу,
|
||||
лучший балл побеждает; сортировка совпадений через Intl.Collator активной
|
||||
локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской
|
||||
локали и наоборот.
|
||||
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются
|
||||
на коды; исполнитель переводит код в текст активной локали, неизвестные
|
||||
сообщения показываются как есть. Пять тестов, матчащих русские подстроки
|
||||
ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это
|
||||
упрощает ассерты. Русские describe/it в тестах не трогаем.
|
||||
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
|
||||
|
||||
## 5. Смоук-чеклист
|
||||
|
||||
1. Переключение RU/EN в шапке мгновенно меняет язык без перезагрузки; после
|
||||
перезагрузки выбор сохранён.
|
||||
2. Тег html lang соответствует активной локали.
|
||||
3. Главная, каталог, страница инструмента, цепочка из трёх звеньев — везде
|
||||
переведено, без смеси языков.
|
||||
4. Форма параметров: подписи полей и опции select переведены у произвольных пяти
|
||||
инструментов из разных категорий.
|
||||
5. Битый вход (некорректный HEX в тонировании, нулевой размер) показывает ошибку
|
||||
на активном языке.
|
||||
6. Поиск: «повер» находит повороты при русской локали; «rotate» находит их же;
|
||||
при английской локали наоборот. Диакритика и ё не мешают совпадению.
|
||||
7. Числа в панели информации отформатированы по локали.
|
||||
8. Скачивание, пипетка, маски, слайдеры работают одинаково на обеих локалях —
|
||||
функциональность не задета.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Оба словаря типобезопасны и полны; тест целостности секции tools зелёный.
|
||||
- Ни одного пользовательского русского строки вне словарей (кроме тестовых
|
||||
describe/it).
|
||||
- test/check/build зелёные, смоук пройден.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
- Сегменты языка в URL, prerender обеих локалей, hreflang/canonical.
|
||||
- Транслитерация поисковых запросов ru-en.
|
||||
- Третьи и дальнейшие языки (структура позволяет, добавление — отдельная волна).
|
||||
- Перевод описаний коммитов, README и документации docs/.
|
||||
@@ -0,0 +1,83 @@
|
||||
# План: параметры между исходником и результатом
|
||||
|
||||
> **СТАТУС: УСТАРЕЛ (2026-09-07).** Раскладка описывалась для старого
|
||||
> `ToolPage`; перекрыта редизайном (`plan-redesign.md`) — страницы инструментов
|
||||
> теперь `SchemaToolView`/`SchemaPreview` в новом дизайне. Сама идея «инлайн:
|
||||
> исходник → параметры → результат» живёт в `backlog.md` (№2).
|
||||
|
||||
> Статус: план к выполнению.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Альтернативная раскладка этапа инструмента: три колонки «Исходник | Параметры |
|
||||
Результат» на широких экранах, вертикальный стек на узких. Цель — отдать вариант
|
||||
дизайнеру как отправную точку. Откат к текущей раскладке — замена одной строки в
|
||||
`ToolPage`.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Два взаимозаменяемых компонента этапа с одинаковым набором пропсов; `ToolPage`
|
||||
рендерит один тег. Попробовать новое — поменять компонент в этом теге; вернуть
|
||||
старое — поменять обратно. Никаких флагов и конфигов.
|
||||
- Общая типизация пропсов в одном файле рядом с компонентами; сами карточки
|
||||
(`SourceCard`, `ResultCard`, `TextInputCard`, `ParamsCard`) переиспользуются
|
||||
как есть — вся логика исполнения, маски, пипетки и автозапуска остаётся в
|
||||
`ToolPage` и приходит сверху.
|
||||
- Chain-звенья используют ту же панельную систему «Вход | Параметры | Результат»
|
||||
— общие классы `.pane` / `.pane-legend` / `.pane-params` вынесены в app.css,
|
||||
вертикальные поля в узкой колонке заданы один раз глобально.
|
||||
- Один порог отзывчивости: три колонки от ~75rem, ниже — стек «исходник →
|
||||
параметры → результат». Промежуточные перестроения не придумываем до фидбека
|
||||
дизайнера.
|
||||
|
||||
## 3. Компоненты
|
||||
|
||||
```txt
|
||||
src/lib/components/stage/
|
||||
stage-props.ts # интерфейс StageProps + сборщик дефолтов
|
||||
ToolStageClassic.svelte # текущая разметка .tool-stage, перенесена 1:1 из ToolPage
|
||||
ToolStageInline.svelte # новая сетка [source | params | result]
|
||||
```
|
||||
|
||||
- `ToolStageClassic` — вырезка существующего шаблона со стилями; после
|
||||
извлечения страница должна быть пиксель-в-пиксель прежней.
|
||||
- `ToolStageInline` — grid-колонки
|
||||
`minmax(0, 1fr) | фикс-ширина параметров | minmax(0, 1fr)`; легенда
|
||||
«Параметры» над средней колонкой в том же стиле edge-legend.
|
||||
- Модификаторы состояний через классы на корне компонента: `no-params`
|
||||
(инструменты без параметров → две колонки), `no-source` (генераторы →
|
||||
параметры и результат), текстовый вход до декодирования занимает левую ячейку
|
||||
как сейчас.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Извлечение классики** — перенос разметки и стилей этапа из `ToolPage` в
|
||||
`ToolStageClassic` без изменений поведения; пропсы собираются в одном объекте.
|
||||
Критерий: страница неотличима, diff по логике `ToolPage` нулевой.
|
||||
- **B. Инлайн-раскладка** — `ToolStageInline`, переключение в `ToolPage` на
|
||||
новый компонент, прогон смоука §5. Ревью в браузере; решение дизайнера —
|
||||
потом, код уже позволяет жить в обоих вариантах.
|
||||
|
||||
## 5. Смоук
|
||||
|
||||
1. Замена компонента этапа — одна строка; обе версии собираются без
|
||||
предупреждений.
|
||||
2. Инструмент без параметров (например Автоконтраст): две колонки, пустой
|
||||
середины нет.
|
||||
3. Генератор без исходника: параметры и результат в две колонки.
|
||||
4. Текстовый инструмент до декодирования: вход слева, параметры в центре, пустое
|
||||
состояние результата справа.
|
||||
5. Маска, пипетка, слайдеры в средней колонке; изменение параметра перезапускает
|
||||
инструмент как раньше.
|
||||
6. Узкий экран: стек без горизонтального скролла, порядок исходник → параметры →
|
||||
результат.
|
||||
7. Легенды присутствуют у всех трёх зон в обеих раскладках.
|
||||
|
||||
## 6. Что сознательно не делаем
|
||||
|
||||
- Редизайн визуального языка (цвета, типографика, формы) — материал для
|
||||
дизайнера, не для этого плана.
|
||||
- Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает
|
||||
про исполнение.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Plan: `/preview/list-tools` → design2 (catalog)
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН (2026-09-07) — устарел.** Каталог `/preview/list-tools`
|
||||
> переписан на `CatalogHeader` / `CatalogToolbar` / `CatalogGroup` / `ToolCard`
|
||||
> (kit), поиск и фильтры функциональны. Доводка верности против рефа остаётся в
|
||||
> рамках `plan-design-fix.md`.
|
||||
|
||||
Ветка `feat/redesign`. Цель — привести каталог инструментов к рефу
|
||||
`refs-html/list-tools.html`. Сейчас страница вообще не на design2: по
|
||||
мульти-аудиту `onlyOurs=257, onlyRef=54` (демо-страница — `18/11`).
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
- `src/routes/preview/list-tools/+page.svelte` использует собственную вёрстку
|
||||
(`.catalog` / `.cat-head` / `.cat-grid`) + `Panel` / `PanelHeading` /
|
||||
`ToolCard` (kit). Нет поиска, фильтров, счётчика, навигация по категориям
|
||||
сделана через `Panel`-блоки, а не через `.catalog-groups`.
|
||||
- Реф (`list-tools.html`) строит:
|
||||
- `.catalog-page` → `.catalog-head` (`.eyebrow`, `h1` «Tool catalog», `p`
|
||||
lede, `.catalog-total` «NN TOOLS AVAILABLE» с большим числом),
|
||||
- `.catalog-toolbar` (`.catalog-search` + `.catalog-filters` с кнопками),
|
||||
- `.catalog-groups` (grid `repeat(2,minmax(0,1fr))`, gap `56px 28px`) →
|
||||
`.catalog-group` (`.group-title` «CAT · NN TOOLS») → `.tool-cards` →
|
||||
`.tool-card`.
|
||||
- `.tool-card` (реф) — `display:grid`, колонки `42px minmax(0,1fr) 24px 18px`,
|
||||
`min-height:106px`, `gap:14px`, `padding:16px`: `.tool-icon` (42×42 синий
|
||||
размытый квадрат), `.tool-copy` (`strong` 13px mono + `span` 12px mono muted),
|
||||
`.tool-index` (10px mono, сверху), `.tool-arrow` (появляется при hover).
|
||||
- Токены `design2.css` уже совпадают с рефом (`--background #eef1f4`,
|
||||
`--panel #f8fafb`, `--foreground #17212b`, `--muted #6d7883`,
|
||||
`--line #cbd3da`, `--blue #1769d2`, `--radius 4px`) — править токены не нужно.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. **Restyle `kit/ToolCard.svelte`** под реф (меняется «на месте» — используется
|
||||
ещё в `preview/+page` и `preview/kit`, стиль унифицируется, ок):
|
||||
- grid-колонки `42px minmax(0,1fr) 24px 18px`, `align-items:center`,
|
||||
`gap:14px`, `min-height:106px`, `padding:16px`, `border:1px solid line`,
|
||||
`background:panel`, `border-radius:radius`.
|
||||
- `.tool-icon`: 42×42 `place-items:center`,
|
||||
`background:color-mix(in srgb, var(--blue) 12%, transparent)`,
|
||||
`color:var(--blue)` (реф использует `--panel-strong`; вместо нового токена
|
||||
берём этот `color-mix`).
|
||||
- `.tool-copy strong`: `600 13px var(--font-mono)`; `span`: `12px/1.5 mono`
|
||||
`muted`.
|
||||
- `.tool-index`: `10px mono muted`, `align-self:start`.
|
||||
- `.tool-arrow`: `color:var(--blue)`, `opacity:0` → `1` +
|
||||
`translate(2px,-2px)` на `.tool-card:hover`.
|
||||
- DOM-порядок как в рефе: `icon, copy, index, arrow`.
|
||||
- `href` через `resolve` из `$app/paths` (убрать `svelte-ignore`).
|
||||
|
||||
2. **`kit/CatalogHeader.svelte`**: `.eyebrow` «EASY-PNG-TOOLS / CATALOG», `h1`
|
||||
«Tool catalog», lede; `.catalog-total` с динамическим `TOOLS.length` (реф
|
||||
показывает `32`).
|
||||
|
||||
3. **`kit/CatalogToolbar.svelte`**: `.catalog-search` (иконка + `<input>`) и
|
||||
`.catalog-filters` (кнопки по категориям). Состояние `query` / `category`
|
||||
поднимается наружу через `bind:` (или callback-пропы), чтобы страница
|
||||
фильтровала список. Фильтры функциональные (клиентский фильтр по названию +
|
||||
категории), не декоративные.
|
||||
|
||||
4. **`kit/CatalogGroup.svelte`**: `.group-title` (uppercase label категории +
|
||||
«NN TOOLS») + слот со списком карточек; сетка `.tool-cards` — `gap:10px`
|
||||
(реф), на мобиле (`@media max-width:800px`) всё схлопывается в 1 колонку.
|
||||
|
||||
5. **Переписать `preview/list-tools/+page.svelte`** как тонкую композицию:
|
||||
- обёртка `.catalog-page` (`padding:60px clamp(24px,4vw,72px) 72px`);
|
||||
- `<CatalogHeader />`;
|
||||
- `<CatalogToolbar bind:query bind:category />`;
|
||||
- `.catalog-groups` из отфильтрованных групп (по `CATEGORIES`, пересчитав
|
||||
`tools` с учётом `query`/`category`);
|
||||
- внутри `<CatalogGroup>` — `<ToolCard>` на каждый tool
|
||||
(`href= "/preview/tools/{id}"`, `icon` из `tool-icons`, `index` по порядку,
|
||||
`title`/`description` из registry);
|
||||
- убрать `Panel`/`PanelHeading` из этого маршрута.
|
||||
|
||||
6. **Категории/локализация**: labels привести к рефу (реф: ALL / CONVERT /
|
||||
TRANSPARENCY / COLOR / GEOMETRY / FILTERS / ANALYZE). Наши `CATEGORIES`
|
||||
(`convert/alpha/color/geometry/filters/text/analyze/generate`) — сопоставить
|
||||
(`alpha`→TRANSPARENCY и т.п.); точное сопоставление уточнить по рефу и
|
||||
`i18n/ru.ts`. Фильтры = ALL + те же label'ы. Тексты заголовков/lede — по рефу
|
||||
(англ. «Tool catalog» / «Focused utilities for working with PNG…»), либо
|
||||
оставить RU-вариант, если это сознательное отклонение (зафиксировать).
|
||||
|
||||
7. **Адаптив**: перенести правила рефа (`@media max-width:800px`) —
|
||||
`.catalog-groups` → 1fr, `.tool-card` → `38px minmax(0,1fr) 18px`, скрыть
|
||||
`.tool-index`/показать `.tool-arrow`, `.catalog-head` в колонку и т.д.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`, `pnpm build`
|
||||
— без ошибок.
|
||||
- `pnpm refs-audit` (теперь мульти-маршрутный) → глянуть секцию
|
||||
`/preview/list-tools`: `onlyOurs`/`onlyRef` должны упасть с `257/54` примерно
|
||||
до уровня demo (`18/11`). Остаточный diff (если будет) зафиксировать в этом
|
||||
файле.
|
||||
|
||||
## Примечания
|
||||
|
||||
- `ToolCard` правится на месте — влияет на `preview/+page` и `preview/kit` (там
|
||||
тоже карточки каталога, унификация уместна).
|
||||
- Старый дизайн (`routes/(old)/**`, `components/search/**`) не трогать.
|
||||
- Коммиты делает автор (агент не коммитит).
|
||||
@@ -0,0 +1,97 @@
|
||||
# План: MEDIUM-волна №1 — удаление фона, размытие, резкость + Web Worker
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
- Первые операции настоящего алгоритмического уровня: свёртки и заливка связной
|
||||
области.
|
||||
- Витринная фича каталога — удаление однотонного фона без нейросетей: порог по
|
||||
цвету + заливка от краёв изображения + сглаживание границы маски.
|
||||
- Эти операции реально блокируют UI-поток на больших картинках — повод включить
|
||||
давно подготовленный путь: исполнитель `executeStep` переезжает в Web Worker,
|
||||
страницы не меняются вовсе.
|
||||
|
||||
## 2. Инструменты — 3
|
||||
|
||||
- **Удалить фон PNG** (`remove-background-png`, категория «Прозрачность») —
|
||||
параметры: цвет фона, допуск похожести %, режим «только внешние области»
|
||||
(заливка от краёв вместо глобального совпадения), сглаживание границы (проходы
|
||||
мажоритарного фильтра маски). Даёт превью маски через существующий контракт
|
||||
`preview`.
|
||||
- **Размытие PNG** (`blur-png`, новая категория «Фильтры») — гауссово
|
||||
приближение тремя проходами разделяемого бокса; радиус слайдером 1–32 px.
|
||||
- **Резкость PNG** (`sharpen-png`, «Фильтры») — свёртка ядром резкости с
|
||||
регулируемой силой (смесь исходного и обработанного), сила слайдером 0–100 %.
|
||||
|
||||
## 3. Новое ядро (чистые функции, детерминированные, покрытые тестами)
|
||||
|
||||
- `core/convolution.ts`
|
||||
- универсальная свёртка с клампом краёв;
|
||||
- разделяемый бокс-блюр и сборка гауссова приближения из трёх проходов;
|
||||
- ядро резкости и смешивание с оригиналом по силе.
|
||||
- `core/background.ts`
|
||||
- построение маски удаления по цветовому расстоянию (та же метрика, что у
|
||||
удаления цвета);
|
||||
- заливка связной области от всех граничных пикселей (очередь, без рекурсии);
|
||||
- мажоритарное сглаживание маски заданным числом проходов;
|
||||
- сборка результата: альфа 0 у удалённых пикселей; плюс функция маски для
|
||||
превью.
|
||||
|
||||
## 4. Web Worker
|
||||
|
||||
- Точка исполнения одна — `tools/executor.ts`; меняется только её внутренность.
|
||||
- Воркер-файл импортирует реестр и исполняет операцию по `toolId`; картинка
|
||||
приходит копией (структурный клон), результат возвращается передаваемым
|
||||
буфером.
|
||||
- Ленивая инициализация воркера при первом вызове; если воркер недоступен
|
||||
(старый браузер/тестовая среда) — автоматический откат на прямое исполнение в
|
||||
главном потоке. Обе ветви обязаны давать побайтово одинаковый результат.
|
||||
- Генераторы и текстовые представления остаются в главном потоке — они
|
||||
мгновенные.
|
||||
- Пипетка и лупа работают с уже готовыми картинками и не зависят от переноса.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — свёртки и фон с тестами на малых матрицах (точные ожидания
|
||||
руками). Проверка: `pnpm test/check`.
|
||||
- **B. Реестр** — категория «Фильтры», три записи, превью маски. Проверка: тесты
|
||||
целостности, визуально.
|
||||
- **C. Web Worker** — перенос исполнителя, отккат-ветвь. Проверка: результаты
|
||||
до/после переключения идентичны байт-в-байт на фиксированной картинке и
|
||||
параметрах; `test/build` зелёные.
|
||||
- **D. Итог** — полный прогон и смоук §8.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Размытие: радиус 1 и 32 — плавно усиливается, прозрачные края не темнеют.
|
||||
2. Резкость: сила 0 не меняет картинку; сила 100 заметно подчёркивает края без
|
||||
цветового мусора.
|
||||
3. Удаление фона на логотипе с однотонным фоном: фон исчез, содержимое цело.
|
||||
4. Режим «только внешние области»: дырка внутри объекта того же цвета остаётся
|
||||
непрозрачной.
|
||||
5. Сглаживание 0/2/4 прохода — граница от жёсткой к мягкой, результат
|
||||
воспроизводим.
|
||||
6. Превью маски показывает белым удаляемую область; скачивание всегда отдаёт
|
||||
результат.
|
||||
7. Во время размытия большой картинки интерфейс остаётся отзывчивым (чип
|
||||
«Пересчёт…», скролл живой).
|
||||
8. Цепочка: размытие → резкость → удаление фона работает; каждый блок скачивает
|
||||
свой промежуток.
|
||||
9. Регресс старых инструментов и главной-поиска без изменений поведения.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Три новых инструмента проходят смоук §6; операции ядра покрыты точными
|
||||
юнит-тестами.
|
||||
- Исполнение изображений идёт через воркер там, где он доступен, с
|
||||
гарантированно одинаковым результатом обеих ветвей.
|
||||
- `test/check/build` зелёные.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Многопроходное гауссово ядро «по-настоящему» (O(r²)) — трёхпроходного
|
||||
боксового приближения достаточно визуально и оно быстрее.
|
||||
- Хромакей по нескольким цветам, ИИ-сегментация — следующие волны.
|
||||
- Отмена выполняющегося прогона воркера — устаревшие результаты отбрасываются
|
||||
токеном, сама работа завершается фоном.
|
||||
@@ -0,0 +1,95 @@
|
||||
# План: MEDIUM-волна №2 — морфология прозрачности
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
Одна пара функций — расширение и сужение непрозрачных областей — открывает целое
|
||||
семейство пользовательских операций: обводка логотипов и иконок, контуры,
|
||||
утолщение и утончение подписей, удаление мелкого мусора и закрытие дырок. Всё
|
||||
это частые запросы в наборах PNG-утилит, а ядро получается компактным и легко
|
||||
покрывается тестами.
|
||||
|
||||
## 2. Инструменты — 7 (все в категории «Прозрачность»)
|
||||
|
||||
- **Обвести PNG** (`add-stroke-png`) — кольцо выбранного цвета вокруг
|
||||
непрозрачного содержимого; параметры: цвет, толщина 1–10 px.
|
||||
- **Найти контур PNG** (`find-contour-png`) — линия по краю непрозрачных
|
||||
областей; параметр: толщина линии.
|
||||
- **Утолщить PNG** (`make-thicker-png`) — расширение содержимого на N пикселей.
|
||||
- **Утончить PNG** (`make-thinner-png`) — сужение содержимого на N пикселей.
|
||||
- **Жёсткие края PNG** (`harden-alpha-png`) — бинаризация альфы по порогу:
|
||||
полупрозрачность уходит, остаётся два состояния.
|
||||
- **Убрать мусор PNG** (`despeckle-alpha-png`) — открытие: удаляет одиночные
|
||||
полупрозрачные пиксели и мелкие крапинки.
|
||||
- **Закрыть дыры PNG** (`close-holes-png`) — закрытие: заполняет одиночные
|
||||
прозрачные точки внутри объекта.
|
||||
|
||||
Все операции применяются к альфа-каналу; RGB пикселей сохраняется.
|
||||
|
||||
## 3. Ядро `core/morphology.ts`
|
||||
|
||||
Чистые функции над бинарной маской и над альфа-каналом:
|
||||
|
||||
- `buildAlphaMask(img)` — 1 там, где альфа > 0.
|
||||
- `dilateMask(mask, w, h, radius, shape)` — расширение; `shape`: квадрат или
|
||||
диск (для обводок диск даёт ровную круглую линию).
|
||||
- `erodeMask(...)` — зеркальная операция.
|
||||
- `dilateImage / erodeImage(img, radiusPx)` — применяют маскирование к альфе
|
||||
изображения, RGB сохраняется.
|
||||
- Сборщики:
|
||||
- обводка: `dilate(mask) − mask` → новое кольцо заливается цветом;
|
||||
- контур: `mask − erode(mask)` → линия;
|
||||
- открытие: `erode → dilate`; закрытие: `dilate → erode`.
|
||||
- `hardenAlpha(img, thresholdPercent)` — отдельно от морфологии, но той же
|
||||
волной.
|
||||
|
||||
Структурирующий элемент при радиусе r — диск радиуса r (расстояние до центра ≤
|
||||
r); квадрат не используем, чтобы углы обводок не были угловатыми.
|
||||
|
||||
## 4. Web Worker
|
||||
|
||||
Ничего не требуется: новые операции попадают в реестр, исполнитель автоматически
|
||||
исполняет их в воркере. Тесты ядра идут мимо воркера (чистые функции).
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — `morphology.ts` + точные тесты на матрицах 5×5: расширение
|
||||
диска, сужение, открытие убирает одиночный пиксель, закрытие закрывает
|
||||
одиночную дыру, кольцо обводки нужного цвета и толщины. Проверка:
|
||||
`pnpm test/check`.
|
||||
- **B. Реестр** — семь записей, тест целостности обновляется (51 → 58).
|
||||
Проверка: `test/build`, визуально.
|
||||
- **C. Итог** — полный прогон, смоук §7.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Семь новых инструментов работают на прозрачных PNG: обводка, контур,
|
||||
толще/тоньше, жёсткие края, убрать мусор, закрыть дыры.
|
||||
- Все операции детерминированы и покрыты юнит-тестами с ручными ожиданиями.
|
||||
- Исполнение автоматически идёт в воркере.
|
||||
- `test/check/build` зелёные, смоук §7 пройден.
|
||||
|
||||
## 7. Смоук-чеклист
|
||||
|
||||
1. Обводка толщиной 3 вокруг круглого стикера — ровное кольцо выбранного цвета,
|
||||
без угловатости.
|
||||
2. Толщина 1 — тонкая аккуратная линия; 10 — толстая, форма содержимого не
|
||||
искажается сверх роста.
|
||||
3. Контур показывает только край содержимого линией.
|
||||
4. Толще/тоньше на тонкой надписи — штрихи растут/сжимаются без разрывов при
|
||||
умеренных значениях.
|
||||
5. Жёсткие края: полупрозрачность исчезает, силуэт сохраняется.
|
||||
6. Убрать мусор убирает одиночные точки-крапинки, не трогая крупные области.
|
||||
7. Закрыть дыры заполняет одиночные прозрачные точки внутри логотипа.
|
||||
8. Цепочка: обводка после удаления фона работает; каждое звено скачивает свой
|
||||
промежуток.
|
||||
9. Регресс остальных инструментов без изменений.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Морфология по яркости для непрозрачных сканов — волна фокусируется на альфе
|
||||
прозрачных PNG.
|
||||
- Выбор структурирующего элемента пользователем — всегда диск.
|
||||
- Feather/свечение краёв — отдельная будущая задача поверх размытия.
|
||||
- Произвольные SVG-маски и градиентные обводки.
|
||||
@@ -0,0 +1,181 @@
|
||||
# План: дожать перевод инструментов в preview и переезд ветки `old/`
|
||||
|
||||
> **СТАТУС: ЗАКРЫТ (2026-09-07).** Перевод каталога завершён — 121 из 125
|
||||
> инструментов в `registry-new`; 4 хвоста отложены решениями и перенесены в
|
||||
> `backlog.md` (`watermark-image-png` → overlay-райз, `png-info` → exif-райз,
|
||||
> `reduce-to-size-png` → download-райз, `compress-png` → закрыт дубликатом).
|
||||
> Дальнейшие «хвосты плана» — переезд старых компонентов в папки `old/`,
|
||||
> закрытие долгов и сбор фидбека — ~уже учитываются в `plan-composite-params.md`
|
||||
> (шаги 36–37) и `plan-design-fix.md`, поэтому план закрыт, а не продолжен.
|
||||
|
||||
> Архивная копия, ниже — состояние на момент закрытия.
|
||||
|
||||
> Статус: **переведено 121 из 125; остальные 4 — отложены/закрыты решениями.**
|
||||
> Текущее состояние: **121 инструмент переведён в `registry-new`**, старый UI
|
||||
> уже живёт на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте (плагин
|
||||
> `isolation/no-mixed-imports`). Здесь — что сделать, чтобы:
|
||||
>
|
||||
> 1. в preview был **весь** каталог инструментов (плюс закрыты известные долги);
|
||||
> 2. физически перенести старую ветку в папки `old/` (шаг 37 основного плана);
|
||||
> 3. после переезда провести детальную проверку в реальных условиях и собрать
|
||||
> отзывы пользователей (правки поверх — отдельными задачами).
|
||||
>
|
||||
> Порядок: перевод остатка → починить известные долги → переезд в `old/` →
|
||||
> пользовательская проверка/фидбек → (позже, отдельным решением) удаление
|
||||
> старого кода.
|
||||
|
||||
## Текущая картина (факты)
|
||||
|
||||
- `registry.ts` + `registry/` — **125 инструментов**; `registry-new/` — **121**.
|
||||
Не переведены только: `png-info` (отложен, хвост-фича с exif), `compress-png`
|
||||
и `reduce-to-size-png` (закрыто решениями, см. ниже), `watermark-image-png`
|
||||
(overlay — единственный реальный остаток).
|
||||
- Preview (`/preview`) показывает переведённые; старый UI работает на
|
||||
`/`-маршрутах (группа `(old)/`): `+page`, `demo`, `list-tools`, `tools/[id]`.
|
||||
Переведено всё кроме четырёх хвостов: color 18, filters 3, geometry 13, alpha
|
||||
13 (+watermark-image в остатке), generate 10 (mix/sort), analyze 12 (6 масок +
|
||||
verify + 5 вердиктов), convert 14 (jpg/webp/bmp + 5 png→texт + 6 text→png),
|
||||
text `watermark-tile-png`.
|
||||
- Плагин `isolation/no-mixed-imports` (`web/eslint-plugins/isolation/`)
|
||||
полностью разделяет ветки: старый `lib/registry.ts|/registry/**`,
|
||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||
`lib/components/**`(кроме `kit/`) vs новый `routes/preview/**`,
|
||||
`lib/registry-new/**`, `lib/preview/**`, `lib/registry-schema.ts`,
|
||||
`lib/components/kit/**`. Общее: `core/`, `i18n/`, `theme`, `assets/`, корневой
|
||||
`lib`.
|
||||
- Правило копий (из основного плана): файл, используемый `(old)`, не трогаем —
|
||||
делаем копию в `lib/preview/` (пример: `lib/preview/tool-icons.ts`).
|
||||
- Известные долги (предсуществующие, не создавались нами): ошибка
|
||||
`ToolCard.svelte:19:36` (svelte-check), фейл `palette.test.ts` (ENOENT
|
||||
`src/app.css` — из-за него `pnpm test` красный при 607 passed), ~335
|
||||
design-tokens ошибок `lint:all` в старых kit-компонентах, a11y-warning в
|
||||
`ColorField.svelte`, пустой ruleset в `Toggle.svelte`, неиспользуемые токены в
|
||||
`preview.css`.
|
||||
|
||||
## Скоуп перевода: переведено всё переводимое (отложено 4 хвоста)
|
||||
|
||||
Каталог дожат до предела текущих решений: переведено 121 из 125. Оставшиеся 4
|
||||
инструмента **отложены осознанно** (не делаем сейчас):
|
||||
|
||||
- **`watermark-image-png` (alpha)** — **отложен**. Нужна overlay-механика
|
||||
(второе изображение-знак через `getOverlay`/store + параметры
|
||||
scale/opacity/position/margin/aspect); решается отдельно, вместе с вопросом,
|
||||
как второй источник изображения вписывается в schema-driven preview.
|
||||
- **`png-info`** — **отложен осознанно**: будет серьёзно дорабатываться
|
||||
отдельным райзом (exif-теги, редактирование, структурированный вывод), для
|
||||
него нужен свой отдельный случай в UI, не «ещё один text/verdict».
|
||||
- **`compress-png`** — **закрыт как дубликат** `decrease-color-count-png`:
|
||||
пресеты сжатия 192/96/44 добавлены прямо в схему `decrease-color-count`.
|
||||
- **`reduce-to-size-png`** — **отложен** до достройки кнопки Download (backlog
|
||||
№7, «экспорт с лимитом размера»).
|
||||
|
||||
### Переведено в этом проходе (text-механика)
|
||||
|
||||
- **Типы:** `ToolEntry.input: "file" | "text" | "none"`,
|
||||
`ToolEntry.result: "image" | "text" | "verdict"`, `OutputFormat` (mime/ext/
|
||||
qualityParamId) для download.
|
||||
- **Executor:** `executeFromText` / `executeToText` / `executeTextToText`
|
||||
(прямые, без worker), экспорт в `preview/executor/index.ts`.
|
||||
- **UI:** `SchemaTextSource` (textarea + Render), `SchemaTextResult` (кол-блок с
|
||||
Copy/Download .txt или бейдж-вердикт по префиксу Yes/No), интеграция в
|
||||
`SchemaPreview`/`SchemaToolView` (режимы file/text/generate, рендер
|
||||
image/text/verdict, скрытие image-download для text-результата).
|
||||
- **Инструменты:** convert 5×png→text (base64/data-uri/hex/bytes/rgb-values),
|
||||
6×text→png (base64/data-uri/hex/bytes/rgb-values/svg, +width), analyze
|
||||
verify-is-png (text→text) и 5 вердиктов (grayscale/file-size/transparent/
|
||||
orientation). Доработка: `base64ToBytes` сужен до `Uint8Array<ArrayBuffer>`
|
||||
(совместимость с `decodeBytes`).
|
||||
- **Гейты:** eslint 0, prettier чист, svelte-check — только предсуществующий
|
||||
ToolCard, тесты — только предсуществующий фейл `palette.test.ts`.
|
||||
|
||||
## Известные долги перед переездом (завести tasks и закрыть)
|
||||
|
||||
По AGENTS.md техдолг «чинится только по заведённым tasks, не игнорируется»:
|
||||
`lint:all` (~335 design-tokens в kit), неиспользуемые токены preview.css, a11y
|
||||
`ColorField`, пустой ruleset `Toggle`. Плюс два, мешающих зелёной проверке
|
||||
до/после переезда: svelte-check error `ToolCard.svelte:19:36` и фейл
|
||||
`palette.test.ts` (ENOENT `src/app.css`). После их закрытия целевое состояние
|
||||
проверок перед переездом: `pnpm --dir web exec svelte-check` → 0 errors,
|
||||
`pnpm --dir web test` → зелёный, `pnpm --dir web lint:all` → только задокумент.
|
||||
ированный остаток (или 0).
|
||||
|
||||
## Шаги перевода остатка (порядок)
|
||||
|
||||
1. **Простые пачки** — **выполнено**: color (18) → filters (3) → geometry (13) →
|
||||
alpha (13/14, кроме watermark-image) → generate (10, включая mix/sort через
|
||||
kind `colors`) → analyze-маски (6) → text `watermark-tile-png`.
|
||||
2. **Text-механика** — **выполнено**: типы `input`/`result` + output-формат,
|
||||
executor `executeFromText`/`toText`/`textToText`, UI
|
||||
`SchemaTextSource`/`SchemaTextResult` + интеграция в preview, перевод
|
||||
конвертеров convert (5 png→text + 6 text→png) и analyze (verify + 5
|
||||
вердиктов). `png-info` отложен отдельным райзом (exif).
|
||||
3. **Шаг про overlay не делается** — `watermark-image-png` **отложен решением**
|
||||
(см. «Скоуп перевода» выше), отдельным райзом вместе с вопросом второго
|
||||
источника изображения в schema-driven preview.
|
||||
4. После категории — `schema.layout` для сгруппированных инструментов (шаг 32-33
|
||||
паттерн) и ручная проверка в `/preview`.
|
||||
|
||||
## Переезд в `old/` (шаг 37 основного плана)
|
||||
|
||||
> Скоп-сдвиг: «Scoped-пути двигаются вместе с папками» — обновляется
|
||||
> `eslint.config.js` (isolation-паттерны и design-tokens glob), не код правила.
|
||||
|
||||
1. **Что переезжает:** `lib/registry.ts`, `lib/registry/**`,
|
||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||
`lib/components/**` (кроме `kit/`) → папки `old/` (например
|
||||
`lib/old/registry …`, `lib/old/tools`, `lib/old/components`) — то есть всё
|
||||
старое в одном поддереве. `routes/(old)/` остаётся как есть (уже отделено).
|
||||
2. **Обновить конфиг плагина** `isolation/no-mixed-imports`: старые glob-ы
|
||||
`lib/registry/**…` заменить на `lib/old/**` (+ `lib/old/components/**` и
|
||||
т.п.), «новое» и «общее» не меняются. Проверить, что правило по-прежнему
|
||||
разделяет (тест фолс-позитивов: временный импорт old↔new ловится).
|
||||
3. **Импорты внутри старого кода** — относительные пути остаются рабочими (папки
|
||||
двигаются целиком); поправить только то, что ссылалось на `$lib/...` в другом
|
||||
стиле. Старые тесты (`registry.test.ts` и др.) переезжают вместе с
|
||||
исходниками и продолжают проходить.
|
||||
4. **Гейты переезда:** `svelte-check` 0 errors, `pnpm --dir web test` зелёный,
|
||||
`prettier --check .` чистый, `lint:all` = задокументированный остаток, обе
|
||||
ветки открываются (старая `/`, новая `/preview`).
|
||||
|
||||
## Проверка и сбор отзывов после переезда
|
||||
|
||||
1. **Регресс-чеклист по каталогу** — пройти все инструменты в `/preview`:
|
||||
дефолты совпадают со старыми, валидация/клампы поведения не меняют, результат
|
||||
эквивалентен старому UI (сверить на одинаковых входах).
|
||||
2. **Тяжёлые кейсы руками:** генераторы, маски, font-style/plate/gradient на
|
||||
всем наборе, text-source, output-формат. (`watermark-image` — позже, после
|
||||
overlay-райза.)
|
||||
3. **Сбор фидбека** — отдельные задачи на каждый найденный фикс; чеклист
|
||||
зафиксировать в этом документе по мере находок (или ссылкой на tasks).
|
||||
4. Сверка каталога с `tools-map.md` (покрытие/дедупликация
|
||||
`convert-png-to-jpg`/`webp`), при необходимости — обновить cards/мета.
|
||||
5. **Решение об удалении старого** — после завершения ревью и фидбека, отдельным
|
||||
шагом: тогда чистятся `lib/old/**`, `routes/(old)/**`, `old.css`, `exports`,
|
||||
изоляционный плагин (glob-ы больше не нужны) и открываются «правило
|
||||
копий»-дубли.
|
||||
|
||||
## Как ревьюить каждый шаг (гайды)
|
||||
|
||||
- Диф на **один инструмент** (или фундамент без инструментов) < ~500 строк.
|
||||
- Старый `params`/`run`/`defaultParams`/`sanitizeParams` не изменены; в
|
||||
`registry-new` копия логики + `schema` (дефолты равны старым).
|
||||
- Старый UI и старые тесты продолжают работать: `pnpm --dir web test`,
|
||||
`pnpm --dir web exec svelte-check`.
|
||||
- Новый инструмент проверяется **руками в `/preview`** end-to-end (применение +
|
||||
сброс дефолтов + смена значений).
|
||||
- После переезда каждый move-шаг гоняет 4 гейта (см. выше).
|
||||
|
||||
## Оценка трудозатрат
|
||||
|
||||
| Часть | Сложность | Оценка |
|
||||
| ---------------------------------------------------------------------- | -------------- | ------------------------- |
|
||||
| Простые инструменты (color/filters/geometry/alpha/generate/маски/text) | Низкая-Средняя | **выполнено** (62 шт) |
|
||||
| Text-механика + конвертеры/вердикты | Средняя | **выполнено** (16 шт) |
|
||||
| watermark-image / png-info / reduce-to-size (отложены решениями) | Высокая | отложено, отдельные райзы |
|
||||
| Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч |
|
||||
| Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч |
|
||||
| Проверка/фидбек + правки | Зависит | ~3-6ч |
|
||||
| **Итого** | | **~9-18ч** по фазам |
|
||||
|
||||
> Оценки ориентировочные; особые случаи — с ревью решения, объём финальных
|
||||
> правок по фидбеку не предсказуем.
|
||||
@@ -0,0 +1,131 @@
|
||||
# План: единый `run(ctx)` в registry-new (унификация 5 режимов исполнения)
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН (2026-09-10) — устарел.** Убивал пару багов-классов
|
||||
> «инструмент с generate показывает загрузку и не выдаёт результат»
|
||||
> (linear-gradient-png и все генераторы): причина — `input` не задан, диспетч по
|
||||
> факту наличия методов.
|
||||
|
||||
## Исходная проблема
|
||||
|
||||
Два бага у `linear-gradient-png` (и у всех генераторов):
|
||||
|
||||
1. Показывается загрузка изображения — `SchemaToolView.svelte` считает
|
||||
`inputMode = tool.input ?? "file"`, а `tool.input` у генераторов не задан.
|
||||
2. Результата нет — `SchemaActions` рендерит кнопку «Generate» только при
|
||||
`inputMode === "none"`, а он всегда `"file"`. Ui-контур «догадывается» о
|
||||
генераторе по эвристике `Boolean(tool.generate && !tool.run)`.
|
||||
|
||||
Корневая причина архитектурная: у инструмента 5 режимов исполнения (`run` 79
|
||||
шт., `generate` 21, `runFromText` 6, `toText` 9, `textToText` 1), и диспетч
|
||||
«какой метод вызывать» размазан между UI и executor. Способ исполнения
|
||||
(worker/поток, было `domOnly`) просочился в метод-диспетч. Договорились:
|
||||
**свести всё к одному методу `run(ctx)`**, а требование входа (`input`) — к
|
||||
обязательному полю. `domOnly` остаётся только executor-хинтом (уйдёт при
|
||||
переезде на WASM-ядро).
|
||||
|
||||
## Целевой контракт
|
||||
|
||||
```ts
|
||||
export type InputMode = "image" | "text" | "none"; // было "file"
|
||||
export type ResultKind = "image" | "text" | "verdict";
|
||||
|
||||
export interface ToolContext<P> {
|
||||
params: P;
|
||||
source?: PixelImage; // присутствует ⇔ input === "image"
|
||||
text?: string; // присутствует ⇔ input === "text"
|
||||
}
|
||||
|
||||
export type ToolResult = PixelImage | string;
|
||||
|
||||
export type ToolEntry<P = Record<string, unknown>> = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
schema: ToolSchema<P>;
|
||||
input: InputMode; // ОБЯЗАТЕЛЬНОЕ, дефолта нет
|
||||
result?: ResultKind; // default "image"
|
||||
run(ctx: ToolContext<P>): Promise<ToolResult> | ToolResult;
|
||||
output?: OutputFormat;
|
||||
icon?: string;
|
||||
domOnly?: boolean; // executor-хинт, уйдёт с WASM
|
||||
};
|
||||
```
|
||||
|
||||
Удаляем: `generate`, `runFromText`, `toText`, `textToText`. Хелперы контракта
|
||||
(бросают `ToolError`): `requireSource(ctx)`, `requireText(ctx)`. Обёртки для
|
||||
миграции тел 1:1: `imgTool((img, p) => …)`, `genTool((p) => …)`,
|
||||
`textGen((t, p) => …)`.
|
||||
|
||||
```ts
|
||||
const imgTool =
|
||||
<P>(fn: (img: PixelImage, p: P) => ToolResult) =>
|
||||
(ctx: ToolContext<P>) =>
|
||||
fn(requireSource(ctx), ctx.params);
|
||||
const genTool =
|
||||
<P>(fn: (p: P) => ToolResult) =>
|
||||
(ctx: ToolContext<P>) =>
|
||||
fn(ctx.params);
|
||||
const textGen =
|
||||
<P>(fn: (t: string, p: P) => ToolResult) =>
|
||||
(ctx: ToolContext<P>) =>
|
||||
fn(requireText(ctx), ctx.params);
|
||||
```
|
||||
|
||||
## Executor (единый)
|
||||
|
||||
```ts
|
||||
export async function execute(
|
||||
tool: ToolEntry,
|
||||
ctx: { params: Record<string, unknown>; source?: PixelImage; text?: string },
|
||||
): Promise<ToolResult> {
|
||||
const params = sanitizeSchemaParams(tool.schema, ctx.params);
|
||||
if (tool.input === "image" && !ctx.source)
|
||||
throw new ToolError("errors.sourceRequired");
|
||||
if (tool.input === "text" && !ctx.text?.trim())
|
||||
throw new ToolError("errors.textRequired");
|
||||
return route(tool, { params, source: ctx.source, text: ctx.text });
|
||||
}
|
||||
```
|
||||
|
||||
`route()` — единственное место выбора места исполнения: `domOnly` или нет Worker
|
||||
→ прямой вызов `tool.run(ctx)`, иначе worker с fallback (логика
|
||||
`ensureWorker/runInWorker/disableWorker` остаётся). Worker-протокол —
|
||||
`{ id, toolId, params, source?, text? }`.
|
||||
|
||||
## Шаги (ревью после каждого)
|
||||
|
||||
Все 11 шагов выполнены; гейт пройден: `svelte-check` → 0 errors,
|
||||
`pnpm --dir web lint` → чисто, `pnpm --dir web test` → 618/618 зелёный. E2E не
|
||||
делали.
|
||||
|
||||
| # | Шаг | Файлы | Что |
|
||||
| --- | --------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Типы | `registry-new/types.ts` | новый `ToolEntry`/`ToolContext`/`ToolResult`/`InputMode "image"`, хелперы `requireSource`/`requireText` + `imgTool`/`genTool`/`textGen` |
|
||||
| 2 | Executor+Worker | `preview/executor/executor.ts`, `executor.worker.ts`, `index.ts` | единый `execute`, `route` по `domOnly`, протокол `source?`/`text?` |
|
||||
| 3 | UI kit | `kit/SchemaToolView.svelte`, `SchemaPreview.svelte`, `SchemaActions.svelte`, `SchemaSourceTile.svelte` | одна `run()`, без `isGenerator`-эвристики, `inputMode` → `"image"` |
|
||||
| 4 | Генераторы | `registry-new/generate.ts` | 21 `generate(p)` → `run(ctx)` + `input: "none"` (чинит градиент и др.) |
|
||||
| 5 | Текст | `registry-new/convert.ts`, `analyze.ts` | `runFromText`/`toText`/`textToText` → `run`; `input: "image"` на run-инструментах |
|
||||
| 6 | Текст | `registry-new/text.ts` | domOnly run-инструменты → `run(ctx)` + `input: "image"` |
|
||||
| 7 | Геометрия | `registry-new/geometry.ts` | run → `run(ctx)` + `input: "image"` |
|
||||
| 8 | Альфа/цвет | `registry-new/alpha.ts`, `color.ts` | run → `run(ctx)` + `input: "image"` |
|
||||
| 9 | Фильтры | `registry-new/filters.ts` | run → `run(ctx)` + `input: "image"` |
|
||||
| 10 | Тесты | `registry-new/registry-new.test.ts` | `generate!`/`run!` → `run(ctx)`; guard-тест: `input` всегда задан, image-инструменты работают с источником, `none` — без |
|
||||
| 11 | Гейт | — | `svelte-check` → 0 errors, `pnpm --dir web lint` → чисто, `pnpm --dir web test` → 618/618 |
|
||||
|
||||
Примечание шага 8: поля `preview:` (маска у remove-color/remove-background)
|
||||
удалены — новый executor зовёт `run()` напрямую; идея вернуть отображение маски
|
||||
— в `docs/backlog.md` №17. Prettier-варнинги `prettier --check` по
|
||||
SchemaToolView.svelte и executor-файлам — предсуществующий долг.
|
||||
|
||||
По миграции: `input: "image"` добавлен всем run-инструментам (~80 шт.),
|
||||
`input: "text"` стоит у 7 text-инструментов, `input: "none"` — у 21 генератора.
|
||||
|
||||
## Известные последствия
|
||||
|
||||
- **Красная сборка с шага 1 до конца шага 10** — осознанное решение, ревью идёт
|
||||
по diff'у шагов.
|
||||
- Старый контур (`registry/**` + `lib/tools/executor.ts` + `ToolPage.svelte`,
|
||||
`sourceMode`) НЕ трогаем — он на удаление (C19).
|
||||
- `input: "file"` переименован в `input: "image"` — семантика «нужно именно
|
||||
изображение», а не «файл» (между инструментами может передаваться не файлом).
|
||||
@@ -0,0 +1,104 @@
|
||||
# План: range-слайдеры и пипетка цвета
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Целевое поведение
|
||||
|
||||
1. «Процентные» параметры (яркость, контраст, качество JPEG/WebP, порог
|
||||
похожести) — слайдеры с числовым значением рядом; точные координаты и размеры
|
||||
(resize/crop) остаются полями ввода.
|
||||
2. У цветовых параметров появляется кнопка-переключатель «Пипетка»: активна →
|
||||
клик по превью исходника снимает цвет пикселя и подставляет его в поле цвета;
|
||||
после выбора режим гаснет.
|
||||
3. В режиме пипетки возле курсора видна лупа: увеличенный блок пикселей вокруг
|
||||
точки с перекрестием и подписью hex текущего пикселя; клик выбирает центр
|
||||
перекрестия.
|
||||
4. Активная пипетка видна на превью перекрестием курсора; повторный клик по
|
||||
кнопке отменяет режим без выбора.
|
||||
5. Всё работает поверх текущего процесса: авто-применение с задержкой, токен
|
||||
запусков, маски — без изменений.
|
||||
|
||||
## 2. Слайдеры
|
||||
|
||||
- В реестре новый тип параметра `'slider'` с обязательными `min`/`max`;
|
||||
валидируется как число (дефолт в диапазоне, минимум меньше максимума).
|
||||
- Форма маппит его на готовый `SliderField` из `ui/`; санитизация параметров
|
||||
обрабатывает slider так же, как number.
|
||||
- Переводятся: яркость и контраст, качество JPEG, качество WebP, порог
|
||||
похожести. Остальные инструменты — без изменений.
|
||||
|
||||
## 3. Пипетка и лупа
|
||||
|
||||
- В ядро добавляется чистая функция `rgbToHex(r, g, b)` → строка вида `#rrggbb`,
|
||||
с юнит-тестами; чтение пикселя — из уже отрисованного canvas превью исходника
|
||||
через `getImageData(x, y, 1, 1)`.
|
||||
- `Preview.svelte` получает опциональный режим пипетки: пропсы «активна» и
|
||||
колбэк «цвет выбран»; курсор перекрестие, клик вычисляет координату пикселя с
|
||||
учётом разницы CSS-размера canvas и его внутренней ширины.
|
||||
- **Лупа** — компонент `PipetteLoupe.svelte`: канвас около 110×110 px у курсора
|
||||
(позиция fixed, `pointer-events: none`); рисует блок 15×15 пикселей из
|
||||
исходника с увеличением ×8 без сглаживания, перекрестие в центре, подпись hex
|
||||
текущего пикселя. Координаты блока клампятся у краёв изображения; лупа скрыта
|
||||
вне превью и вне режима пипетки. Тач-устройства: прямой тап выбирает пиксель
|
||||
без лупы.
|
||||
- Проводка: кнопка у каждого цветового поля в карточке параметров → состояние
|
||||
«активное цветовое поле» живёт в оркестраторе → выбор цвета записывает hex в
|
||||
это поле и выключает режим.
|
||||
- Пипетка работает только по левой панели (исходнику): удаляемый цвет и подложка
|
||||
берутся с оригинала.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
- **A. Слайдеры** — тип в реестре, перевод четырёх инструментов, тесты реестра и
|
||||
санитизации, тест `rgbToHex`. Проверка: `pnpm test/check`, визуально слайдеры.
|
||||
- **B. Пипетка** — режим в Preview, проводка через карточки до полей цвета.
|
||||
Проверка: смоук §6 пп. 5–7.
|
||||
- **B2. Лупа** — компонент лупы поверх режима пипетки. Проверка: смоук §6 пп.
|
||||
8–10.
|
||||
- **C. Итог** — полный прогон `test/check/build`.
|
||||
|
||||
## 5. Изменения по файлам
|
||||
|
||||
- `registry.ts` — тип `'slider'` в `ParamDef`, перевод четырёх записей, ветка в
|
||||
`sanitizeParams`.
|
||||
- `ParamForm.svelte` — ветка slider → `SliderField`.
|
||||
- `core/color.ts` — `rgbToHex`.
|
||||
- `Preview.svelte` — режим пипетки (курсор, клик, колбэк).
|
||||
- `PipetteLoupe.svelte` — лупа у курсора в режиме пипетки.
|
||||
- `SourceCard.svelte` — проброс режима и колбэка наверх.
|
||||
- `ParamsCard.svelte` + `ColorField.svelte` — кнопка «Пипетка» у цветового поля.
|
||||
- `ToolPage.svelte` — состояние активного поля, обработчик выбора цвета.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Яркость/контраст — слайдеры со значением справа, результат обновляется сам
|
||||
при перетаскивании.
|
||||
2. Качество JPEG/WebP — слайдер от 1 до 100.
|
||||
3. Порог похожести — слайдер от 0 до 100.
|
||||
4. Resize и crop остались числовыми полями.
|
||||
5. Удаление цвета: «Пипетка» → клик по красной области исходника → поле цвета
|
||||
получил hex этого пикселя, режим погас, результат пересчитался.
|
||||
6. Повторный клик по кнопке или выбор без подтверждения — режим снят, цвет не
|
||||
изменился.
|
||||
7. Подложка JPEG: пипетка проставляет цвет подложки тем же способом.
|
||||
8. В режиме пипетки лупа следует за курсором, перекрестие показывает пиксель под
|
||||
ним, подпись hex совпадает с выбранным при клике.
|
||||
9. У края картинки блок лупи корректно клампится — за границей тянется
|
||||
чёрный/прозрачный фон, выбор по-прежнему точен.
|
||||
10. Курсор ушёл с превью или режим выключен — лупа скрыта; клики по кнопкам под
|
||||
превью не перехватываются лупой.
|
||||
11. Слайдеры и пипетка дружат с быстрыми правками: одна финальная версия
|
||||
результата, чип «Пересчёт…» на месте.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Все процентные параметры — слайдеры; числовые поля остались только там, где
|
||||
нужен точный ввод.
|
||||
- Цвет любого color-параметра можно взять кликом с исходника.
|
||||
- `test/check/build` зелёные, чеклист §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Пипетка и лупа на превью результата и маски — только исходник.
|
||||
- Кастомная стилизация слайдеров за пределами дизайн-системы.
|
||||
- Лупа для тач-устройств — там прямой тап без лупы.
|
||||
@@ -0,0 +1,82 @@
|
||||
# План: инструмент «Разрезать PNG на части» (split-into-parts-png)
|
||||
|
||||
> Статус: **выполнено и в архиве** (2026-09). Инструмент и механизм «результат =
|
||||
> набор файлов» (1 → many) реализованы; первый потребитель —
|
||||
> `split-into-parts-png`. Общий процесс таких инструментов — задача 18 в
|
||||
> `docs/backlog.md`.
|
||||
|
||||
## Цель
|
||||
|
||||
Пользователь загружает одну картинку, выбирает число столбцов и строк, картинка
|
||||
разрезается на равномерные части. Результат — набор PNG-файлов, скачивается
|
||||
ZIP-архивом.
|
||||
|
||||
## Решения
|
||||
|
||||
- **Неравномерное деление:** канвас дополняется прозрачным до кратного размера
|
||||
(`pieceW = ceil(w / cols)`, `newW = pieceW * cols`), все части строго равные,
|
||||
картинка покрывается целиком.
|
||||
- **Дефолт схемы:** 2 × 2.
|
||||
- **Zip-библиотека:** `fflate` (zero deps, малый размер), скачивание из UI-слоя
|
||||
(`encode()` требует DOM).
|
||||
- **Результат инструмента:** `result: "files"` — терминальный тип, в пайплайн
|
||||
(следующий шаг) не передаётся.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. **Зависимость:** `fflate` в `web/package.json`.
|
||||
2. **`registry/types.ts`:** `RESULT_KINDS.files`, тип `ToolImageFile`,
|
||||
`FileResult`, расширение `ToolResult = PixelImage | string | FileResult`.
|
||||
3. **`core/geometry.ts`:** `splitToParts(img, columns, rows)` — padding + сетка
|
||||
через `crop`.
|
||||
4. **`web/src/lib/zip.ts` (новый):** `downloadZip(files, zipName)` через
|
||||
`zipSync`.
|
||||
5. **`core/errors.ts`:** ключ `errors.tooManyParts` (страховочный лимит
|
||||
cols·rows ≤ 1000; при максимуме 6×6 недостижим).
|
||||
6. **`registry/geometry.ts`:** схема (`columns`, `rows`, дефолт 2×2, min 1, max
|
||||
6 — максимум 36 частей) + entry `split-into-parts-png` в `geometryEntries`.
|
||||
7. **Executor:** сериализация/десериализация `FileResult` в `executor.worker.ts`
|
||||
и тип ветки в `executor.ts`.
|
||||
8. **UI:** `SchemaToolView.svelte` (`fileResult`, Download → zip),
|
||||
`SchemaPreview.svelte` (canDownload для files), `SchemaResultTile.svelte`
|
||||
(сетка-превью частей, мета «N parts / ZIP»).
|
||||
9. **i18n:** переводы в `ru.ts`/`en.ts` **не добавляем** — словари `tools`
|
||||
привязаны к v1-реестру (тест «нет лишних ключей»), а preview берёт заголовки
|
||||
из registry (EN). Перевод придёт вместе с беклог-задачей «i18n в preview».
|
||||
Поиск по id/заголовку работает из registry.
|
||||
10. **Тесты:** юнит `splitToParts`, registry (result: "files"), i18n coverage.
|
||||
11. **Docs:** `docs/backlog.md` (задача «процесс 1 → many и many → 1», отметить
|
||||
пункт 11 «Мультифайловый вывод»), `docs/tools-map.md` (перенос из идей).
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Изменение |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `web/package.json` | `+ fflate` (dependencies) |
|
||||
| `web/src/lib/registry/types.ts` | `RESULT_KINDS.files`, `ToolImageFile`, `FileResult`, `ToolResult` |
|
||||
| `web/src/lib/core/geometry.ts` | `splitToParts()` |
|
||||
| `web/src/lib/zip.ts` | **новый** — `downloadZip()` |
|
||||
| `web/src/lib/core/errors.ts` | `errors.tooManyParts` |
|
||||
| `web/src/lib/registry/geometry.ts` | schema + `splitPartsTool` |
|
||||
| `web/src/lib/executor/executor.worker.ts` | ветка `FileResult` |
|
||||
| `web/src/lib/executor/executor.ts` | тип ветки `FileResult` |
|
||||
| `web/src/lib/components/SchemaToolView.svelte` | `fileResult`, download → zip |
|
||||
| `web/src/lib/components/SchemaPreview.svelte` | props `fileResult`, `canDownload` |
|
||||
| `web/src/lib/components/SchemaResultTile.svelte` | сетка частей, «N parts» |
|
||||
| `web/src/lib/i18n/ru.ts`, `en.ts` | записи инструмента |
|
||||
|
||||
## Нейминг файлов в ZIP
|
||||
|
||||
`part-<row>-<col>.png` (row, col нумеруются с 1, при 2×2 — `part-1-1.png`).
|
||||
|
||||
## Верификация
|
||||
|
||||
```bash
|
||||
pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json
|
||||
pnpm --dir web test
|
||||
pnpm --dir web lint
|
||||
```
|
||||
|
||||
Ручная проверка: PNG не кратного размера (например 101×77), 3×2 → 6 частей
|
||||
одинакового размера, скачивается ZIP; на кратном размере (100×80, 3×2) padding
|
||||
= 0.
|
||||
@@ -0,0 +1,95 @@
|
||||
# План: закрытие техдолга перед переездом `old/` (C17)
|
||||
|
||||
> **Статус: ЗАКРЫТ.** Все гейты пройдены, C17 разблокирован.
|
||||
|
||||
## Состав техдолга
|
||||
|
||||
- **svelte-check error** — `ToolCard.svelte:19:36`: `resolve(normalizedHref)`
|
||||
принимает только типизированный роут, а `normalizedHref: string`. Красный
|
||||
svelte-check, висит на каждом прогоне.
|
||||
- **фейл теста** — `src/lib/palette.test.ts`: `ENOENT src/app.css` (файл
|
||||
разъехался на `old.css` + `design2.css` при редизайне). Тест проверял
|
||||
контрасты палитры по токенам старого `app.css`
|
||||
(`text/surface/bg/link/ accent/danger/border`). При гейтах `pnpm test`
|
||||
красный, хотя 607 тестов проходят.
|
||||
- **design-tokens в `kit/**`** — ~335 ошибок `lint:all` от локального плагина
|
||||
`eslint-plugins/design-tokens` (захардкоженные цвета/размеры/длительности/
|
||||
z-index/`color-mix()` в `<style>`-блоках svelte), плюс stylelint по CSS.
|
||||
- **a11y-предупреждение** — `ColorField.svelte`.
|
||||
- **пустой ruleset** — `Toggle.svelte`.
|
||||
|
||||
## Этапы
|
||||
|
||||
### Этап 1 — «зелёные» гейты (мелкие, по ~10–40 строк каждый)
|
||||
|
||||
1. **ToolCard** — убрать ошибку типов: либо валидировать `href` против набора
|
||||
известных маршрутов, либо явно протащить типизированный роут из каталога.
|
||||
Проверка: svelte-check 0 errors.
|
||||
2. **palette.test.ts** — переписать чтение на новый словарь: тест контрастов
|
||||
гоняется по токенам `preview.css` (`:root` + `[data-theme="dark"]`, цвета
|
||||
`--color-*`/`--brand-*`). Отдельно: взять из нового дизайна, какие пары
|
||||
реально проверить (текст/фон, muted, акцент/бренд, бордер). Если часть
|
||||
проверок потеряла смысл в новом дизайне — сократить, не растягивать.
|
||||
3. **a11y `ColorField` + пустой ruleset `Toggle`** — пофиксить прямо в этих
|
||||
компонентах.
|
||||
|
||||
Гейт этапа: `svelte-check` 0 errors, `pnpm test` зелёный.
|
||||
|
||||
### Этап 2 — миграция `kit/**` на design-токены (main объём)
|
||||
|
||||
Цель — `lint:all` без ошибок design-tokens (все примитивы/`color-mix()` уходят в
|
||||
токены `preview.css`).
|
||||
|
||||
**Вариант А (основной): авто-фикс одноразовым скриптом.** Писать скрипт ТОЛЬКО
|
||||
если он гарантированно ничего не испортит. Гарантии:
|
||||
|
||||
- Скрипт — одноразовый (`web/scripts/fix-design-tokens.mjs`), после прогона
|
||||
удаляется либо помечается неактуальным.
|
||||
- Строгая карта замен: примитив → токен **1:1, только если значение точно
|
||||
совпадает с уже существующим токеном словаря** `preview.css` (цвет →
|
||||
`--color-*`/`--brand-*`, радиус → `--radius-*`, размер →
|
||||
`--space-*`/`--size-*`, длительность → `--duration-*`, z-index → `--z-*`).
|
||||
Никаких новых токенов скрипт не генерирует.
|
||||
- Замены — только примитивы, не содержащие `var()`/`calc()`/`color-mix()`;
|
||||
исключения по правилам плагина (0, 0px, 1px, проценты, unitless) не трогаются.
|
||||
`color-mix()` → выносится в `preview.css` как производный `--color-*` токен
|
||||
(может требовать ручного решения имени).
|
||||
- Скрипт обрабатывает только `src/lib/components/kit/**` и `src/preview.css`
|
||||
(scoped-пути плагина) и только `<style>`-блоки/`.css` — логику/разметку не
|
||||
меняет, только строки стилей.
|
||||
- После прогона: обязательный дифф-ревью (все изменения — замена значений на
|
||||
`var(--x)`, ничего удалено/переставлено), затем полный `lint:all` +
|
||||
`svelte-check` + `pnpm test`. Если хоть что-то вне «замена примитива на токен»
|
||||
— скрипт считается проваленным, откатываем и переходим на вариант Б.
|
||||
|
||||
**Вариант Б (страховка): руками, пакетами по компонентам.** Если скрипт не
|
||||
удался — чистим/заводим токены вручную, пакетами по компонентам (1–2 на задачу,
|
||||
дифф < ~500 строк). Отсутствующие токены (например
|
||||
`--space-2xl`/`--space-panel`, уже дважды обходились в SchemaTextSource)
|
||||
добавляем в словарь `preview.css` осознанно.
|
||||
|
||||
Гейт этапа: `pnpm --dir web lint:all` — только допустимый остаток
|
||||
(неиспользуемые токены — варнинг), плюс регресс-прогон тестов и svelte-check.
|
||||
|
||||
### Этап 3 — чистота словаря `preview.css`
|
||||
|
||||
- Разобрать варнинги неиспользуемых токенов (check-tokens): либо использовать,
|
||||
либо удалить из словаря.
|
||||
- После этапа 2 — повторный аудит токенов
|
||||
(`pnpm --dir web exec node scripts/check-tokens.mjs`): parity light/dark,
|
||||
hct-only, без мёртвых токенов.
|
||||
|
||||
### Этап 4 — финальные гейты (гейт переезда `old/`)
|
||||
|
||||
1. `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` → 0 errors
|
||||
2. `pnpm --dir web test` → зелёный
|
||||
3. `pnpm --dir web exec prettier --check .` → чистый
|
||||
4. `pnpm --dir web lint:all` → 0 (или только задокументированный остаток)
|
||||
5. Обе ветки открываются: старый UI `/`, новый `/preview`
|
||||
|
||||
## Порядок и коммиты
|
||||
|
||||
- Коммиты атомарные, < ~500 строк (AGENTS). По завершении каждой мини-задачи —
|
||||
ревью (агент не коммитит).
|
||||
- Техдолг чинится по заведённым задачам, правила линтеров не отключаются и не
|
||||
игнорируются.
|
||||
@@ -0,0 +1,194 @@
|
||||
# План: тестирование preview перед промоушеном в главную
|
||||
|
||||
> Статус: **реализация завершена**. Playwright-набор в `web/e2e/` (108 тестов:
|
||||
> 104 прошли, 4 `test.fixme` — известные баги №1–4 ниже), ручной чек-лист —
|
||||
> `docs/checklist-manual-testing.md`. План ветки preview
|
||||
> (`web/src/routes/preview/**`, регистр `lib/registry-new/`, 121 инструмент)
|
||||
> перед шагом C17 из `docs/plan-redesign.md` (переезд preview → реальные
|
||||
> маршруты).
|
||||
>
|
||||
> Охват: **документация плана** — реализация тестов (Playwright-раннер, сами
|
||||
> тесты) делается отдельными задачами после ревью этого документа.
|
||||
>
|
||||
> Приоритет: **отсутствие падений/ошибок** > пиксельная корректность. Дизайн
|
||||
> доделываем по ходу, визуальная сверка — ручная и вторичная.
|
||||
>
|
||||
> Команды для проверки в процессе:
|
||||
>
|
||||
> - `pnpm --dir web test` — существующий Vitest (42 файла, 121 инструмент
|
||||
> частично покрыт юнит-тестами).
|
||||
> - `pnpm --dir web test:e2e` — Playwright (`web/e2e/`), сборка +
|
||||
> `scripts/serve-static.mjs` на 4173.
|
||||
> - `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` — проверка
|
||||
> типов.
|
||||
> - `pnpm --dir web lint:all` — ESLint + stylelint + токен-аудит.
|
||||
> - `pnpm --dir web build` — статическая сборка (adapter-static).
|
||||
|
||||
## 0. Что тестируем
|
||||
|
||||
Preview-ветка — клиентское SPA на SvelteKit (adapter-static, всё в браузере,
|
||||
никакого API). Структура:
|
||||
|
||||
| Маршрут | Что это |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `/preview` | Workspace: поиск + сетка `ToolCard` (топ-24), «Open last project» (stub), футер |
|
||||
| `/preview/kit` | Витрина всех kit-компонентов (light/dark) |
|
||||
| `/preview/list-tools` | Каталог: 8 групп, 121 инструмент, фильтр по категории, поиск |
|
||||
| `/preview/tools/[id]` | Страница инструмента: `SchemaToolView` (настройки + live preview), prerendered на все 121 id |
|
||||
| `/preview/tools/xxx` (несуществующий) | 404 → `EmptyState` «Tool not found» |
|
||||
|
||||
Инструменты: **121**, категории CONVERT / TRANSPARENCY (alpha) / COLOR /
|
||||
GEOMETRY / FILTERS / TEXT / ANALYZE / GENERATE. Режимы инструмента:
|
||||
|
||||
- **file** (transform): upload → `run` → картинка → download;
|
||||
- **text** (convert-source): ввод текста → `runFromText` → картинка, либо
|
||||
`textToText` → текстовый результат;
|
||||
- **verdict** (`result: "verdict"`, file): `toText` → текстовый вердикт;
|
||||
- **text-output** (`result: "text"`, file): `toText` → текстовый результат;
|
||||
- **generate**: без входа, `executeGenerate` → картинка.
|
||||
|
||||
Исполнение: `preview/executor` — worker с fallback на main-thread (domOnly / нет
|
||||
Worker / ошибка worker). Auto-run с debounce 200 мс.
|
||||
|
||||
## 1. Функциональные блоки для проверки
|
||||
|
||||
### A. Инфраструктура и навигация
|
||||
|
||||
1. Все 4 маршрута + 404 открываются без console errors, uncaught exceptions и
|
||||
failed requests.
|
||||
2. Поиск на `/preview` — фильтрация по названию/описанию, пустой результат →
|
||||
аккуратное empty-state.
|
||||
3. Каталог `/preview/list-tools` — 8 групп, счётчик 121, фильтр категории,
|
||||
иконки не падают.
|
||||
4. Тема light/dark и язык RU/EN переключаются на превью-страницах без регресса
|
||||
рендера.
|
||||
5. 404 на несуществующий id инструмента → EmptyState, без краша.
|
||||
6. Prerender: `pnpm --dir web build` проходит; все 121 страницы
|
||||
`/preview/tools/[id]` генерируются (проверка `entries()`/`getTool`).
|
||||
|
||||
### B. Конвейер инструмента (file-инструменты)
|
||||
|
||||
7. Upload → авто-run → результатный тайл появился, поле ошибки пустое.
|
||||
8. Download — событие download, имя `<toolId>.<ext>`, MIME соответствует
|
||||
`output`.
|
||||
9. Reset — возвращает дефолты, сбрасывает результат и ошибку.
|
||||
10. Смена параметра → авто-перезапуск (debounce 200 мс) без «залипания»
|
||||
running-стейта.
|
||||
11. Worker-исполнение и fallback-исполнение оба дают результат.
|
||||
|
||||
### C. Текстовые инструменты и вердикты
|
||||
|
||||
12. Text-source: ввод → текстовый результат или картинка; copy/download .txt.
|
||||
13. Вердикты (analyze): корректный вердикт на PNG с альфой, на PNG без альфы, на
|
||||
не-PNG входе → без падений.
|
||||
14. Пустой text-source → run пропускается без ошибки.
|
||||
|
||||
### D. Генераторы
|
||||
|
||||
15. Каждый генератор по дефолтам создаёт картинку; смена размеров/цвета
|
||||
перегенерирует; канва ожидаемого размера.
|
||||
|
||||
## 2. Репрезентативная выборка по категориям (для «нет падений»)
|
||||
|
||||
Полный прогон всех 121 — опционально (C/all); минимальный чек — по группам:
|
||||
|
||||
| Категория | Кол-во | Охват для «нет падений» |
|
||||
| ------------ | ------ | --------------------------------------------------------------------------------- |
|
||||
| CONVERT | 14 | convert-png-to-jpg, convert-png-to-webp, png-to-base64, base64-to-png, svg-to-png |
|
||||
| TRANSPARENCY | 20 | remove-background, round-corners, add-stroke, remove-color-from, circle-mask |
|
||||
| COLOR | 25 | grayscale, invert, black-and-white, quantize, dithering, png-to-hsl |
|
||||
| GEOMETRY | 18 | resize, crop, rotate, flip, add-border, tile |
|
||||
| FILTERS | 8 | blur, sharpen, pixelate, add-noise |
|
||||
| TEXT | 3 | add-text, date-stamp, watermark-tile |
|
||||
| ANALYZE | 12 | extract-color-from, show-transparent-pixels, verify-is-png, png-is-transparent |
|
||||
| GENERATE | 21 | create-empty, linear-gradient, two-colors, placeholder, text-to-png |
|
||||
|
||||
## 3. Playwright (авто) против ручного — разбиение
|
||||
|
||||
### ✅ Playwright — детерминированные сквозные потоки и «нет падений»
|
||||
|
||||
- Все маршруты + 404: открытие, отсутствие console errors / uncaught exceptions
|
||||
/ failed requests (слушатели `page.on('console'/'pageerror')`).
|
||||
- Поиск и каталог: фильтрация, счётчики, переходы.
|
||||
- Upload (`setInputFiles` с фикстурой PNG) → авто-результат → Download (ожидание
|
||||
`download` события, проверка имени/расширения/suggestedFilename).
|
||||
- Reset / смена параметра → перегенерация, running не «залипает».
|
||||
- Тема light/dark и язык RU/EN.
|
||||
- Текстовые инструменты: ввод → текстовый результат → копия/скачивание.
|
||||
- Вердикты на подготовленных фикстурах (PNG с альфой, PNG без альфы,
|
||||
JPG/некартинка).
|
||||
- Генераторы: дефолты → результат появился, размер канвы ожидаемый.
|
||||
- 404 и empty-state.
|
||||
- Worker и fallback: сравнить оба пути рендера (инжект отключения Worker).
|
||||
|
||||
### 🔄 Playwright + ручная сверка
|
||||
|
||||
- Пиксельная корректность результата (сравнение `<canvas>` через
|
||||
`toDataURL`/тест-per-pixel) — по приоритету вторично; эталоны задаются
|
||||
вручную, делать отдельной задачей.
|
||||
- Реальные пользовательские картинки (не фикстуры) — вручную.
|
||||
|
||||
### ✋ Ручное тестирование
|
||||
|
||||
- **Краевые PNG:** большие (мегапиксели), 1×1, анимация, палитровые,
|
||||
чекерборд/полупрозрачность, повреждённые файлы → нет зависаний, понятные
|
||||
сообщения об ошибке.
|
||||
- **Края параметров:** экстремальные значения, NaN/пустые поля, инвалидные цвета
|
||||
в плашках/градиентах → корректная санитизация (silent repair).
|
||||
- **Производительность/отзывчивость:** задержка авто-реранов на слайдерах, нет
|
||||
«залипания» UI, скролл при длинных настройках.
|
||||
- **Визуальный слой дизайна** (доделываем по ходу): сетки, выравнивание, отступы
|
||||
на брейкпоинтах mobile/tablet/desktop (640/800/1100).
|
||||
- **Буфер обмена** (copy) в реальном браузере.
|
||||
- **Drag-n-drop** файла на dropzone (UX-ощущения).
|
||||
- **Разные браузеры:** Firefox/Safari-нюансы (webkit в Playwright опционален).
|
||||
- **Субъективная корректность:** dithering, two-colors, watermark, текст
|
||||
не-ASCII/кириллица в add-text/date-stamp/watermark-tile.
|
||||
|
||||
## 4. Критерии приёмки (доступ в главную)
|
||||
|
||||
1. Все 4 маршрута + 404 открываются без console errors и исключений.
|
||||
2. Репрезентативный прогон по 8 категориям: ни один инструмент не падает и не
|
||||
показывает поле ошибки.
|
||||
3. Upload → результат → download завершается для file-инструментов;
|
||||
текст/вердикты/генераторы дают корректный output-тип.
|
||||
4. `pnpm --dir web test`, `pnpm --dir web lint:all`, `pnpm --dir web build`
|
||||
зелёные.
|
||||
5. Тема и язык переключаются без регрессов.
|
||||
|
||||
## 5. Оценка покрытия
|
||||
|
||||
- **~60–70%** «нет падений/ошибок» закрывается Playwright детерминированно
|
||||
(маршруты, сквозные потоки, отсутствие ошибок).
|
||||
- **~30–40%** — руками: краевые PNG/параметры, реальные пользовательские файлы,
|
||||
буфер обмена, кросс-браузеры, визуальный/производительный слой, субъективная
|
||||
корректность.
|
||||
|
||||
## 6. Шаги реализации (после ревью)
|
||||
|
||||
1. ✅ Playwright-раннер: `web/playwright.config.ts` (webServer на
|
||||
`pnpm build + `scripts/serve-static.mjs` `--port
|
||||
4173`), папка `web/e2e/`, npm-скрипт `test:e2e`.
|
||||
2. ✅ Фикстуры PNG (генерируются в рантайме, `web/e2e/helpers/fixtures.ts`): с
|
||||
альфой, без альфы, 1×1, большой, повреждённый, ландшафт.
|
||||
3. ✅ E2E по блокам A–D и секции 3 (авто-часть): `navigation.spec.ts`,
|
||||
`catalog.spec.ts`, `pipeline.spec.ts`, `text-and-verdicts.spec.ts`,
|
||||
`tools-smoke.spec.ts` (матрица ~71 инструмента), `generators.spec.ts`,
|
||||
`known-issues.spec.ts` (fixme).
|
||||
4. ✅ Ручной чек-лист из секции 3 (ручная часть) —
|
||||
`docs/checklist-manual-testing.md` (разделы A–G, включая известные баги).
|
||||
5. ✅ Найденные баги (зафиксированы как `test.fixme` + чек-лист §G):
|
||||
1. генераторы (21/121) не имеют кнопки «Generate» и полей схемы — результат
|
||||
через UI недостижим;
|
||||
2. `resize-png` с дефолтом 0×0 → `errors.resizeSize` при любом входе;
|
||||
3. `crop-png` с дефолтом 0×0 → `errors.cropSize` при любом входе;
|
||||
4. ключи ошибок показываются сырыми (`errors.*`) вместо переведённого текста
|
||||
из `en.ts`/`ru.ts`. Исправления — отдельными атомарными задачами по
|
||||
конвенции AGENTS.md (после правок: `lint:all`, `test:e2e`).
|
||||
|
||||
## 7. Итоговое состояние на момент сдачи
|
||||
|
||||
- `pnpm --dir web test:e2e` — **104 passed, 4 skipped (fixme), 0 failed**.
|
||||
- `pnpm --dir web lint:all` и `svelte-check` — зелёные.
|
||||
- Визуальный слой, краевые PNG/параметры, кросс-браузеры — остаются на ручной
|
||||
прогон по `docs/checklist-manual-testing.md`.
|
||||
@@ -0,0 +1,81 @@
|
||||
# План: волна 4 — текст, штампы, водяные знаки
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.**
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Четыре инструмента новой категории «Текст»: надпись на изображении, дата-штамп,
|
||||
защитная плитка (повторяющийся полупрозрачный текст под углом), водяной знак
|
||||
картинкой. Каталог вырастает с 69 до 73. Это первые операции, рисующие через
|
||||
canvas 2D — класс perceptual по роадмапу: точные пиксельные тесты на них не
|
||||
пишутся, юнит-тестами покрывается чистая математика раскладки.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Вся геометрия — чистые функции без DOM: якорь из 9-позиционной сетки →
|
||||
координаты, перенос текста по ширине с инъекцией измерителя, раскладка плитки
|
||||
с углом и шагом. Canvas-вызовы — тонкий слой рядом (`core/textdraw.ts`), в
|
||||
юнит-тесты не попадает.
|
||||
- Один движок рендера текста на все три текстовых инструмента: шрифт из
|
||||
системного набора (sans/serif/mono), размер, цвет, жирность, прозрачность,
|
||||
поворот. Различия инструментов — только в пресетах параметров и количестве
|
||||
слоёв (одна надпись vs плитка).
|
||||
- Дата-штамп — надпись с автотекстом: строка формата (`YYYY-MM-DD hh:mm`)
|
||||
разворачивается в текущую дату в момент запуска; свой мини-форматтер без
|
||||
зависимостей.
|
||||
- Водяной знак картинкой — первый двухисточниковый инструмент: вторая картинка
|
||||
грузится отдельной зоной в этапе, живёт только в состоянии страницы и
|
||||
сознательно не сохраняется ни в workspace-pipeline, ни в экспорте JSON; после
|
||||
восстановления цепочки знак нужно выбрать заново. Ограничение фиксируем в
|
||||
описании инструмента.
|
||||
- Новая категория `text` встаёт после «Фильтров»; названия в обоих словарях,
|
||||
порядок в CATEGORIES один.
|
||||
|
||||
## 3. Инструменты
|
||||
|
||||
- `add-text-png` — параметры: текст (строка), размер (слайдер 8–200), цвет,
|
||||
шрифт (select), жирность (чекбокс), позиция (select 3×3: углы/стороны/центр),
|
||||
отступ (слайдер). Опциональная подложка: цвет + прозрачность плашки за
|
||||
текстом.
|
||||
- `date-stamp-png` — тот же набор минус текст, плюс формат даты; дефолт — нижний
|
||||
правый угол.
|
||||
- `watermark-tile-png` — текст, размер, цвет, прозрачность (0–100), угол
|
||||
(-90…90), шаг по X/Y; плитка заполняет весь холст.
|
||||
- `watermark-image-png` — второй источник (загрузка PNG с альфой), масштаб знака
|
||||
(% от ширины холста), прозрачность, позиция 3×3, отступ.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Фундамент** — категория `text` (categories + словари + порядок),
|
||||
`core/textdraw.ts`: anchor9, wrap, tileLayout с юнит-тестами на математике
|
||||
(координаты углов/центра, переносы слов, плотность плитки), тонкий
|
||||
canvas-модуль `domText.ts` (без тестов). Реестр пока пуст в категории.
|
||||
- **B. Надпись и дата** — `add-text-png`, `date-stamp-png` на общем движке;
|
||||
иконки, переводы, записи реестра. Смоук: обе локали, тёмная тема (плашка
|
||||
читаема), длинный текст переносится и не вылезает за край.
|
||||
- **C. Плитка** — `watermark-tile-png`; смоук: отрицательные и нулевой угол,
|
||||
прозрачность 0/50/100, мелкий шаг не подвешивает страницу на больших холстах
|
||||
(ограничение количества плиток капом).
|
||||
- **D. Знак картинкой** — двухисточниковый UX: вторая drop-зона в этапе
|
||||
(компонент рядом с SourceCard, виден только у этого инструмента),
|
||||
масштаб/позиция/отступ; ограничение про сохранение — в описании и подсказке.
|
||||
Этап режется без ущерба остальным, если решим отложить.
|
||||
- **E. Итог** — полный прогон, смоук §6, архивация плана.
|
||||
|
||||
## 5. Критерии готовности
|
||||
|
||||
- 73 инструмента; вся математика раскладки покрыта юнит-тестами; canvas-слой
|
||||
изолирован в одном модуле.
|
||||
- test/check/build зелёные; новые инструменты работают в цепочке как обычные
|
||||
шаги (кроме источника знака — см. ограничение).
|
||||
|
||||
## 6. Что сознательно не делаем
|
||||
|
||||
- Выбор произвольных пользовательских шрифтов (файлами) и веб-шрифтов — только
|
||||
системный набор.
|
||||
- Кернинг/линии/многострочное редактирование с абзацами — один абзац с
|
||||
автопереносом.
|
||||
- Сохранение картинки-знака в пайплайне и её передача между сессиями.
|
||||
- Текст по контуру, градиентные заливки текста.
|
||||
@@ -0,0 +1,61 @@
|
||||
# План: светлая и тёмная тема
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.**
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Переключатель light/dark в шапке рядом с языковым. Выбор в localStorage
|
||||
(`theme`), первый визит — по `prefers-color-scheme`. Тёмная палитра —
|
||||
переопределение токенов селектором `[data-theme='dark']`; компоненты не
|
||||
правятся.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Два состояния, без «авто»: ключ хранит только явный выбор, отсутствие ключа =
|
||||
системная тема.
|
||||
- Анти-вспышка: инлайн-скрипт в `app.html` до гидрации ставит `data-theme` из
|
||||
localStorage или `matchMedia`; пререндеренная разметка остаётся светлой.
|
||||
- `color-scheme: dark` на корне — нативные контролы (input[type=color], range,
|
||||
select, скроллбары) темнеют без правок.
|
||||
- Модуль `theme.svelte.ts` по образцу locale: состояние, персист, применение
|
||||
атрибута. Стартовую установку делает скрипт из `app.html`, модуль
|
||||
синхронизирует runes-состояние после гидрации.
|
||||
|
||||
## 3. Палитра
|
||||
|
||||
- Переопределяем: `--bg`, `--surface`, `--border`, `--text`, `--text-muted`.
|
||||
- Акцент `#2563eb` не меняется; `--accent-hover` — светлее для тёмного фона.
|
||||
- `--danger` осветлить для читаемости.
|
||||
- `--check-a`/`--check-b` шахматки — приглушённые серые: паттерн прозрачности
|
||||
остаётся узнаваемым, но не слепит.
|
||||
- `--shadow-card` — почти чёрная с низкой альфой вместо синей.
|
||||
- Все `color-mix(… var(--surface))` пересчитаются сами — проверить визуально
|
||||
легенды, recalc-бейдж, drag-оверлеи.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Инфраструктура** — `theme.svelte.ts`, скрипт в `app.html`, переключатель
|
||||
в шапке, черновая тёмная палитра. Тесты: персист выбора, игнор мусора в
|
||||
localStorage, применение атрибута (стаб document).
|
||||
- **B. Палитра** — выверка значений: герой, каталог, страница инструмента,
|
||||
цепочка, баннер ошибки, маска, текстовые инструменты; фокус-ринги и hover; обе
|
||||
локали.
|
||||
|
||||
## 5. Смоук
|
||||
|
||||
1. Переключение мгновенное, без перезагрузки; выбор переживает её же.
|
||||
2. При системной тёмной теме первый визит открывается сразу тёмным.
|
||||
3. Нативные контролы выглядят тёмными.
|
||||
4. Шахматка различима, легенды и бейджи читаемы.
|
||||
5. Баннер ошибки и пипетка заметны на тёмных панелях.
|
||||
|
||||
## 6. Критерий готовности
|
||||
|
||||
Grep: ни одного цвета вне токенов в компонентах и маршрутах.
|
||||
|
||||
## 7. Не делаем
|
||||
|
||||
Три позиции в переключателе; пользовательские цветовые схемы; автосмену по
|
||||
времени суток; отдельную тему для превью изображений.
|
||||
@@ -0,0 +1,98 @@
|
||||
# План: декомпозиция страницы инструмента, реактивные параметры, предпросмотр маски
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
- Страница инструмента (`ToolPage.svelte`) разрослась: состояние процесса,
|
||||
загрузка файлов, вставка из буфера и разметка трёх зон живут в одном файле.
|
||||
- Назревают два требования к процессу: параметры применяются автоматически без
|
||||
кнопки; у части утилит на экране показывается одно — визуализация
|
||||
фильтра/маски, а скачивается (и в будущем передаётся следующему инструменту)
|
||||
другое — результат.
|
||||
|
||||
## 2. Контракт «результат и маска» в реестре
|
||||
|
||||
- `run(img, params)` — как сейчас: возвращает итоговое изображение. Его
|
||||
скачивают и будут передавать дальше по цепочке.
|
||||
- Запись реестра получает опциональное поле `preview?(img, params)` — возвращает
|
||||
картинку для показа на экране (маску/фильтр).
|
||||
- Если у записи есть `preview`, в карточке результата появляется переключатель
|
||||
«Показать маску». Включён — на превью маска, выключен (по умолчанию) —
|
||||
результат. Скачивание всегда отдаёт результат `run`, независимо от положения
|
||||
переключателя.
|
||||
- Первая реализация — удаление цвета: в ядро добавляется чистая функция
|
||||
`colorMask(img, hex, tolerance)` — чёрно-белая маска, где удаляемые пиксели
|
||||
белые; с юнит-тестами; `preview` записи вызывает её.
|
||||
|
||||
## 3. Декомпозиция страницы
|
||||
|
||||
Новая папка `components/tool/`; карточки — чисто отображательные, состояние
|
||||
живёт только в оркестраторе:
|
||||
|
||||
- **`SourceCard.svelte`** — левая половина сцены: пусто → зона перетаскивания;
|
||||
загружено → превью исходника под оверлеем замены с кнопкой «Заменить
|
||||
изображение».
|
||||
- **`ResultCard.svelte`** — правая половина: пустые состояния, превью результата
|
||||
или сводка, чип «Пересчёт…», кнопка скачивания, при наличии `preview` —
|
||||
переключатель маски.
|
||||
- **`ParamsCard.svelte`** — форма параметров и подпись «Изменения применяются
|
||||
автоматически»; для инструментов без параметров не выводится.
|
||||
- **`ToolPage.svelte`** остаётся оркестратором: состояние (исходник, результат,
|
||||
сводка, статус, ошибка, значения), декодирование файлов, вставка из буфера,
|
||||
сброс, токен запусков и эффект с задержкой. Ожидаемо худеет примерно вдвое.
|
||||
|
||||
Карточки переиспользуются будущим многошаговым режимом.
|
||||
|
||||
## 4. Реактивные параметры (в этом же проходе)
|
||||
|
||||
- Кнопки «Применить» больше нет ни у одного инструмента.
|
||||
- Изменение параметра запускает вычисление через 300 мс после последнего
|
||||
изменения; каждое новое изменение перезапускает таймер.
|
||||
- Токен запусков: если во время вычисления параметры поменялись, устаревший
|
||||
ответ отбрасывается.
|
||||
- Загрузка и замена файла считают сразу и подавляют ровно один авто-запуск
|
||||
эффекта.
|
||||
- Ошибка пересчёта — баннер сверху, прежний результат не сбрасывается.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Декомпозиция** — перенос разметки в три карточки один к одному, поведение
|
||||
не меняется. Проверка: `check/build`, визуально идентично.
|
||||
- **B. Реактивность** — токен, задержка, чип пересчёта, минус кнопка. Проверка:
|
||||
смоук §6 пп. 1–6.
|
||||
- **C. Маска** — типы реестра, `colorMask` с тестами, переключатель в карточке
|
||||
результата, пример на удалении цвета. Проверка: тесты + смоук §6 пп. 7–9.
|
||||
- **D. Итог** — полный прогон `test/check/build`.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Загрузка файла → результат появляется сразу.
|
||||
2. Изменить параметр → около 0.3 с и результат обновился сам.
|
||||
3. Быстро изменить яркость несколько раз → одна финальная версия, промежуточные
|
||||
не мелькают.
|
||||
4. Во время пересчёта чип виден, предыдущий результат не исчезает.
|
||||
5. Инструмент без параметров и информационная страница ведут себя как раньше.
|
||||
6. Некорректные параметры (обрезка вне картинки) → баннер ошибки, прежний
|
||||
результат цел.
|
||||
7. Переключатель «Показать маску» у удаления цвета: включён — белым по чёрному
|
||||
видно, что удалится; выключен — обычный полупрозрачный результат.
|
||||
8. С включённой маской скачивание всё равно сохраняет прозрачный результат, а не
|
||||
маску.
|
||||
9. Замена исходника возвращает переключатель маски в выключенное состояние.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- `ToolPage` содержит только оркестрацию состояния и эффектов; вся разметка
|
||||
панелей — в `components/tool/*`.
|
||||
- Кнопки «Применить» нет ни на одной странице.
|
||||
- Скачивание всегда соответствует `run`; экран может показывать `preview`.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Вынос вычислений в веб-воркер; настраиваемая задержка — фиксированные 300 мс.
|
||||
- Редактор масок (кисть, слои, история) — только автогенерация из параметров
|
||||
инструмента.
|
||||
- Передача результата между инструментами — придёт вместе с многошаговым режимом
|
||||
позже.
|
||||
@@ -0,0 +1,118 @@
|
||||
# План: раскладка «исходник слева — результат справа» + вставка изображения
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-22.**
|
||||
>
|
||||
> **Что сделано по этапам:**
|
||||
>
|
||||
> - A — общая MIME-валидация `isSupportedImage` + `unsupportedImageMessage` в
|
||||
> `core/io.ts`; `DropZone` переведён на них ✔
|
||||
> - B — постоянная двухпанельная раскладка: одна карточка-«сцена» с равными
|
||||
> половинами «Исходник | Результат», кнопки внутри своих половин, параметры —
|
||||
> отдельной карточкой ниже; контент сайта растянут на всю ширину окна ✔
|
||||
> - C — Ctrl+V: `<svelte:window onpaste>`, поиск image-item в буфере, общая
|
||||
> валидация; текстовые вставки не затрагиваются ✔
|
||||
> - D — drag-замена: компонент `DropOverlay` с вуалью-подсказкой и счётчиком
|
||||
> `dragenter/dragleave` против мерцания; тот же счётчик в `DropZone` ✔
|
||||
> - E — верификация: тесты 85/85, svelte-check 0 ошибок, статический билд с 11
|
||||
> страницами; ручной смоук-чеклист §5 пройден ✔
|
||||
>
|
||||
> **Отступления от исходного плана в ходе реализации:** вместо пропа `overlay` у
|
||||
> `DropZone` сделан отдельный компонент-обёртка `DropOverlay` (drag-события
|
||||
> ловит контент под курсором); раскладка доработана по ревью — одна карточка
|
||||
> равных половин вместо двух независимых колонок, сайт без ограничения ширины.
|
||||
|
||||
## 1. Целевое поведение
|
||||
|
||||
1. **Постоянная двухпанельная раскладка** страницы инструмента — отдельного
|
||||
состояния «страница загрузки» больше нет:
|
||||
- **Слева — исходник**, панель есть всегда. Не загружено → пустое дроп-поле
|
||||
(drag&drop или клик). Загружено → превью исходника.
|
||||
- **Справа — результат**: до загрузки — `EmptyState` «Результат появится
|
||||
здесь»; во время обработки — `EmptyState` «Обработка…»; после — превью
|
||||
результата (для info-инструмента — `InfoPanel`).
|
||||
2. **Ctrl+V** — картинка из буфера обмена вставляется как исходник с любого
|
||||
места страницы.
|
||||
3. **Замена перетаскиванием** — новый файл, брошенный на левую панель с
|
||||
загруженной картинкой, заменяет исходник (с оверлеем-подсказкой во время
|
||||
перетаскивания).
|
||||
4. **Кнопка «Заменить»** под превью исходника (переносится туда текущая
|
||||
«Загрузить другое изображение»).
|
||||
|
||||
На мобильной ширине панели складываются в столбец: исходник сверху, результат
|
||||
снизу.
|
||||
|
||||
## 2. Изменения по файлам
|
||||
|
||||
Новые компоненты не вводим — `EmptyState`, `Preview`, `DropZone` покрывают всё.
|
||||
Меняем три файла:
|
||||
|
||||
- **`core/io.ts`** — экспорт `isSupportedImage(file): boolean`: общая
|
||||
MIME-проверка на базе `ACCEPTED_IMAGE_TYPES`.
|
||||
- **`DropZone.svelte`** — переходит на `isSupportedImage`; новый опциональный
|
||||
проп `overlay` — режим «невидимая зона приёма поверх контента» для замены
|
||||
перетаскиванием.
|
||||
- **`ToolPage.svelte`** — постоянная сетка из двух панелей;
|
||||
`<svelte:window onpaste>`; drag-оверлей левой панели; кнопки замены/сброса
|
||||
переезжают в левую панель; правая панель — три состояния через `EmptyState`.
|
||||
|
||||
## 3. Этапы и проверки
|
||||
|
||||
- **A. Общая валидация** — `isSupportedImage` в `io.ts`, `DropZone` переведён на
|
||||
неё. Проверка: `pnpm check`.
|
||||
- **B. Раскладка** — две постоянные колонки в ToolPage; правая панель через
|
||||
EmptyState; кнопки уходят в левую панель; обёртка `{#if !source}` на всю
|
||||
страницу убирается. Проверка: визуальный осмотр всех инструментов,
|
||||
`check/build`.
|
||||
- **C. Ctrl+V** — `onpaste` на окне → поиск image-item в `clipboardData` → тот
|
||||
же `handleFile`; не-изображения игнорируются молча. Проверка: ручной смоук со
|
||||
скриншотом из буфера.
|
||||
- **D. Drag-замена** — оверлей DropZone(overlay) при dragover на левой панели;
|
||||
drop → `handleFile` нового файла. Проверка: ручной смоук «бросить файл на
|
||||
загруженный исходник».
|
||||
- **E. Итог** — полный ручной смоук-чеклист (§5). Проверка: `test/check/build`
|
||||
зелёные.
|
||||
|
||||
## 4. Нюансы и решения
|
||||
|
||||
- **Мерцание dragleave** — классическая проблема оверлеев: используем счётчик
|
||||
`dragenter/dragleave`, оверлей скрывается только когда счётчик обнулился и
|
||||
произошёл `drop`/`dragend`.
|
||||
- **Paste при фокусе в поле параметров** — обрабатываем всегда: если в буфере
|
||||
именно изображение, пользователь очевидно хочет заменить исходник; текстовые
|
||||
вставки обработчик не трогает.
|
||||
- **Форматы буфера** — скриншоты приходят как PNG, часть приложений отдаёт
|
||||
JPEG/WebP; все проходят общую `isSupportedImage`. GIF/BMP из буфера редки, но
|
||||
тоже допустимы.
|
||||
- **Info-инструменты** — ведут себя так же: слева исходник, справа после
|
||||
загрузки появляется `InfoPanel`.
|
||||
- **Состояние ошибки** — баннер остаётся сверху страницы; неудачная замена не
|
||||
сбрасывает старый исходник.
|
||||
|
||||
## 5. Чеклист ручного смоука
|
||||
|
||||
1. Пустые обе панели → drag&drop файла слева → появился исходник и результат.
|
||||
2. Клик по пустой левой панели → выбор файла.
|
||||
3. Ctrl+V со скриншотом → исходник заменился, результат пересчитался.
|
||||
4. Drag файла на загруженную левую панель → оверлей виден, после drop — замена.
|
||||
5. Отмена перетаскивания (Esc / мимо) → оверлей скрылся, исходник не изменился.
|
||||
6. Бросок неподдерживаемого файла (например .txt) → баннер ошибки, старый
|
||||
исходник цел.
|
||||
7. Кнопка «Заменить» открывает выбор файла.
|
||||
8. png-info: работает та же схема, справа сводка.
|
||||
9. Мобильная ширина: панели в столбец, порядок исходник → результат.
|
||||
10. Все 11 инструментов отвечают на замену исходника корректным пересчётом.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- На странице инструмента нет состояния «только дропзона на всю страницу» —
|
||||
раскладка двухпанельная всегда.
|
||||
- Загрузка работает тремя способами: клик, drag&drop (включая замену), Ctrl+V.
|
||||
- Ошибочные файлы и отмена перетаскивания не портят текущий исходник.
|
||||
- `test/check/build` зелёные; чеклист §5 пройден.
|
||||
|
||||
## 7. Что сознательно не делаем (граница скоупа этого плана)
|
||||
|
||||
- Множественная загрузка / пакетная обработка — вне скоупа этого плана.
|
||||
- Камера/скриншот-инструменты захвата — только буфер обмена.
|
||||
- Прогресс-бары декодирования — файлы читаются быстро, достаточно индикации
|
||||
«Обработка…».
|
||||
@@ -0,0 +1,130 @@
|
||||
# План: главная страница как рабочая область с цепочкой
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.** Заменяет идею отдельной страницы рабочего
|
||||
> пространства.
|
||||
|
||||
## 1. Модель
|
||||
|
||||
1. **Главная страница — это рабочая область.** Без выбранного инструмента на ней
|
||||
большой поиск: начните вводить название — список отфильтровывается; выбор
|
||||
разворачивает инструмент прямо здесь, без перехода.
|
||||
2. Развернутый инструмент выглядит как привычная страница: слева исходник,
|
||||
справа результат со своей кнопкой скачивания, параметры — в отдельной
|
||||
карточке под сценой.
|
||||
3. Под результатом рядом с кнопкой скачивания — кнопка **«⛓ Chain with…»**:
|
||||
добавляет пустую секцию, внутри которой такой же поиск; выбор заполняет
|
||||
секцию следующим инструментом. Так цепочка растёт сколько угодно.
|
||||
4. Каждая заполненная секция цепи — полноценная: свои параметры, своё превью,
|
||||
своя кнопка скачивания промежуточного результата. Крестик убирает секцию;
|
||||
кнопка «✂ Remove chain» снимает всю цепь разом (без подтверждения).
|
||||
5. Чекбокс «Показать маску» живёт в карточке параметров базового инструмента и
|
||||
влияет только на его превью: в цепь всегда передаётся сам результат.
|
||||
6. Сетка-каталог инструментов переезжает на отдельную страницу `/list-tools` —
|
||||
для SEO и визуального поиска. Основная работа происходит на главной; в шапке
|
||||
появляются ссылки на обе.
|
||||
7. Прямые адреса `/tools/<id>` сохраняются: тот же развернутый инструмент для
|
||||
глубоких ссылок.
|
||||
8. Цепочка переживает перезагрузку (автосохранение в localStorage).
|
||||
Экспорт/импорт файлом отложен до появления CLI/wasm-части.
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
- Страница инструмента уже собрана как самодостаточный компонент, принимающий
|
||||
запись реестра, — он и становится встраиваемым блоком. Маршруты `/tools/[id]`
|
||||
и главная используют один и тот же компонент: первая — по адресу из URL,
|
||||
вторая — по выбору из поиска.
|
||||
- Состояние главной: выбран ли инструмент; если да — рендерится блок
|
||||
инструмента, целиком владеющий своим исходником, параметрами, результатом и
|
||||
цепочкой.
|
||||
- Смена инструмента на главной (через повторный поиск) сбрасывает блок целиком.
|
||||
- Цепочка хранится массивом шагов; у пустого звена инструмент ещё не выбран —
|
||||
такие звенья не сохраняются в localStorage и не участвуют в прогоне.
|
||||
- Исполнение — последовательная свёртка: выход каждого звена — вход следующего;
|
||||
результаты всех звеньев доступны их секциям. Общий авто-раннер даёт токен
|
||||
отмены и задержку ~300 мс.
|
||||
|
||||
## 3. Переиспользование и доработки существующего
|
||||
|
||||
Готовое берётся как есть: `SourceCard`, `Preview`, `ParamForm`,
|
||||
`DownloadButton`, `EmptyState`, модуль пайплайна, авто-раннер, `PNG_OUTPUT`.
|
||||
|
||||
Доработки:
|
||||
|
||||
- **Блок страницы инструмента** — расширяется секцией цепочки: список
|
||||
заполненных звеньев, каждое со своими параметрами, превью и скачиванием, плюс
|
||||
пустые звенья с поиском. Кнопки «⛓» и «✂ Remove chain» — рядом со скачиванием
|
||||
базового результата.
|
||||
- **`ParamsCard`** — опциональный блок «Показать маску» (флаг preview у
|
||||
инструмента + двусторонний флаг).
|
||||
- **Карточка результата** — упрощается: получает готовую картинку, маска уходит
|
||||
в карточку параметров.
|
||||
- **Существующая главная-каталог** — переезжает на маршрут `/list-tools` почти
|
||||
без изменений.
|
||||
|
||||
Удаляется: черновой маршрут рабочего пространства и его компоненты
|
||||
списка/селектора — вместо них новые, под вертикальную модель.
|
||||
|
||||
## 4. Новые компоненты
|
||||
|
||||
- **`ToolSearch.svelte`** — поиск с нечётким совпадением: поле ввода,
|
||||
отфильтрованный список (регистронезависимая подпоследовательность по названию
|
||||
и описанию, приоритет совпадению с начала), клавиатура ↑↓ Enter Esc; два
|
||||
размера — герой на главной и компактный внутри пустого звена. Предлагает
|
||||
только инструменты-трансформеры.
|
||||
- **`ChainStep.svelte`** — секция одного звена: пустое звено показывает
|
||||
компактный поиск; заполненное — заголовок с номером и крестиком, форму
|
||||
параметров, превью результата, кнопку скачивания.
|
||||
- **`ChainBar.svelte`** — строка под результатом базового инструмента: «⛓ Chain
|
||||
with…» и «✂ Remove chain» (видна только при непустой цепи).
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Поиск и главная** — `ToolSearch`, герой поиска на главной, встраивание
|
||||
блока инструмента, перенос каталога на `/list-tools`, ссылки в шапке.
|
||||
Проверка: `check/build`, визуально.
|
||||
- **B. Цепочка** — состояние цепи в блоке инструмента, пустые звенья с поиском,
|
||||
прогон, промежуточные превью и скачивания, ошибки звеньев. Проверка: смоук §8
|
||||
пп. 1–6.
|
||||
- **C. Маска и упрощение** — блок маски в `ParamsCard`, упрощение карточки
|
||||
результата. Проверка: смоук §8 п. 7.
|
||||
- **D. Хранение и итог** — автосохранение и восстановление цепочки, удаление
|
||||
чернового маршрута рабочего пространства, полный регресс каталога,
|
||||
`test/check/build`.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. На главной — большое поле поиска; ввод фильтрует список по мере набора; Enter
|
||||
выбирает подсвеченный.
|
||||
2. Выбранный инструмент разворачивается на той же странице: исходник, параметры,
|
||||
результат.
|
||||
3. «⛓ Chain with…» добавляет пустую секцию с компактным поиском; выбор заполняет
|
||||
её.
|
||||
4. У каждой секции своё превью и своё скачивание промежуточного результата.
|
||||
5. Крестик убирает секцию; «✂ Remove chain» снимает все звенья разом.
|
||||
6. Ошибка звена — баннер «Шаг N (имя): …»; последние успешные результаты секций
|
||||
не исчезают.
|
||||
7. «Показать маску» переключает превью базового инструмента; в цепь идёт
|
||||
результат, скачивание базового отдаёт результат, не маску.
|
||||
8. Перезагрузка восстанавливает цепочку заполненных звеньев; пустые звенья при
|
||||
этом исчезают.
|
||||
9. Быстрые правки подряд — один финальный пересчёт всей вертикали.
|
||||
10. `/list-tools` открывает старый каталог по категориям; прямые адреса
|
||||
`/tools/<id>` работают.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Работа начинается с главной: поиск → инструмент → цепочка звеньями, каждый со
|
||||
своим превью и скачиванием.
|
||||
- Отдельной страницы рабочего пространства нет; каталог доступен на
|
||||
`/list-tools`; прямые ссылки инструментов работают.
|
||||
- Пустые звенья не попадают в сохранение и не ломают прогон.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Ветвления и прицепывание от середины цепи — новое звено всегда добавляется в
|
||||
конец.
|
||||
- Пипетка и маски внутри звеньев цепи — они остаются фичей базового инструмента.
|
||||
- Экспорт и импорт цепочки файлом — отложено до появления CLI/wasm-части.
|
||||
- Несколько параллельных инструментов на главной одновременно — один активный
|
||||
блок за раз.
|
||||
@@ -0,0 +1,239 @@
|
||||
# Backlog
|
||||
|
||||
## lint и прочие правила
|
||||
|
||||
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
||||
`docs/plan-preview-i18n.md`
|
||||
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
||||
что за `SonarLint` или написать кастомный линтер)
|
||||
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
||||
модуль
|
||||
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
||||
оно должно быть у всех или объяснено, почему этот инструмент такой
|
||||
особенный и заслуживает комментарий
|
||||
|
||||
## Мелочи всякие
|
||||
|
||||
- [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют
|
||||
количество инструментов, точное совпадение текстов, поиск по
|
||||
`img[alt="Result image"]`, svelte классам и тому подобные
|
||||
- [ ] можно ли в тестах получать 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 с
|
||||
паспортом, семейства с кириллицей)
|
||||
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
||||
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
|
||||
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
|
||||
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность
|
||||
(range?)
|
||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
||||
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
||||
значения
|
||||
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
|
||||
изменено
|
||||
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
||||
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
||||
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
||||
- [ ] обновить страницу kit или сделать storybook
|
||||
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
|
||||
удаление, добавление инструментов. переход один инструмент - pipeline без
|
||||
перезагрузки
|
||||
- [ ] Слаги инструментов (`*-png`) — надо переименовть, они работают с любыми
|
||||
изображениями. Надо сделать отдельный интерфейс маппинга инструмент ->
|
||||
ссылка
|
||||
|
||||
## Переезд
|
||||
|
||||
- [ ] добавить i18n в preview
|
||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||
`tPlural()` и формат `PluralForms`, детали в §Фаза 9
|
||||
`docs/plan-preview-i18n.md`
|
||||
|
||||
## SEO/GEO
|
||||
|
||||
Общий план: `docs/plan-seo.md` (S0–S4). C17 выполнен; старт возможен — пока
|
||||
только domain-free часть S1.
|
||||
|
||||
- [ ] переименование бренда + домен: чек-лист в локальном плане
|
||||
`docs/plan-domain.local.md` (вне git, имя домена в репо не публикуется;
|
||||
план-seo §6, R1/R2)
|
||||
- [ ] download с селектором формата PNG/JPG/WebP/BMP для всех инструментов (райз
|
||||
№7 → S1f)
|
||||
- [ ] форматные страницы (`crop-jpeg`, …) — только по вордстату с уникальным
|
||||
контентом; редиректы только для опечаток; клонов с canonical не делать
|
||||
(план §5)
|
||||
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
|
||||
- [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e)
|
||||
- [ ] контент страницы инструмента: seo-блок в словарях, H1/how-to/FAQ/related
|
||||
(S2)
|
||||
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
|
||||
- [ ] GSC + приватная аналитика (Plausible/Umami/CF), дистрибуция (S4)
|
||||
|
||||
## Платформа (API/PWA)
|
||||
|
||||
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
|
||||
(фазы 3–8) — здесь только новые треки.
|
||||
|
||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1;
|
||||
C19 отложен — не блокер)
|
||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||
(после wasm-фаз 5–7)
|
||||
|
||||
## Баги preview
|
||||
|
||||
Зарегистрированы при написании e2e (`web/e2e/`, `pnpm --dir web test:e2e`) и
|
||||
ручного чек-листа — см. `docs/checklist-manual-testing.md` (раздел G). Каждый
|
||||
зафиксирован как `test.fixme`. После фикса бага: убрать соответствующий
|
||||
`test.fixme` и отметить пункт G в чек-листе как пройденный.
|
||||
|
||||
1. **Генераторы без кнопки «Generate»** — все 21 инструмент категории GENERATE
|
||||
открываются, но в UI нет кнопки Generate и полей схемы (только RU/EN/Reset);
|
||||
результат через UI недостижим. В `SchemaToolView.svelte` `isGenerator`
|
||||
определяется как `tool.generate && !tool.run`, кнопка рисуется только если
|
||||
`ongenerate` прокинут, а для генераторов с дефолтным `input: "file"` — нет.
|
||||
Ожидание: поля схемы + кнопка «Generate», результат-картинка по дефолтам.
|
||||
Fixme: `web/e2e/generators.spec.ts`. Кнопки и не должно быть, все процессы
|
||||
должны запускаться автоматически
|
||||
2. **resize-png с дефолтами падает** — схема по умолчанию `size: 0×0` +
|
||||
`keepAspect: true` → при загрузке ЛЮБОГО PNG сразу `errors.resizeSize`
|
||||
(alert), результата нет. `registry-new/geometry.ts`. Ожидание: осмысленный
|
||||
дефолт-размер (например, исходный) либо кнопка, а не авто-ошибка.
|
||||
3. **crop-png с дефолтами падает** — аналогично: `width: 0, height: 0` → сразу
|
||||
`errors.cropSize`. `registry-new/geometry.ts`. Ожидание: базовый кроп по
|
||||
умолчанию либо внятное предзаполнение.
|
||||
4. **Сырые ключи ошибок в UI** — alert на tool-странице показывает сырой ключ
|
||||
i18n (`errors.resizeSize`), а не сообщение из `en.ts` / `ru.ts` (там
|
||||
`resizeSize: "Width and/or height must be positive"`). Проверяется на
|
||||
resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке.
|
||||
Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»).
|
||||
|
||||
## Идеи
|
||||
|
||||
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
|
||||
зафиксирован при открытии страницы; хочется заменить его на другой без
|
||||
разборки цепочки: шаги и их параметры сохраняются, вход перечитывается.
|
||||
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
|
||||
file).
|
||||
|
||||
2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
|
||||
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
|
||||
помнить в workspace-pipeline.
|
||||
|
||||
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
|
||||
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
|
||||
Формат экспортного JSON расширить опциональным полем имени, старые файлы
|
||||
читаются как безымянные.
|
||||
|
||||
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
|
||||
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
|
||||
chain по каждому файлу, сборка результата обратно в zip для скачивания.
|
||||
Зависит от п.4 (выбор цепочки) и от экспортного формата pipeline JSON;
|
||||
прогресс и ошибки — пофайлово.
|
||||
|
||||
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
|
||||
карточках каталога, результатах поиска и в шапке страницы инструмента; список
|
||||
id в localStorage. Избранное показывается отдельной секцией сверху каталога и
|
||||
поднимается в выдаче поиска (бонус к popularity при скоринге).
|
||||
|
||||
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
|
||||
— оценка M/L. Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др.
|
||||
форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого
|
||||
— бесшовное внедрение выбора формата/качества прямо в кнопку/диалог
|
||||
**Download**: юзер скачивает результат в нужном формате (mime/ext/quality), а
|
||||
pipeline при этом не усложняется лишним звеном. **Сделано (2026-09):**
|
||||
механическая часть реализована в preview — `ToolEntry.output`
|
||||
(`OutputFormat`: mime/ext/qualityParamId) в `registry-new/types.ts`;
|
||||
`SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены;
|
||||
`png-to-bmp` переведён сюда же). **Осталось (UX-райз «Download»):**
|
||||
- выбор формата/качества прямо в кнопке/диалоге Download для любого
|
||||
результата (не отдельными конвертерами);
|
||||
- экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png`
|
||||
(целевой KB, бинарный поиск по k из квантования). Проработать вместе с
|
||||
достройкой download-флоу нового UI.
|
||||
|
||||
7. **`watermark-image-png` (водяной знак картинкой)** — оценка M. Отложен из
|
||||
миграции (`archive/plan-migrate-remaining.md`). Нужна 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 выделения области на превью. Из
|
||||
`archive/plan-gap-waves.md` («вне очереди»).
|
||||
|
||||
10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
|
||||
— механизм «результат = набор файлов» (1 → many) реализован: `FileResult` +
|
||||
`result: "files"` + скачивание zip-архивом + грид-превью частей. Первый
|
||||
потребитель — `split-into-parts-png`. Остальные подключаются по мере нужды.
|
||||
|
||||
11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
|
||||
решение о формате. Из `archive/plan-gap-waves.md`.
|
||||
|
||||
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
|
||||
`archive/plan-gap-waves.md`.
|
||||
|
||||
13. **Контраст текста на акцентных кнопках — пересмотреть после готовности
|
||||
сайта**. Текст `--color-background` на фоне `--color-main`/`--color-accent`
|
||||
(тон 48) даёт контраст ~4.1–4.3 (light) — ниже AA 4.5 для обычного текста;
|
||||
`palette.test.ts` держит для кнопок порог 4.0 (AA large-text) осознанно, как
|
||||
компромисс текущего дизайна. После полного завершения редизайна пересмотреть
|
||||
тона (например поднять tone кнопок до ~55) и вернуть порог 4.5. Также
|
||||
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
|
||||
light-danger тон 48 = 4.25 на границе.
|
||||
|
||||
14. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
|
||||
реализовать процесс показа маски результата. **Контекст:** в старом
|
||||
контракте поле `preview` у `remove-color`/`remove-background` показывало
|
||||
быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что
|
||||
будет вырезано. При миграции на единый `run(ctx)` поле убрано (шаг 8,
|
||||
`registry-new/alpha.ts`) — новый executor и schema-превью зовут `run()`
|
||||
напрямую, маска пока не нужна. **Идея 1:** дать каждому `run` возможность
|
||||
вернуть (или инструмент — объявить) `runMask` — ч/б изображение маски
|
||||
изменений либо иной подход (отдельное поле `mask` + функция маски, второй
|
||||
результат, overlay). Оценка — на усмотрение при проработке (S/M). Пока живём
|
||||
без неё. **Идея 2:** каждый `run` возвращает два изображения - для
|
||||
отображения (показывает маску или сам результат), и для передачи в следующий
|
||||
инструмент (всегда результат. Оценка — на усмотрение при проработке (S/M).
|
||||
Пока живём без неё.
|
||||
|
||||
15. **Процесс работы с инструментами 1 → many и many → 1** — абстракция
|
||||
результата «набор файлов» оформлена и работает: тип `FileResult`,
|
||||
`result: "files"` в реестре, сериализация через executor, скачивание
|
||||
zip-архивом, грид-превью частей. Реализовано на первом потребителе
|
||||
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
|
||||
**1 → many:** подключение остальных потребителей (gif-to-frames,
|
||||
separate-colors) при необходимости; формат/качество файлов внутри zip —
|
||||
отложено до UX-райза «Download» (п.7). **many → 1** (вход = архив/несколько
|
||||
картинок → единый результат): не реализовано. Зависит от batch-обработки
|
||||
архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна отдельная
|
||||
проработка входа (распаковка zip в браузере, порядок файлов, поведение в
|
||||
пайплайне — такие инструменты терминальны, как и 1 → many).
|
||||
@@ -0,0 +1,104 @@
|
||||
# Чек-лист ручного тестирования preview
|
||||
|
||||
> Вспомогательный документ к `docs/archive/plan-testing-preview.md` и e2e-тестам
|
||||
> (`web/e2e/`, `pnpm --dir web test:e2e`). Автоматизированная часть закрывает
|
||||
> «нет падений/ошибок»; здесь — то, что руками, и что 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 (индексированные цвета) — конвертируется/анализируется.
|
||||
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
||||
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
||||
pipeline.spec.ts).
|
||||
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
|
||||
размер, прозрачность).
|
||||
|
||||
### Файлы (`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 пропускается, ошибок нет
|
||||
(кейс из плана п.14).
|
||||
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
||||
|
||||
## C. Буфер обмена и drag-n-drop
|
||||
|
||||
- [ ] В реальном браузере кнопка copy на текстовом результате (png-to-base64 и
|
||||
т.п.) кладёт текст в буфер (в Playwright не тестируем из-за permissions).
|
||||
- [ ] Drag-n-drop файла на dropzone страницы инструмента (не только файл-диалог)
|
||||
— UX-ощущения и корректность.
|
||||
|
||||
## D. Производительность и отзывчивость
|
||||
|
||||
- [ ] Долгие авто-рераны на больших картинках (jpeg-artifacts, dithering,
|
||||
quantize на 4К) — UI не фризит, канселяция/дебаунс срабатывает.
|
||||
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
|
||||
залипаний, панели не прыгают.
|
||||
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
|
||||
(утечек blob-URL/bitmap быть не должно).
|
||||
|
||||
## E. Визуальный слой (доделываем по ходу)
|
||||
|
||||
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
|
||||
desktop) на всех 4 маршрутах.
|
||||
- [ ] Тема light/dark: контраст вердиктов (Yes/No), статус «LIVE PREVIEW»,
|
||||
мета-инфо бликов не даёт.
|
||||
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
|
||||
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
|
||||
|
||||
## F. Субъективная корректность
|
||||
|
||||
- [ ] dithering / two-colors / quantize — результат «по ощущениям» соответствует
|
||||
описанию.
|
||||
- [ ] watermark-tile / add-text / date-stamp на кириллице — рендер текста
|
||||
корректный (не «кракозябры»).
|
||||
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
||||
|
||||
## Как долго
|
||||
|
||||
- Полный прогон: ~40 минут (все разделы, включая мегапиксели и 4К) в двух
|
||||
браузерах.
|
||||
- Быстрый смоук (< 15 мин): A (кроме мегапикселей), B (выборочно), C, F
|
||||
выборочно, G обязательно.
|
||||
@@ -0,0 +1,388 @@
|
||||
# План: исправления по аудиту UX/UI
|
||||
|
||||
> Статус: **draft** — на ревью.
|
||||
>
|
||||
> Источник: `docs/tools-audit.md`. Этот документ разбивает аудит на конкретные
|
||||
> шаги, группирует по типу работы и фиксирует решения.
|
||||
|
||||
## 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 коммит)
|
||||
|
||||
### Шаг 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`
|
||||
|
||||
### Шаг A2. Seed → input + randomize
|
||||
|
||||
Для `field.number()` с `kind: "seed"` (или новый подтип `field.seed()`) —
|
||||
рендерить поле ввода + кнопку «Random» вместо range slider.
|
||||
|
||||
Решение: новый вид `field.seed()` (наследует от number, но рендерится иначе).
|
||||
Или проще: опциональное поле `variant?: "seed"` в `NumberSpec` — тогда
|
||||
`RangeControl` покажет input + кнопку.
|
||||
|
||||
Решение: **`variant: "seed"` в `NumberSpec`** — меньше новых типов.
|
||||
|
||||
Файлы:
|
||||
|
||||
- `web/src/lib/registry-schema.ts` — `NumberSpec.variant?: "seed"`
|
||||
- `web/src/lib/components/fields/schema/RangeControl.svelte` — условный рендер
|
||||
- `web/src/lib/registry/filters.ts` — `randomizePixels`, `addNoise`: поле seed →
|
||||
`{ ...field.number(...), spec: { ...spec, variant: "seed" } }` (или
|
||||
пересоздать через `field.seed()`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Волна B — select → buttons (по файлам)
|
||||
|
||||
Каждый шаг — один коммит, < 500 строк. Меняем `field.select()` →
|
||||
`field.buttons()` в перечисленных ниже схемах.
|
||||
|
||||
### B1. geometry.ts (8 инструментов)
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| ----------------------- | -------- | ----------------------------------------- |
|
||||
| rotate-png | angle | 90° / 180° / 270° |
|
||||
| flip-png | axis | horizontal / vertical |
|
||||
| swap-orientation-png | target | portrait / landscape |
|
||||
| symmetric-copy-png | axis | vertical / horizontal |
|
||||
| symmetric-copy-png | keepSide | left / right / top / bottom |
|
||||
| change-aspect-ratio-png | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 |
|
||||
| change-aspect-ratio-png | mode | crop / pad |
|
||||
|
||||
**Исключение:** `change-canvas-size-png` (anchor, 9 опций) — оставить
|
||||
`field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже
|
||||
отдельный компонент `PositionControl`).
|
||||
|
||||
### B2. color.ts (4 инструмента)
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| ------------------------ | --------- | ------------------------------------ |
|
||||
| dithering-png | pattern | floyd-steinberg / bayer |
|
||||
| extract-channel-png | channel | red / green / blue |
|
||||
| swap-channels-png | pair | r-g / r-b / g-b |
|
||||
| decrease-color-count-png | maxColors | 2 / 4 / 8 / 16 / 32 / 64 / 128 / 256 |
|
||||
|
||||
**Исключение:** channel spaces (hsl/hsv/... component) — 3-6 опций, но это
|
||||
динамические инструменты, оставить `select`.
|
||||
|
||||
### B3. analyze.ts (5 инструментов)
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| ------------------------- | ---- | ------------------ |
|
||||
| show-transparent-png | mode | binary / highlight |
|
||||
| show-grayscale-pixels-png | mode | (тот же) |
|
||||
| show-color-pixels-png | mode | (тот же) |
|
||||
| light-pixel-mask-png | mode | (тот же) |
|
||||
| dark-pixel-mask-png | mode | (тот же) |
|
||||
|
||||
Все используют общий `maskBaseFields` — правка в одном месте.
|
||||
|
||||
### B4. filters.ts (1 инструмент)
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| ------------- | ---- | ------------ |
|
||||
| add-noise-png | mode | mono / color |
|
||||
|
||||
### B5. generate.ts (12 инструментов)
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| ------------------ | ------------ | --------------------- |
|
||||
| color-spectrum-png | direction | horizontal / vertical |
|
||||
| step-colors-png | layout | grid / strip |
|
||||
| complementary-png | layout | grid / strip |
|
||||
| triadic-png | layout | grid / strip |
|
||||
| tetradic-png | layout | grid / strip |
|
||||
| analogous-png | layout | grid / strip |
|
||||
| monochromatic-png | layout | grid / strip |
|
||||
| shades-png | layout | grid / strip |
|
||||
| sort-colors-png | layout | grid / strip |
|
||||
| mix-colors-png | (нет select) | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. Волна C — обязательные фиксы (баги + дефолты)
|
||||
|
||||
### C1. resize-png: дефолт 0×0 → осмысленный
|
||||
|
||||
`geometry.ts`: `width: 0, height: 0` → `width: 512, height: 512` (или
|
||||
`width: imgWidth, height: imgHeight` — но дефолт в схеме не может знать размер
|
||||
изображения). **Решение:** дефолт `512×512`.
|
||||
|
||||
### C2. symmetric-copy-png: "keep side" не работает
|
||||
|
||||
Баг в `run()`: `keepSide` не учитывает `axis`. При `axis: "vertical"` (удвоение
|
||||
ширины) работают только `left`/`right`, `top`/`bottom` не имеют смысла. Нужно:
|
||||
либо фильтровать опции `keepSide` в зависимости от `axis` (reactive schema),
|
||||
либо нормализовать в `run()`.
|
||||
|
||||
**Решение:** reactive — при смене `axis` сбрасывать `keepSide` на допустимое
|
||||
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
|
||||
{top, bottom} → заменить на `left`.
|
||||
|
||||
### C3. Механизм проброса размеров картинки в схему
|
||||
|
||||
**Проблема.** Поля, значения которых зависят от размеров исходника (crop x/y,
|
||||
resize, offsets), объявлены в статической схеме (`-100000…100000`), а реальная
|
||||
картинка обычно 800–4000px. Диапазон надо считать от фактического размера
|
||||
изображения, а не от потолков.
|
||||
|
||||
**Что нужно спроектировать и завести:**
|
||||
|
||||
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
||||
→ `PixelImage` (width/height). Сейчас он никуда не пробрасывается — надо
|
||||
прокинуть в `SchemaFields` и дальше в контролы, например через context или
|
||||
проп `sourceSize`.
|
||||
|
||||
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 из текущего
|
||||
размера источника; при смене картинки диапазон пересчитывается, текущее
|
||||
значение пере-клампится. Дефолт и `sanitize` остаются на статической основе
|
||||
(или тоже учат bound) — решить при проектировании.
|
||||
|
||||
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
|
||||
(offsetX/offsetY), resize (max → maxSide).
|
||||
|
||||
Это отдельный шаг плана: **завести механизм**, спроектировать на ревью интерфейс
|
||||
спека (`bound` vs `range: (size) => [min, max]`), затем применить к crop и
|
||||
остальным полям размерности. Пока механизма нет — поля crop остаются как есть.
|
||||
|
||||
### C4. verify-is-png: неверное название
|
||||
|
||||
Проверяет текст, не PNG-файл. Переименовать:
|
||||
|
||||
- `id` → `verify-is-png-data` (или оставить для обратной совместимости)
|
||||
- `title` → "Verify PNG data"
|
||||
- `description` → уточнить
|
||||
|
||||
**Решение:** поменять title и description, id не трогать (url-dependent).
|
||||
|
||||
---
|
||||
|
||||
## 4. Волна D — обязательные фиксы (новые параметры)
|
||||
|
||||
### D1. remove-alpha-channel-png: выбор цвета фона
|
||||
|
||||
Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`.
|
||||
|
||||
Файл: `alpha.ts`, схема `removeAlphaChannelSchema`.
|
||||
|
||||
### D2. extract-alpha-mask-png: галочка «инвертировать»
|
||||
|
||||
Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В
|
||||
`run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`.
|
||||
|
||||
Файл: `alpha.ts`.
|
||||
|
||||
### D3. change-canvas-size-png: position9 вместо select
|
||||
|
||||
`anchor` сейчас `field.select()` с 9 опциями → `field.position9()`.
|
||||
|
||||
Файл: `geometry.ts`. Уже есть `PositionControl`.
|
||||
|
||||
### D4. Generators: добавить height
|
||||
|
||||
Инструменты без `size`/`height`:
|
||||
|
||||
- blend-two-png — добавить
|
||||
`height: field.slider({ min: 1, max: 1024, default: 512 })`
|
||||
- step-colors-png — аналогично
|
||||
- complementary, triadic, tetradic, analogous, monochromatic, shades,
|
||||
sort-colors — все используют `paletteBaseSchema` (общий объект), добавить
|
||||
`height` туда
|
||||
- mix-colors-png — добавить height
|
||||
|
||||
Файл: `generate.ts`.
|
||||
|
||||
### D5. add-border-png: прозрачность цвета
|
||||
|
||||
Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть
|
||||
(прозрачность не поддерживается нативным color picker). Отметить в аудите как
|
||||
blocked.
|
||||
|
||||
---
|
||||
|
||||
## 5. Волна E — полировка (некритично)
|
||||
|
||||
### E1. Плейсхолдер "нет параметров"
|
||||
|
||||
Инструменты с пустой схемой (`EmptyParams`) выглядят странно. Добавить
|
||||
`EmptyState` или подсказку "No parameters — click Run".
|
||||
|
||||
Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст.
|
||||
|
||||
### E2. quantize-png: пресеты
|
||||
|
||||
`field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под
|
||||
слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера.
|
||||
|
||||
### E3. trim-empty-space-png: 0-254 → проценты
|
||||
|
||||
Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог
|
||||
шага A1 — `variant: "alpha-threshold"` в NumberSpec).
|
||||
|
||||
### E4. add-text-png: plate offset
|
||||
|
||||
Plate снизу имеет большой отступ — добавить галочку "compact plate" или
|
||||
уменьшить дефолтный padding.
|
||||
|
||||
### E5. emoji-to-png: выбор эмодзи
|
||||
|
||||
Поле `text` → добавить группу часто используемых эмодзи как кнопки- пресеты над
|
||||
полем ввода.
|
||||
|
||||
---
|
||||
|
||||
## 6. Верификация
|
||||
|
||||
После каждой волны:
|
||||
|
||||
1. `pnpm --dir web build` — без ошибок
|
||||
2. `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` — без ошибок
|
||||
3. `pnpm --dir web lint` — без ошибок (кроме ожидаемых долгов)
|
||||
4. Ручная проверка в браузере: параметры рендерятся, инструменты работают
|
||||
|
||||
---
|
||||
|
||||
## 7. Не покрыто / требует решения
|
||||
|
||||
Эти пункты из аудита не попали в волны A–E. Требуют либо дополнительного
|
||||
исследования, либо нормативного решения, либо отложены.
|
||||
|
||||
### 7.1 Требуют normative решения
|
||||
|
||||
- **Pixel ↔ Percent** (circle-mask, square-mask, star-mask, wavy-mask — %→px,
|
||||
round-corners — px→%): нужен компонент переключателя единиц в слайдере.
|
||||
Отложен до волны E, т.к. требует правки `RangeControl` + всех схем.
|
||||
- **Smoothing checkbox** для масок (circle, square, star, wavy, round-corners,
|
||||
invert-alpha): нужен `field.checkbox("smoothing")` + правка core (shapes.ts,
|
||||
alpha). Отложен — требует визуального тестирования.
|
||||
- **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует
|
||||
normative решения по UI.
|
||||
- **Remove-background**: выбор точки удаления — либо параметр, либо отдельный
|
||||
инструмент.
|
||||
- **Precision/quality**: select в resize (bilinear/bicubic/nearest) — нужен ли
|
||||
выбор алгоритма?
|
||||
- **Placeholders**: "нет параметров" — какой текст/компонент показывать.
|
||||
|
||||
### 7.2 Операционные (тест-кейсы, описания)
|
||||
|
||||
- find-contour-png: расширять на толщину линии (требует правки core)
|
||||
- make-thicker-png: расширять картинку на толщину линии
|
||||
- make-thinner-png: уменьшать от краев, fix выступа
|
||||
- feather-edges-png: расширять картинку на толщину линии
|
||||
- clean-edges-png: найти тест-кейс, возможно увеличить максимум
|
||||
- despeckle-alpha-png: найти более заметный тест-кейс
|
||||
- close-holes-png: проверить поведение на больших полупрозрачных областях
|
||||
- auto-contrast-png: найти тестовый кейс
|
||||
- sharpen-png: добавить тест-кейс
|
||||
- center-by-alpha-png: найти тест-кейс
|
||||
- png-is-grayscale: улучшить видимость результата
|
||||
|
||||
### 7.3 Дизайн-вопросы
|
||||
|
||||
- add-padding-png vs add-border-png: разница неочевидна, нужен review
|
||||
- change-aspect-ratio-png: не видно что меняется — рамка результата
|
||||
- watermark-tile-png: заполняет не полностью
|
||||
- sepia-png: нужна ли настройка силы эффекта?
|
||||
- posterize-png: настройка для каждого канала отдельно?
|
||||
- pixelate-png: разные алгоритмы?
|
||||
- swap-channels-png: обдумать flow через более простые инструменты
|
||||
|
||||
### 7.4 Аудит полей (компоненты)
|
||||
|
||||
- Range slider: поле ввода + кнопки +/- + кнопка сброса
|
||||
- Color picker: пипетка с картинки, прозрачность
|
||||
- Width/height с keep-aspect-ratio: единый компонент
|
||||
- Position offset: отдельные слайдеры x/y (уже сделано в `OffsetControl`)
|
||||
@@ -0,0 +1,132 @@
|
||||
# План: платформа — общее ядро, API и PWA
|
||||
|
||||
> **Статус (2026-09-09):** план-набросок. WASM-ядро и CLI уже описаны в
|
||||
> `docs/roadmap.md` (фазы 3–8) и здесь не дублируются — этот план добавляет то,
|
||||
> чего в roadmap нет: **API** (фаза 9, возможная монетизация) и **PWA** (фаза
|
||||
> 10, оффлайн + установка). Оба стартуют после wasm-фаз; PWA не ждёт API и может
|
||||
> идти раньше.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
- **Одно ядро — три потребителя.** Rust-крейт `core/` (roadmap фаза 3) даёт:
|
||||
wasm-пакет для сайта (фаза 5), нативный CLI (фаза 6), серверный слой API (фаза
|
||||
9). Единый контракт — JSON-пайплайн `{tool, params}` (заложен в roadmap §0);
|
||||
эталонные файлы и harness (фазы 2, 4, 7) гарантируют одинаковый результат во
|
||||
всех рантаймах.
|
||||
- **API — нативный Rust на сервере, не wasm.** В браузере wasm нужен ради
|
||||
доставки и песочницы; на сервере крейт линкуется напрямую — быстрее и проще.
|
||||
Исключение — edge-воркеры (см. спайк ниже): там исполняется тот же
|
||||
wasm-артефакт фазы 5.
|
||||
- **PWA не трогает SEO-схему.** Сайт остаётся полностью prerendered-статикой;
|
||||
service worker добавляет оффлайн-слой поверх и не отвечает за HTML ботовам.
|
||||
- **Монетизация не ломает позиционирование.** Браузерные инструменты — бесплатны
|
||||
и без трекинга; API — отдельный продукт для автоматизации/бизнеса. Файлы в API
|
||||
обрабатываются в памяти: без хранения и логирования содержимого — продолжение
|
||||
«privacy»-истории.
|
||||
|
||||
## 1. Фаза 9 — API (после фаз 5–7)
|
||||
|
||||
- [ ] **[A1]** Спайк сервера: (а) axum/actix + нативный `core/` на VPS против
|
||||
(б) wasm-артефакт фазы 5 на edge (CF Workers). Сравнить: стоимость, лимиты
|
||||
памяти/времени на больших картинках, сложность деплоя и rate-limiting.
|
||||
Решение зафиксировать здесь.
|
||||
- [ ] **[A2]** Контракт: `POST /v1/process` — multipart (image) + JSON-пайплайн
|
||||
→ изображение/архив; лимиты размера, коды ошибок; OpenAPI-спека.
|
||||
- [ ] **[A3]** Ключи и лимиты: Bearer-ключи, free tier (N запросов/мес), rate
|
||||
limiting по ключу; самостоятельная выдача/ротация ключей в кабинете —
|
||||
позже.
|
||||
- [ ] **[A4]** Биллинг: Stripe/LemonSqueezy (merchant of record), 2–3 тира по
|
||||
запросам/размеру файла. Только после стабильного free tier.
|
||||
- [ ] **[A5]** Документация как лендинг: `/api` — индексируемая страница
|
||||
(туториал, примеры curl/JS), OpenAPI, ссылка из `llms.txt`. Dev-аудитория
|
||||
— источник бэклинков (план-seo S4c).
|
||||
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
||||
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
||||
|
||||
## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер)
|
||||
|
||||
- [ ] **[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 (не дублируются).
|
||||
- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19
|
||||
отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7.
|
||||
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
||||
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
||||
GEO-текстов (`llms.txt`, описания категорий).
|
||||
|
||||
## 4. Шрифты: лицензирование и DOM-free рендер текста
|
||||
|
||||
Текст — единственный класс операций, который roadmap (фаза 8) держит в TS из-за
|
||||
canvas. Для CLI/API его всё равно придётся рендерить в Rust, поэтому решение:
|
||||
**перевести text-инструменты в ядро** (в браузере canvas-рендер остаётся
|
||||
TS-фолбэком для A/B). Два вопроса — легальность шрифтов в дистрибутиве и рендер
|
||||
без DOM.
|
||||
|
||||
### Лицензирование (решить до выбора шрифтов, 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, источник, версия, хеш; CI-проверка «у каждого файла шрифта есть
|
||||
паспорт». Браузер грузит шрифты из бандла, не из ОС и не с
|
||||
`fonts.googleapis.com`-CDN (сторонний запрос = утечка IP пользователя,
|
||||
противоречит «no tracking»), — одинаковый результат у всех пользователей и
|
||||
во всех рантаймах.
|
||||
- [ ] **[F4]** Кириллица: приоритет семействам с полным покрытием — Liberation
|
||||
Sans, PT Sans/PT Serif, Noto Sans, Inter, Roboto (OFL/Apache), DejaVu Sans
|
||||
(пермиссивная лицензия Bitstream Vera: редистрибуция и коммерческое
|
||||
использование разрешены) — закрывает и backlog-вопрос про «только с
|
||||
кириллицей».
|
||||
- **Стартовый набор** (все OFL/Apache, с кириллицей; состав не догма —
|
||||
расширяется по запросам, каждый проходит паспорт F3):
|
||||
- базовый sans: **Inter** (дефолт add-text), **PT Sans** / **Golos Text**
|
||||
(профессиональная русская типографика), **Noto Sans** (фолбэк покрытия);
|
||||
- serif: **PT Serif**, **Lora**, **Playfair Display** (display);
|
||||
- mono: **JetBrains Mono** (дефолт), **Roboto Mono**;
|
||||
- display/акцент: **Oswald** (узкий), **Rubik** (округлый), **Comfortaa**,
|
||||
**Caveat** (рукописный), **Amatic SC** (маркерный);
|
||||
- кириллицу проверить по файлу при добавлении: Bebas Neue, Pacifico
|
||||
(добавлялась поздними версиями). Источник файлов — только Google Fonts /
|
||||
Fontsource (версионированные пакеты под паспорт F3); случайные «free
|
||||
font»-сайты — нет. Вес: woff2-сабсеты latin+cyr лениво в браузере, TTF в CLI
|
||||
(~2–4 МБ суммарно), в wasm не зашивать — грузить из статики по требованию.
|
||||
|
||||
### Рендер без DOM (спайк — фаза 5–6, реализация — фаза 8)
|
||||
|
||||
- [ ] **[F5]** Спайк стека: растеризация глифов (`ab_glyph`/`rusttype`) +
|
||||
шейпинг (`swash`/`harfrust`; для латиницы/кириллицы сложный шейпинг не
|
||||
критичен, сложные письменности — осознанно не в первой версии) → отрисовка
|
||||
в наш `Image`. Эталон — canvas-рендер TS (perceptual-класс по roadmap §3):
|
||||
совпадать обязаны позиция/выравнивание/переносы, пиксели будут отличаться
|
||||
хинтингом/антиалиасингом.
|
||||
- [ ] **[F6]** Единый текстовый API для TS-core и Rust-core: параметры текста —
|
||||
из JSON-схемы инструмента, шрифт — **id из реестра шрифтов** (не системное
|
||||
имя), метрики базовой линии считать в ядре, а не через canvas
|
||||
`measureText` (иначе TS и Rust разъедутся).
|
||||
- Гейт: один и тот же текстовый шаг пайплайна даёт в браузере (wasm), CLI и API
|
||||
визуально идентичный результат; в дистрибутиве нет ни одного файла шрифта без
|
||||
паспорта лицензии.
|
||||
@@ -0,0 +1,330 @@
|
||||
# План: i18n в preview (новый UI)
|
||||
|
||||
> Статус: готов к реализации (вопросы §5 решены). Соответствует беклог-задаче
|
||||
> «добавить i18n в preview» (`docs/backlog.md`). Реализация не начата.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
|
||||
`v1`) переходит на существующий модуль `lib/i18n`: переключатель RU/EN в шапке
|
||||
работает по-настоящему, все строки локализованы, ошибки уже локализуются.
|
||||
Инфраструктура i18n уже есть и проверена (v1-ветка) — задача в том, чтобы
|
||||
подключить к ней preview и закрыть пробелы словарей.
|
||||
|
||||
До начала локализации **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** Цель — zero-warn. Сейчас `en.ts` неполный (в `tools` только
|
||||
результаты-помощники, остальной английский — из реестра), поэтому после
|
||||
включения будет много warn'ов: доводим `en.ts` до полноты `ru.ts` (секции +
|
||||
`tools` полностью, source — registry-English, без дрейфа). До достижения цели
|
||||
допускается allowlist-механизм в ядре правила для осознанных отклонений.
|
||||
- **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` следует за локалью.
|
||||
@@ -0,0 +1,447 @@
|
||||
# План: переезд на новый дизайн (refs) — параллельная сборка
|
||||
|
||||
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**.
|
||||
> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21
|
||||
> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не
|
||||
> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт,
|
||||
> аудит против рефа прекращён. Tech debt закрыт
|
||||
> (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд старых файлов в
|
||||
> `old/`) — **выполнено**. Покупка домена НЕ была блокером C17: домен нужен для
|
||||
> S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
||||
>
|
||||
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
||||
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
||||
>
|
||||
> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка
|
||||
> общих компонентов на месте с алиасами токенов) — **параллельная сборка**:
|
||||
> новый дизайн строится отдельно, изолируется от старого, проверяется целиком,
|
||||
> затем становится основным и старый удаляется. Старое приложение остаётся
|
||||
> рабочим и шippable на всём протяжении перехода.
|
||||
|
||||
## 0. Почему меняем стратегию
|
||||
|
||||
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые токены
|
||||
как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие компоненты
|
||||
редактируются вживую → регресс старого UI во время работы, новый дизайн нельзя
|
||||
принять изолированно.
|
||||
- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые страницы
|
||||
рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем его основным,
|
||||
(5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги.
|
||||
- **Главный технический риск и его решение.** Сейчас `web/src/app.css`
|
||||
импортируется в **корневом** `web/src/routes/+layout.svelte:2` и потому
|
||||
грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать новый
|
||||
CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без
|
||||
наследования**: (а) убрать `import '../app.css'` из корневого layout; (б)
|
||||
старые маршруты обернуть группой `(old)/` с собственным layout, импортирующим
|
||||
`app.css`; (в) новые маршруты (`preview/*`) — собственный layout,
|
||||
импортирующий `design2.css`. SvelteKit делает code-splitting CSS по маршрутам,
|
||||
а стили компонентов скоупятся сами — поэтому глобальные стили каждого дизайна
|
||||
живут только в своей ветке, и префикс-обёртка `.ds2` не нужна. Старые `ui/` и
|
||||
страницы не трогаются до фазы 5.
|
||||
|
||||
## 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. Маршрутизация параллельных страниц
|
||||
|
||||
Новые страницы живут в **отдельной ветке `preview/*`**, не трогая старые:
|
||||
|
||||
```txt
|
||||
src/routes/+layout.svelte # нейтральный, БЕЗ app.css
|
||||
src/routes/(old)/+layout.svelte # импорт app.css + старая шапка/подвал
|
||||
src/routes/(old)/+page.svelte # / (старое)
|
||||
src/routes/(old)/demo/+page.svelte # /demo
|
||||
src/routes/(old)/list-tools/...
|
||||
src/routes/(old)/tools/[id]/...
|
||||
src/routes/preview/+layout.svelte # импорт design2.css, <AppShell>
|
||||
src/routes/preview/+page.svelte # /preview — воркспейс (index/demo)
|
||||
src/routes/preview/demo/+page.svelte # /preview/demo
|
||||
src/routes/preview/list-tools/+page.svelte
|
||||
src/routes/preview/tools/[id]/+page.svelte
|
||||
```
|
||||
|
||||
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы не
|
||||
изменяются (только перемещены в `(old)/`).
|
||||
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты (move-файлы),
|
||||
группа `(old)/` удаляется, `design2.css` подключается в корневом
|
||||
`+layout.svelte` (как новый основной слой), временный флаг/кнопка убираются.
|
||||
|
||||
## 7. Фазы выполнения (1–5 по запросу)
|
||||
|
||||
Каждая фаза заканчивается зелёными `test`/`check`/`build` и ревью в браузере
|
||||
(обе темы) против `refs-html/`.
|
||||
|
||||
- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все примитивы
|
||||
§3 и контролы §4 как отдельные компоненты. Критерий: демо-страница kit'а
|
||||
(`/preview` или временная) показывает все состояния (light/dark,
|
||||
hover/disabled, все варианты Button/Segmented/Badge); unit-тесты форм не
|
||||
падают; **старые страницы визуально не изменились** (изоляция работает).
|
||||
|
||||
- **Фаза 2 — Новые страницы.** Под `preview/*` (§6): воркспейс, demo,
|
||||
list-tools, tool-page (двухпанельная раскладка из рефа: settings-panel +
|
||||
preview-panel, step-cards для chain). Критерий: разметка соответствует
|
||||
`refs-html/index.html`, `gradient.html`, `background-remover.html`,
|
||||
`list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает в
|
||||
новом стиле; старые маршруты продолжают работать как раньше.
|
||||
|
||||
- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран, 800px),
|
||||
скриншоты против `refs-html/`, прогон старого тест-сьюта для доказательства
|
||||
отсутствия регресса, RU/EN-локализация новых лейблов. Критерий: визуальное
|
||||
совпадение с рефом, зелёные тесты, записанные скриншоты приняты.
|
||||
|
||||
- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*` → реальные
|
||||
маршруты; `design2.css` подключается в корневом `+layout.svelte`; удаление
|
||||
preview-группы и временного флага/кнопки. Старые страницы теперь не
|
||||
используются (мёртвый код), но физически ещё в репо. Критерий: реальные
|
||||
маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений.
|
||||
|
||||
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`,
|
||||
старого `app.css` (и его токенов), неиспользуемых классов; наведение порядка в
|
||||
`components/` (имя каталога и подпапок — решается на фазе 1; к C17 `kit/` уже
|
||||
разложен в `components/`). Финальный проход по брейкпоинтам 1200/1100/800/480.
|
||||
Критерий: `grep` по старым токенам/классам пуст, build чистый, старый дизайн
|
||||
нигде не упоминается.
|
||||
|
||||
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня),
|
||||
Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня).
|
||||
|
||||
## 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). На C19 — только
|
||||
навести порядок внутри `components/`, переименование не требуется.
|
||||
- **Изоляция через layout:** `app.css` вынесен из корневого layout в
|
||||
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
|
||||
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
|
||||
(DevTools → вкладка Styles, нет правил из `app.css`).
|
||||
- **Переключатель для проверки:** рекомендую `localStorage`-флаг + кнопка
|
||||
«Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
|
||||
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
|
||||
ключи, но допустить часть английских брендовых («приборная панель»).
|
||||
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
|
||||
мелочь, решить при сборке topbar.
|
||||
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
|
||||
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
|
||||
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
|
||||
не нужен и не создаётся. `refs/` и `refs-html/` можно удалить из репо на фазе
|
||||
5 (по желанию), они не являются частью рантайма.
|
||||
|
||||
## 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`): старый `(old)/` → `/v1/`,
|
||||
> preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки
|
||||
> импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались:
|
||||
> C19 (удаление старого, раскрытие `recommended` на весь код), 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. Проверка нового дизайна
|
||||
|
||||
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
||||
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
|
||||
отсутствия регресса: старый `v1/` работает и не затронут.
|
||||
|
||||
### Шаг 7. Опциональная чистка (отложен, нет срока)
|
||||
|
||||
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
|
||||
правды.
|
||||
- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
||||
`app_v1.css` (и его токены), неиспользуемые классы. **И раскрыть ESLint
|
||||
`recommended` на весь код** — убрать scoped-блок `newCode` в
|
||||
`web/eslint.config.js`, прогнать `lint` и починить всплывшие ошибки.
|
||||
_Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) —
|
||||
это не «старый дизайн», у них свой линт-долг; расширение `recommended` на
|
||||
них может выдать много ошибок. Решить на C19: либо чиним сразу, либо
|
||||
расширяем scoped только на `src/routes/**` + `src/lib/components/**`._
|
||||
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
|
||||
каталога).
|
||||
|
||||
> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской
|
||||
> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит
|
||||
> только когда нет горящих задач и хочется навести порядок.
|
||||
>
|
||||
> Если какой-то коммит тянет за собой > 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).
|
||||
|
||||
## Бэклог полировки (после C9–C21, отдельным проходом)
|
||||
|
||||
Конкретные расхождения витрины `preview/kit` с `refs-html/`. Править системно в
|
||||
конце, а не по ходу сборки базы.
|
||||
|
||||
- **Toggle** в дизайне прямоугольный, у нас — закруглённый. Сделать
|
||||
прямоугольным (или уточнить радиус у свича отдельно от общих токенов).
|
||||
- **Радиусы бордеров** не совпадают: в `refs` радиусы вообще 0, а мы взяли
|
||||
ненулевой `--radius` для всех бордеров. Проверить токен `--radius` против
|
||||
референсов; возможно он должен быть 0 (или близко), а скругления оставить
|
||||
только там, где они реально есть в дизайне.
|
||||
- **DownloadButton**: нет отступа между текстом и размером файла (`size`-чип).
|
||||
Добавить `gap`/отступ внутри кнопки.
|
||||
- **Поля ввода (controls)**: выглядят без паддингов (возможно артефакт самой
|
||||
витрины, но визуально плохо). Проверить паддинги в `TextField`/`SelectField`/
|
||||
`ColorField`/`SliderField` против референса и поправить.
|
||||
- **Segmented**: на витрине последняя кнопка растянута и нечитаемо, что это
|
||||
сегмент. Проверить, как `Segmented` выглядит в реальных раскладках (C9–C14);
|
||||
возможно нужен фиксированный/авто размер сегментов или перенос.
|
||||
@@ -0,0 +1,360 @@
|
||||
# План: SEO + GEO — индексация и конкуренция в image tools
|
||||
|
||||
> **Статус (2026-09-11):** план к выполнению. C17 выполнен — `preview/*`
|
||||
> переехали на реальные маршруты (в `noindex` сохраняются до запуска S1, см.
|
||||
> `plan-redesign.md` C17). Сейчас применять можно domain-free часть S1.
|
||||
> **Уточнено:** домен не был блокером C17 (см. §6). Задачи S1 разбиты на
|
||||
> domain-free (можно делать параллельно с покупкой домена) и domain-required
|
||||
> (после покупки и настройки домена на Vercel).
|
||||
>
|
||||
> **Цель:** каждая страница инструмента — индексируемый лендинг под конкретный
|
||||
> интент («crop png», «make png transparent», «jpg to png»…), конкуренция с
|
||||
> onlinepngtools.com за ядро запросов + попадание в ответы генеративных систем
|
||||
> (ChatGPT, Perplexity, AI Overviews) — GEO.
|
||||
>
|
||||
> Задача: «для каждого инструмента — своя страница, и можно реально
|
||||
> конкурировать с onlinepngtools».
|
||||
>
|
||||
> **Позиционирование (уточнено 2026-09-09):** сайт — не «PNG-инструменты», а
|
||||
> **image tools с PNG-ядром**: любой инструмент работает с PNG/JPG/WebP/GIF/BMP
|
||||
> на входе и отдаёт результат в PNG/JPG/WebP/BMP через селектор в кнопке
|
||||
> Download. Дифференциатор — прозрачность/альфа/фон и no-upload-приватность.
|
||||
> Референс-конкурент по ядру — onlinepngtools.com (см. §2). Долгосрочно ядро
|
||||
> переезжает на wasm и обрастает API + PWA (оффлайн/установка) —
|
||||
> `docs/roadmap.md` фазы 3–10, `docs/plan-platform.md`; для SEO это аргументы
|
||||
> дистрибуции (S4c) и GEO-факты, схему сайта они не меняют.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
- **Бренд: переименовать до индексации** (новое имя и домен сознательно не
|
||||
записаны в этом репозитории — см. §6 и локальный план
|
||||
`docs/plan-domain.local.md`, исключён из git: выбранное имя не должно утекать
|
||||
в историю коммитов до покупки домена). Легенда честная: «started as PNG tools,
|
||||
now all formats» — это контент-плюс, а не баг. Названия инструментов
|
||||
(`crop-png`, …) и localStorage-ключи не переименовывать (см. решение про слаги
|
||||
ниже). Точка истины по бренду: константа в i18n + README; хардкод в
|
||||
компонентах недопустим.
|
||||
- **Форматы: формат = выбор в UI, не отдельные страницы.** Download-кнопка
|
||||
каждого инструмента — с селектором PNG/JPG/WebP/BMP (+quality для lossy). Райз
|
||||
«выбор формата/качества в Download» вытащен из backlog №7 в S1f: механика уже
|
||||
есть (`ToolEntry.output`), нужна как продуктовая основа «image
|
||||
tools»-позиционирования. Отдельные страницы-конвертеры — только для
|
||||
подтверждённых вордстатом интентов.
|
||||
- **Слаги инструментов `-png` сохраняются.** Они держат низкоконкурентные
|
||||
png-интенты; широкие интенты («crop image online») переносятся теми же
|
||||
страницами контентом: H1 «Crop image», текст «works with PNG, JPG, WebP».
|
||||
URL-алиасы без `-png` не создаются (канонический беспорядок).
|
||||
- **Индексируемая локаль — EN** (базовая; конкурент англоязычный). RU-версия —
|
||||
отдельно и позже: префикс `/ru/`, prerender обеих локалей, hreflang
|
||||
(сознательно отложено в `archive/plan-i18n.md`). Пока язык переключается на
|
||||
клиенте (localStorage) — для SEO не учитываем.
|
||||
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`) ломает
|
||||
абсолютные URL, canonical и «корневость» страниц. Свой домен + корень.
|
||||
- **URL-схема инструментов:** короткие плоские адреса. После C17 вынести
|
||||
страницы инструментов в корень: `/crop-png` (как у конкурента), каталог —
|
||||
`/tools`. Переезд `/preview/tools/[id]` → `/[id]` сделать **одновременно с
|
||||
C17** (в одном атомарном коммите): после индексации переименование URL = 301 и
|
||||
потеря позиций.
|
||||
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или
|
||||
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя
|
||||
подрывать собственными скриптами.
|
||||
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
|
||||
каждому инструменту, с ручной вычиткой.
|
||||
|
||||
## 1. Аудит (2026-09-09)
|
||||
|
||||
Фундамент уже есть:
|
||||
|
||||
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
|
||||
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
|
||||
JS. Это главный технический козырь.
|
||||
- 121 инструмент в `registry-new` с «SEO-шными» id (`crop-png`,
|
||||
`add-border-png`, …) — URL уже повторяют паттерн конкурента.
|
||||
- Уникальные `<title>` + `<meta description>` на страницах инструментов старой
|
||||
ветки; механика переносится на новую при C17.
|
||||
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
|
||||
инструментов против конкурента: `docs/tools-map.md` (у них 311, из них 107 —
|
||||
нишевые клоны-серии Logo*/Icon*/Stamp* поверх обычных операций).
|
||||
- EN-тексты в реестре + i18n-словари ru/en.
|
||||
|
||||
Чего нет (пробелы закрываются этапами ниже):
|
||||
|
||||
| Пробел | Где сейчас | Этап |
|
||||
| ----------------------------------------------------- | ----------------------------------- | ------ |
|
||||
| `sitemap.xml` | нет | S1 |
|
||||
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
|
||||
| `rel=canonical` | нет | S1 |
|
||||
| OG/Twitter-теги, OG-картинки | нет | S1 |
|
||||
| `noindex, nofollow` на страницах инструментов preview | `preview/tools/[id]/+page.svelte` | S1/C17 |
|
||||
| Контент страницы (H1-текст, how-to, FAQ, related) | только title/description | S2 |
|
||||
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
|
||||
| `llms.txt`, хабы категорий с текстом | нет | S3 |
|
||||
| GSC + приватная аналитика | нет | S4 |
|
||||
|
||||
## 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 запросов.
|
||||
Покрытие инструментов у нас почти полное (121); приоритет — контент и
|
||||
качество страниц ядра, затем хвосты.
|
||||
2. **Страница = лендинг, а не «пустая коробка».** Шаблон контента: H1 → 1–2
|
||||
абзаца «что делает» → как использовать (шаги) → параметры → FAQ (3–5 вопросов
|
||||
уровня People-Also-Ask) → related tools по категории.
|
||||
3. **CWV как измеримое преимущество.** Лёгкие быстрые страницы против ~450-КБ
|
||||
страниц конкурента; после редизайна не потерять скорость.
|
||||
4. **GEO.** LLM-системы цитируют страницы с прямыми определениями, списками,
|
||||
таблицами и уникальными фактами. Всё это уже есть в данных реестра — отдавать
|
||||
в HTML-структуре, без воды.
|
||||
5. **Информационный контур — потом, не вместо.** Гайды по форматам (PNG/JPEG/
|
||||
WebP: история, модификации, lossy/lossless, прозрачность, «png vs jpeg») дают
|
||||
E-E-A-T, перехватывают сравнительные запросы и служат хабами внутренних
|
||||
ссылок на инструменты. Но это этап S3e и только после того, как написано ядро
|
||||
S2: транзакционная страница приносит конверсию, информационная — только
|
||||
трафик. Тонкие FAQ-страницы «про всё» не заводить — несколько сильных
|
||||
материалов с ссылками на 3–5 инструментов каждый.
|
||||
6. **Дистрибуция.** GitHub-репо (звёзды/форки = сигналы), alternative.to,
|
||||
Product Hunt, Reddit по интенту — реальный путь набрать первые бэклинки без
|
||||
бюджетов.
|
||||
|
||||
## 4. Этапы
|
||||
|
||||
### S1. Технический минимум (сразу после C17)
|
||||
|
||||
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
|
||||
**domain-required** (после покупки и DNS-настройки).
|
||||
|
||||
#### S1-free: без домена (параллельно с C18–C19)
|
||||
|
||||
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
|
||||
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
|
||||
файл `static/og/[id].png`, ссылка из `Seo.svelte`. Относительные пути
|
||||
(`/og/crop-png.png`) работают без домена; при деплое на Vercel с
|
||||
собственным доменом OG-картинки будут корректными.
|
||||
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
|
||||
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть
|
||||
(backlog №7, райз «выбор формата/качества»). Продуктовая основа «image
|
||||
tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
|
||||
параллельно с C18–C19.
|
||||
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
|
||||
формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в
|
||||
png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`,
|
||||
`png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами со своим
|
||||
контентом (§5).
|
||||
|
||||
#### S1-domain: с доменом (после покупки + DNS на Vercel)
|
||||
|
||||
- [ ] **[S1a]** Снять `noindex, no_follow` со страницы инструмента
|
||||
(`tools/[id]/+page.svelte`) — делается одновременно с S1d (canonical)
|
||||
чтобы Google сразу проиндексировал с правильным доменом.
|
||||
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
|
||||
`build/` из `TOOLS` реестра (`registry-new`) + статических страниц. Вызов
|
||||
из `pnpm build` после `vite build` (adapter-static пишет в `build/`,
|
||||
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
|
||||
опционального `BASE_PATH`.
|
||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до
|
||||
C19; после — вернуть минимальный allow-all).
|
||||
- [ ] **[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. Гейты: build
|
||||
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
|
||||
> canonical/OG присутствуют на всех страницах; e2e зелёные.
|
||||
|
||||
### S2. Контентный слой (параллельно C18–C19, волнами по категориям)
|
||||
|
||||
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
||||
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
||||
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
|
||||
вручную пачками по категориям (самый трудоёмкий этап: ~121 × 2 локали; RU
|
||||
— отложить до решения о `/ru/`).
|
||||
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
|
||||
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
|
||||
внутри категории + 2–3 «соседних» интента).
|
||||
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI.
|
||||
- [ ] **[S2d]** Первая волна контента — топ-30 инструментов ядра (приоритет по
|
||||
вордстату: transparent png, jpg→png, compress, resize, crop, rotate,
|
||||
circle crop, add text, pixelate, grayscale…).
|
||||
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
|
||||
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
|
||||
рантайме.
|
||||
|
||||
### S3. Структурированные данные + GEO
|
||||
|
||||
- [ ] **[S3a]** JSON-LD: `SoftwareApplication` (+ `offers` price 0) и `FAQPage`
|
||||
на страницах инструментов; `WebSite` + `Organization` на главной;
|
||||
`BreadcrumbList` на всех внутренних. Учесть: Google ограничил rich results
|
||||
по FAQ/HowTo для мелких сайтов — разметка нужна прежде всего для
|
||||
LLM-парсинга, она безвредна.
|
||||
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
|
||||
инструментов с одним предложением на каждый (генерится из реестра тем же
|
||||
скриптом, что sitemap).
|
||||
- [ ] **[S3c]** Хабы категорий с текстом: 2–3 абзаца на категорию (что за
|
||||
группа, когда какие инструменты применять), не голые списки карточек.
|
||||
- [ ] **[S3d]** GEO-редактура: каждое описание начинается прямым определением
|
||||
(«Crop PNG is a free tool that cuts a rectangular area from a PNG image
|
||||
right in your browser»), списки/таблицы для фактов, без вводных «в
|
||||
современном мире…». Проверка: страница отвечает на запрос одним абзацем
|
||||
«без скролла».
|
||||
- [ ] **[S3e]** _(опционально)_ Информационные гайды по форматам: 3–4 сильных
|
||||
материала (`/guides/png-format`, `/guides/jpeg-vs-png`, …): история и
|
||||
модификации формата, lossy/lossless, прозрачность, когда какой формат
|
||||
выбирать. Каждый — с ссылками на 3–5 связанных инструментов (хабы
|
||||
перелинковки), FAQ из гайдов продублирован как FAQPage-разметка. Приоритет
|
||||
ниже S2/S3a–d: только когда ядро написано и есть ресурс.
|
||||
- Гейты: валидация JSON-LD (Rich Results Test без ошибок); `llms.txt` актуален
|
||||
при добавлении инструментов (генерация, а не ручное редактирование).
|
||||
|
||||
### S4. Измерение и запуск
|
||||
|
||||
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
|
||||
мониторинг индексации и запросов. Бесплатно и обязательно.
|
||||
- [ ] **[S4b]** Аналитика без трекинга: self-hosted Plausible/Umami либо
|
||||
Cloudflare Web Analytics. GA/ЯМ не ставим (позиционирование).
|
||||
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
|
||||
подача на alternative.to (страница против конкурента), Product Hunt,
|
||||
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
|
||||
- [ ] **[S4d]** _(опционально, позже)_ Сравнительные посадочные «vs …» (без
|
||||
негатива: «no limits, no signup, no upload») — по мере роста доверия
|
||||
домена.
|
||||
- [ ] **[S4e]** Контроль CWV: Lighthouse на страницы ядра после каждого крупного
|
||||
этапа редизайна; цель — зелёные метрики на мобильных.
|
||||
|
||||
## 5. Приоритизация инструментов (первая волна контента)
|
||||
|
||||
Источник — `docs/tools-map.md` (121 реализовано; у конкурента 311, из них 107 —
|
||||
клоны-серии). Первая волна — топ-интенты, где конкуренты ранжируются, а у нас
|
||||
уже есть инструмент и он не хуже:
|
||||
|
||||
- прозрачность/фон: create-transparent-png, remove-background, change-opacity
|
||||
- конвертация: jpg-to-png, png-to-jpg, webp-to-png, svg-to-png, png-to-base64
|
||||
- геометрия: crop, resize, rotate, circle-crop, round-corners, flip
|
||||
- цвет/фильтры: grayscale, invert, pixelate, add-text, watermark
|
||||
- генерация: gradient, empty-canvas
|
||||
|
||||
Новая волна форматов (после 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-jpeg` → `/crop-png`** — законно только для опечаток и
|
||||
альтернативных написаний (`crop-jpg` → `crop-jpeg`) и переименований
|
||||
существующих страниц. Для самостоятельного запроса редирект — это отказ от
|
||||
страницы: 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»** — интент «страница-конвертер»: пользователь ищет
|
||||
конкретную пару форматов (лучшее качество конверсии, меньше ошибок), это
|
||||
готовые лендинги. Существующие (`jpg-to-png`, `png-to-jpg`, `png-to-webp`,
|
||||
`png-to-bmp`, `svg-to-png`, …) **оставить и наполнить контентом как все**:
|
||||
свой H1 («Convert PNG to JPG — free, no upload»), FAQ про
|
||||
качество/прозрачность/фон, related. Селектор формата (S1f) их **не заменяет**
|
||||
— он про «уже открыл инструмент и хочу другой формат на выходе».
|
||||
- **«convert image» / «image converter»** — интент «швейцарский нож». Под него —
|
||||
**один будущий инструмент `/convert-image`**: сначала пустой **core-tool** в
|
||||
`core/` — функция ре-энкода («копия с перекодированием»: декодирует любой
|
||||
поддержанный исходник в ImageData и кодирует в выбранный формат; поверх неё
|
||||
S1f строит селектор формата). `ToolEntry` и страницу добавлять **только вместе
|
||||
с собственным текстом** («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, дёшево сейчас)
|
||||
|
||||
Новое имя и конкретный домен **не записаны в этом репозитории** — детальный
|
||||
чек-лист в `docs/plan-domain.local.md` (исключён из git через `.gitignore`),
|
||||
чтобы выбранное имя не попало в историю коммитов до покупки домена.
|
||||
|
||||
- [ ] **[R1]** Механический переезд бренда на новое имя по чек-листу из
|
||||
локального плана: GitHub-репо + `git remote set-url`, README,
|
||||
`package.json` (корень + `web/`), `TopBar.svelte` / `Footer.svelte`,
|
||||
`favicon.svg` (title в SVG), i18n-строки (`en.ts`, `ru.ts`: defaultTitle,
|
||||
pageTitle, metaDescription). НЕ трогать: слаги инструментов (`*-png`),
|
||||
ключи localStorage (`easy-png-tools:*` — или мигрировать отдельной
|
||||
мелочью), историю docs/archive.
|
||||
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
||||
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
||||
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
||||
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2
|
||||
(домен) — блокер только для S1-domain (canonical + sitemap + снятие
|
||||
`noindex`). После индексации переименование = переезд с 301, потеря позиций и
|
||||
ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17.
|
||||
|
||||
## 7. Что осознанно НЕ делаем
|
||||
|
||||
- Клонирование 311 URL конкурента (низкокачественные дубли + нет ресурсов на
|
||||
контент) — вместо этого ядро + обоснованные хвосты.
|
||||
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
|
||||
- GA/реклама/трекинг — противоречит позиционированию.
|
||||
- Дорвеи, программные зеркала, покупные ссылки.
|
||||
- Массово-сгенерированные форматные клоны (`crop-jpeg`/`crop-webp` как копии
|
||||
`crop-png` с подстановкой формата в шаблонный текст) — doorway-паттерн, риск
|
||||
deindex всего сайта. Форматная страница — только уникальный контент + вордстат
|
||||
(§5); редиректы — только для опечаток/переименований.
|
||||
- URL-алиасы без `-png` для существующих инструментов и зеркальные
|
||||
страницы-конвертеры «на каждый формат» без вордстата.
|
||||
|
||||
## 8. Связанные документы
|
||||
|
||||
- `docs/plan-redesign.md` — C17 (снятие noindex + переезд маршрутов), C18–C21.
|
||||
- `docs/tools-map.md` — карта «наш инструмент ↔ интент конкурента».
|
||||
- `docs/backlog.md` — пункты SEO/GEO в общем бэклоге.
|
||||
- `docs/roadmap.md` — фазы 2–8 (ядро TS→Rust/wasm, CLI); фазы 9–10 — API и PWA.
|
||||
- `docs/plan-platform.md` — планы API и PWA (общее ядро, оффлайн-режим).
|
||||
- `docs/archive/plan-i18n.md` — отложенная многоязычная схема (будущий `/ru/`).
|
||||
- `docs/plan-domain.local.md` — план переименования/домена; **в git не входит**
|
||||
(`.gitignore`), лежит только локально.
|
||||
@@ -0,0 +1,201 @@
|
||||
# План разработки: easy-png-tools
|
||||
|
||||
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
||||
> каталог переведён в типизированный `registry` (121/125), новый дизайн по
|
||||
> `plan-redesign.md` стал основным на корневых маршрутах (C17/C18 выполнены),
|
||||
> старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19 в бэклоге).
|
||||
> Tech debt закрыт. Следующий этап: C20 (проверка нового дизайна,
|
||||
> `plan-redesign.md`, §10) + SEO S1-free (`plan-seo.md`) + баги. Домен нужен для
|
||||
> SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
|
||||
> `docs/plan-platform.md`.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
- **Набор PNG-утилит, всё в браузере** (SvelteKit + `adapter-static`), данные не
|
||||
покидают машину.
|
||||
- **Первый и главный приоритет — полностью рабочий TS-сайт**: максимум простых
|
||||
инструментов уровня EASY, UX/UI, пайплайны (несколько инструментов подряд),
|
||||
описания инструментов. Сайтом можно пользоваться уже после этой фазы.
|
||||
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` —
|
||||
эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки
|
||||
Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
|
||||
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат закладывается сейчас
|
||||
и потом станет общим для браузера, wasm и CLI.
|
||||
- **Реестр инструментов** (id, title, description, схема параметров, `run`) —
|
||||
источник истины: из него генерируются страницы, формы, пайплайны. Позже —
|
||||
общий формат для Rust-парсера.
|
||||
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из
|
||||
TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций,
|
||||
**perceptual diff** для canvas-зависимых.
|
||||
- **Единая точка исполнения инструментов.** Страницы вызывают операции только
|
||||
через `web/src/lib/tools/executor.ts::executeStep` — нигде напрямую
|
||||
`tool.run`. Контракт уже асинхронный (`Promise<PixelImage>`), поэтому:
|
||||
- когда появятся тяжёлые MEDIUM-операции (свёртки, квантование), внутренности
|
||||
`executeStep` переезжают в Web Worker — воркер импортирует тот же чистый
|
||||
core как TS-фолбэк, алгоритмы не дублируются, вызывающий код не меняется;
|
||||
- в WASM-фазе тот же воркер принимает wasm-модуль и диспетчеризует по `toolId`
|
||||
— свитч A/B из фазы 5 сводится к замене реализации внутри исполнителя;
|
||||
- до появления MEDIUM-операций воркер не вводится: EASY-операции быстры и не
|
||||
блокируют UI.
|
||||
|
||||
## 1. Структура репозитория (этап TS)
|
||||
|
||||
```txt
|
||||
easy-png-tools/
|
||||
web/ # SvelteKit (adapter-static)
|
||||
src/lib/core/ # TS-ядро: Image (ImageData) + операции (без DOM)
|
||||
color.ts alpha.ts geometry.ts format.ts text.ts analyze.ts
|
||||
src/lib/registry.ts # реестр инструментов: id, title, description, params, run
|
||||
src/lib/pipeline.ts # пайплайн: шаги, применение, localStorage, экспорт/импорт JSON
|
||||
src/lib/components/ # DropZone, Preview, ParamForm (из схемы), Download, PipelineSteps
|
||||
src/routes/ # маршруты-утилиты (генерируются из реестра) + /workspace
|
||||
docs/
|
||||
core/ # (позже) Rust-крейт
|
||||
tests/ # (позже) эталонные файлы и harness
|
||||
```
|
||||
|
||||
## 2. Фазы
|
||||
|
||||
### Фаза 1 — Полноценный TS-сайт (единственный приоритет)
|
||||
|
||||
**Цель: сайт, которым можно пользоваться.** Без wasm, без Rust, без
|
||||
clamp-семантики (в TS это делает `Uint8ClampedArray` сам).
|
||||
|
||||
1.1 **Каркас и UX/UI.** SvelteKit + static adapter, дизайн-система (цвета,
|
||||
типографика, компоненты), общий лейаут, шапка с навигацией по категориям.
|
||||
|
||||
1.2 **Ядро.** Тип `Image` (обёртка над `ImageData`), загрузка/декодирование
|
||||
файла, кодирование и скачивание, библиотека операций
|
||||
(color/alpha/geometry/format/text/analyze). Всё без DOM-зависимостей внутри
|
||||
`core`.
|
||||
|
||||
1.3 **Реестр инструментов.** Одна запись =
|
||||
`{ id, title, description, category, params: ParamDef[], run }`. Универсальный
|
||||
рендерер: страница инструмента и форма параметров строятся из записи реестра
|
||||
автоматически; маршруты-утилиты генерируются из реестра на билде. Новый
|
||||
инструмент = новая запись + функция `run`.
|
||||
|
||||
1.4 **Пайплайн-workspace** (`/workspace`): загрузил изображение → список шагов
|
||||
(инструмент + его параметры) → последовательное применение с превью каждого шага
|
||||
→ скачивание финального результата. Добавление/удаление/перестановка шагов.
|
||||
Сохранение пайплайнов в localStorage, экспорт/импорт JSON. Общие компоненты:
|
||||
DropZone, Preview, ParamForm, Download, PipelineSteps.
|
||||
|
||||
1.5 **Массовая реализация EASY-инструментов** по категориям:
|
||||
|
||||
- конвертация форматов (png/jpg/webp/bmp/base64/gif) и текстовые представления;
|
||||
- прозрачность и альфа-канал;
|
||||
- цвет: замена, тон, каналы, оттенки серого, инверсии;
|
||||
- геометрия и холст: resize, crop, rotate, flip, border, padding, background;
|
||||
- текст и простые эффекты: watermark, add-text, рамки, шум, pixelate;
|
||||
- анализ: размеры, палитра, проверки, просмотр. Приоритет — ширина, не глубина:
|
||||
как можно больше простых инструментов.
|
||||
|
||||
1.6 **Полировка.** Описания и подсказки для всех инструментов, состояния
|
||||
загрузки/ошибок, доступность, пустые состояния.
|
||||
|
||||
**Checkpoint:** загрузил PNG → применил пайплайн из N шагов → сохранил и вернул
|
||||
пайплайн → скачал результат. Все инструменты 1.5 работают на сайте. Ни одного
|
||||
wasm.
|
||||
|
||||
### Фаза 2 — Тестовые PNG и генератор эталонов
|
||||
|
||||
- Фикстурные входы: градиенты/паттерны/шум + пара «настоящих» PNG.
|
||||
- Правило: сравниваем **распакованные пиксели**, не байты файла.
|
||||
- **Checkpoint:** в `tests/reference/<op>/` лежат `input-*.png` +
|
||||
`expected-*.png`.
|
||||
|
||||
### Фаза 3 — Playwright-тесты
|
||||
|
||||
- `tools/gen-references.ts`: грузит страницу, гоняет TS-эталон, сохраняет
|
||||
`expected-*.png`.
|
||||
- Регресс: изменение TS-ядра ловится тестами.
|
||||
- **Checkpoint:** регресс-тесты падают при изменении выхода эталона.
|
||||
|
||||
### Фаза 4 — Rust-ядро
|
||||
|
||||
- Порт 1:1 чистых операций (первым делом те, что покрыты эталонными файлами) с
|
||||
`Image` ↔ 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-фаз не требует, идёт после C17/C19
|
||||
и S1 из `plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
|
||||
|
||||
## 3. Правила сравнения (для фаз 4–7)
|
||||
|
||||
| Класс операции | Критерий |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| Чистые пиксельные: цвет, альфа, порог, свёртки, морфология, квантование | **бит-в-бит** |
|
||||
| Canvas-растеризация: текст, градиенты, антиалиасинг, `drawImage`, `ctx.filter` | **perceptual diff** (допуск: % изменившихся пикселей / цветовое расстояние) |
|
||||
| PNG-декод | бит-в-бит (lossless) |
|
||||
| JPEG-декод | perceptual (разные декодеры дают лёгкий дрейф) |
|
||||
| Кодирование | сравниваем декодированные пиксели, не байты файла |
|
||||
| compress/optimize | своя метрика: размер + визуальная дельта |
|
||||
|
||||
## 4. Риски и контрмеры
|
||||
|
||||
- **Реестр разрастается, страницы дублируются** — один универсальный рендерер
|
||||
страниц/форм из записей реестра; инструмент = данные + `run`.
|
||||
- **Семантика пайплайна** — зафиксировать: каждый шаг применяется к результату
|
||||
предыдущего; единый тип `Image` на всём пути; схема параметров на шаге.
|
||||
- **Canvas-операции не бит-в-бит** — класс perceptual закладывается на Фазе 2–3,
|
||||
а не в конце.
|
||||
- **WASM + Vite** — спайк на Фазе 5 до массовой интеграции (риск отложен
|
||||
сознательно: сайт уже живёт на TS).
|
||||
- **Расползание реестра при будущем Rust-порте** — схема параметров в JSON, её
|
||||
можно парсить и из Rust.
|
||||
- **Разные декодеры PNG** — фикстурные PNG генерировать самим, чтобы не тащить
|
||||
чужие артефакты.
|
||||
|
||||
## 5. Команды верификации
|
||||
|
||||
```bash
|
||||
pnpm dev # разработка (Фаза 1+, обёртка `pnpm --dir web dev`)
|
||||
pnpm build # статический экспорт (обёртка `pnpm --dir web build`)
|
||||
pnpm --dir web exec playwright test # эталоны + UI-регресс (Фаза 3+)
|
||||
cargo test # Rust-ядро против эталонных файлов (Фаза 4+)
|
||||
harness compare tests/reference # сквозная сверка native + wasm (Фаза 7)
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# Хранить данные для сортировки по популярности инструментов
|
||||
|
||||
Идея выложить на 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 млн) запросов на чтение в сутки.
|
||||
- Для пет-проекта этого хватит навсегда, даже если вы будете делать запросы
|
||||
напрямую с фронтенда без кеширования.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Аудит использования - UX\UI
|
||||
|
||||
## везде
|
||||
|
||||
- возможность переключать пиксели\проценты
|
||||
- инструменты без параметров выглядят странно, пересмотреть, может добавить какую-то надпись "нет параметров"
|
||||
- удаление\маски и т.д. - надо добавить галочку сглаживание
|
||||
- производительность: почему flip заметно тормозит, но нет индикации работы инструмента (также sharpen-png)
|
||||
- производительность: исследовать, есть ли смысл на инструментах геометрии выводить рамку результата до полноценной работы (например на skew \ rotate будет удобнее)
|
||||
|
||||
## аудит инструментов
|
||||
|
||||
### transparency
|
||||
|
||||
- add-stroke-png: добавить галочку "расширить на толщину линии"
|
||||
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо расширять на толщину линии
|
||||
- circle-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- square-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- star-mask-png: размеры заданы в процентах, надо задавать в пикселях, проверить форму звезды - сейчас выпуклые края, радиус задан до 100%, а более 50 - смысле нет
|
||||
- wavy-mask-png:размеры заданы в процентах, надо задавать в пикселях, пересмотреть форму и параметры. как будто неудобно конфигурировать
|
||||
- remove-alpha-channel-png: выбор цвета фона, как будет работать с полупрозрачной картинкой?
|
||||
- extract-alpha-mask-png: галочка инвертировать маску
|
||||
- round-corners-png: размеры заданы в пикселях, должна быть возможность задать в процентах, нету сглаживания по вырезанным пикселям
|
||||
- invert-alpha-png: края со сглаживанием странно обрабатываются, может галочка\слайдер обрезать сглаживание?
|
||||
- remove-background-png: возможность выбора точки, от которой удалять (или отдельный инструмент?)
|
||||
- make-thicker-png: расширять картинку на толщину линии
|
||||
- make-thinner-png: уменьшать от краев, если картинка начинается от края, то остается выступ
|
||||
- feather-edges-png: расширять картинку на толщину линии, (выбор цвета возможен? другой инструмент?)
|
||||
- clean-edges-png: непонятный инструмент (слишком маленький максимум?). найти тест кейс для него
|
||||
- despeckle-alpha-png: найти более заметный тест-кейс
|
||||
- close-holes-png: проверить, как должен работать, картинки с большой полупрозрачной областью - закрывает всё, по описанию должен закрывать только небольшие дырки
|
||||
|
||||
### colors
|
||||
|
||||
- two-colors-png: в range slider добавить описание что это luminance threshold
|
||||
- gamma-png: проверить правильность работы - яркое изображение выглядит не контрастным, проверить максимум и минимум
|
||||
- temperature-png: проверить правильность работы - не слишком ли отдает желтым\синим?
|
||||
- quantize-png: максимум 64 - не слишком мало? сделать кнопки с пресетами
|
||||
- custom-palette-png: копирует two-colors-png, только ввод через текстовое поле и нет выбора порога luminance
|
||||
- dithering-png: проверить алгоритм Floyd–Steinberg на малом количестве цветов
|
||||
- sepia-png: не нужна настройка силы эффекта?
|
||||
- extract-channel-png: мульти вывод сразу в 3 канала?
|
||||
- swap-channels-png: обдумать описание подобных инструментов через flow более простых?
|
||||
- black-and-white-png: аналог two-colors-png, только без выбора цветов
|
||||
- posterize-png: не нужен вариант настройки для каждого канала отдельно?
|
||||
- auto-contrast-png: найти тестовый кейс
|
||||
- decrease-color-count-png: сделать кнопки вместо селекта
|
||||
- png-to-hsl: проверить, что `space as rgb` должна выдавать одинаковые результаты, вне зависимости от выбора h s l. может выбрать отдельные кнопки?
|
||||
- png-to-hsv: тоже самое ^
|
||||
- png-to-hsi: тоже самое ^, нужно ли разделять на отдельные инструменты?
|
||||
- png-to-cmyk: тоже самое ^
|
||||
- png-to-lab: тоже самое ^
|
||||
|
||||
### geometry
|
||||
|
||||
- add-border-png: прозрачность цвета (аудит полей)
|
||||
- fit-on-background-png: цвет
|
||||
- change-canvas-size-png: использовать position9 вместо селекта
|
||||
- resize-png: по умолчанию размеры 0х0, надо поменять; выбор метода масштабирования
|
||||
- crop-png: offset - слишком большие значения
|
||||
- rotate-png: кнопки вместо select
|
||||
- flip-png: кнопки вместо select
|
||||
- add-padding-png: чем отличается от add-border-png?
|
||||
- center-by-alpha-png: найти тест кейс
|
||||
- zoom-png: выбор точки центра
|
||||
- trim-empty-space-png: выбор от 0 до 254 (надо везде привести к единому стилю - переключение 0-100 проценты и 0-255)
|
||||
- change-aspect-ratio-png: не видно что меняется, добавить рамку вокруг результата, а не вокруг div
|
||||
- swap-orientation-png: кнопки вместо select
|
||||
- symmetric-copy-png: кнопки вместо select, два выбора в селекте `keep side` не работают (в зависимости от выбора `axis`)
|
||||
- shift-png: цвет (прозрачность)
|
||||
- split-into-parts-png: результат с несколькими файлами, обдумать процесс
|
||||
|
||||
### filters
|
||||
|
||||
- vignette-png: выбор точки центра, цвет, кроме силы нужен еще параметр прироста силы (как называется?)
|
||||
- pixelate-png: разные алгоритмы? выглядит не очень. тест кейс?
|
||||
- randomize-pixels-png: настройка размеров другим вариантом - на сколько кусков делить. seed - поменять
|
||||
- add-noise-png: кнопки вместо select, seed - поменять
|
||||
- sharpen-png: добавить тест кейс
|
||||
- silhouette-png: аналог extract-alpha-mask-png?
|
||||
|
||||
### text
|
||||
|
||||
- add-text-png: plate снизу большой отступ, сделать галочку для этого (некоторые буквы будут обрезаться - y, g)
|
||||
- watermark-tile-png: заполняет не полностью
|
||||
|
||||
### analyze
|
||||
|
||||
- extract-color-from-png: надо описать или дать выбор на основе какого параметра работает threshold
|
||||
- show-transparent-png: кнопки вместо select
|
||||
- show-grayscale-pixels-png: кнопки вместо select
|
||||
- show-color-pixels-png: кнопки вместо select
|
||||
- light-pixel-mask-png: кнопки вместо select
|
||||
- verify-is-png: название неверное, в описании указано изображение, а фактически проверяется текст
|
||||
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный вердикт? может отдельные поля - 0\1, yes\no?
|
||||
|
||||
### generators
|
||||
|
||||
- color-spectrum-png: кнопки вместо select
|
||||
- draw-grid-png: выбор цвета фона
|
||||
- placeholder-png: с галочкой "показать текст" нет заполнения фона
|
||||
- blend-two-png: высота не задается
|
||||
- step-colors-png: кнопки вместо select, высота не задается, не слишком ли маленький max steps?
|
||||
- emoji-to-png: выбор эмодзи
|
||||
- color-wheel-png: сглаживание краев
|
||||
- complementary-png: кнопки вместо select(?), не меняется, всего 2 цвета, высота не задается
|
||||
- triadic-png: кнопки вместо select, высота не задается
|
||||
- tetradic-png: кнопки вместо select, высота не задается
|
||||
- analogous-png: кнопки вместо select, высота не задается
|
||||
- monochromatic-png: кнопки вместо select, высота не задается
|
||||
- shades-png: кнопки вместо select, высота не задается
|
||||
- mix-colors-png: высота не задается
|
||||
- sort-colors-png: кнопки вместо select, высота не задается
|
||||
|
||||
## аудит полей
|
||||
|
||||
- position offset: должны быть отдельные range slider на x и y
|
||||
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
|
||||
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
|
||||
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
|
||||
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
|
||||
- поле seed (random) - range slider не нужен, поле для ввода и "randomize" кнопка
|
||||
@@ -0,0 +1,235 @@
|
||||
# Карта инструментов: реализовано · добавить · идеи
|
||||
|
||||
> Живой документ для сверки с каталогом. Формат записи:
|
||||
> `id / название — параметры через запятую`. Источник сравнения:
|
||||
> onlinepngtools.com (311 уникальных; 107 из них — нишевые клоны серий
|
||||
> Logo*/Icon*/Stamp*/Signature* поверх обычных операций).
|
||||
|
||||
---
|
||||
|
||||
## 1. Реализовано
|
||||
|
||||
### Конвертация
|
||||
|
||||
- jpg-to-png — без параметров
|
||||
- webp-to-png — без параметров
|
||||
- gif-to-png — первый кадр
|
||||
- bmp-to-png — без параметров
|
||||
- ico-to-png — выбор размера кадра
|
||||
- png-to-bmp — 24-бит, фон вместо альфы
|
||||
- convert-png-to-jpg — background, quality
|
||||
- convert-png-to-webp — quality
|
||||
- svg-to-png — width результата
|
||||
- png-to-base64 / base64-to-png — строка
|
||||
- png-to-data-uri / data-uri-to-png — строка
|
||||
- png-to-hex — rrggbbaa по строкам / hex-to-png — tokens + width
|
||||
- png-to-bytes — десятичные RGBA-байты по строкам / bytes-to-png — tokens +
|
||||
width
|
||||
- png-to-rgb-values — rgba(r,g,b,a) по пикселям / rgb-values-to-png — числа +
|
||||
width
|
||||
- svg-to-png — width результата
|
||||
- verify-is-png — текстовый источник (base64/data-uri), вердикт по сигнатуре
|
||||
|
||||
### Прозрачность
|
||||
|
||||
- change-png-opacity — percent
|
||||
- set-alpha-channel-png — percent
|
||||
- remove-alpha-channel-png — без параметров
|
||||
- extract-alpha-mask-png — без параметров
|
||||
- invert-alpha-png — без параметров
|
||||
- remove-background-png — color, tolerance, outerOnly, smooth
|
||||
- remove-color-from-png — targetColor, tolerance
|
||||
- add-stroke-png — color, thickness
|
||||
- find-contour-png — color, thickness
|
||||
- make-thicker-png / make-thinner-png — radius
|
||||
- harden-alpha-png — threshold
|
||||
- feather-edges-png — radius (размытие только альфы)
|
||||
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного)
|
||||
- watermark-image-png — вторая картинка-знак (загружается на странице), scale,
|
||||
opacity, position, margin
|
||||
- despeckle-alpha-png / close-holes-png — radius
|
||||
- center-by-alpha-png — без параметров
|
||||
- round-corners-png — radius
|
||||
- circle-mask-png — size (диаметр, % меньшей стороны), offsetX, offsetY
|
||||
- square-mask-png — widthPct, heightPct, offsetX, offsetY
|
||||
- star-mask-png — points, innerRadius, size, rotation, offsetX, offsetY
|
||||
- wavy-mask-png — size, amplitude, waves, phase, offsetX, offsetY
|
||||
|
||||
### Цвет
|
||||
|
||||
- grayscale-png / invert-colors-png / sepia-png / auto-contrast-png — без
|
||||
параметров
|
||||
- adjust-brightness-contrast-png — brightness, contrast
|
||||
- change-png-hue — degrees
|
||||
- extract-channel-png — channel (r/g/b)
|
||||
- swap-channels-png — pair (r-g/r-b/g-b)
|
||||
- black-and-white-png — threshold
|
||||
- posterize-png — levels
|
||||
- two-colors-png — lightColor, darkColor, threshold
|
||||
- temperature-png — percent
|
||||
- gamma-png — value
|
||||
- tint-png — color, strength
|
||||
|
||||
### Квантование и палитры
|
||||
|
||||
- quantize-png — colors (k, median-cut)
|
||||
- decrease-color-count-png — maxColors (пресеты 2…256)
|
||||
- custom-palette-png — colors (hex через запятую, ближайший цвет)
|
||||
- dithering-png — colors (k), pattern (Floyd–Steinberg / Bayer 4×4)
|
||||
|
||||
### Разложение каналов
|
||||
|
||||
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray
|
||||
| space-as-rgb)
|
||||
- png-to-cmyk — component (c/m/y/k), display
|
||||
- png-to-ycbcr — component (y/cb/cr), display
|
||||
- png-to-lab — component (l/a/b), display
|
||||
|
||||
### Геометрия
|
||||
|
||||
- resize-png — width (0=авто), height (0=авто), keepAspect
|
||||
- crop-png — x, y, width, height
|
||||
- rotate-png — angle (90/180/270)
|
||||
- flip-png — axis (h/v)
|
||||
- skew-png — degX, degY
|
||||
- rotate-free-png — angle
|
||||
- zoom-png — scale
|
||||
- shift-png — offsetX, offsetY, color фона
|
||||
- add-padding-png — padding, transparent, color
|
||||
- add-border-png — thickness, color
|
||||
- fit-on-background-png — width, height, transparent, color
|
||||
- tile-png — columns, rows
|
||||
- split-into-parts-png — columns, rows (мультифайловый вывод, zip)
|
||||
- trim-empty-space-png — threshold (альфа)
|
||||
- change-canvas-size-png — width, height, anchor (3×3)
|
||||
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad)
|
||||
- swap-orientation-png — target (portrait/landscape)
|
||||
- symmetric-copy-png — axis, keepSide
|
||||
|
||||
### Фильтры
|
||||
|
||||
- blur-png — radius
|
||||
- sharpen-png — strength
|
||||
- vignette-png — strength
|
||||
- jpeg-artifacts-png — quality (имитация пережатия jpg/webp)
|
||||
- pixelate-png — blockSize (закрывает и их Color Blocks)
|
||||
- randomize-pixels-png — blockSize, seed
|
||||
- add-noise-png — amount, mode (mono/color), seed
|
||||
- silhouette-png — color, threshold
|
||||
|
||||
### Анализ
|
||||
|
||||
- png-info — размеры, альфа, число цветов
|
||||
- png-is-transparent / png-is-grayscale / png-orientation — текстовый вердикт
|
||||
|
||||
### Маски по свойствам пикселей
|
||||
|
||||
- show-transparent-png — color, opacity (подсветка прозрачных/полупрозрачных)
|
||||
- show-grayscale-pixels-png — tolerance, mode (binary/highlight),
|
||||
highlightColor, highlightOpacity
|
||||
- show-color-pixels-png — tolerance, mode, highlightColor, highlightOpacity
|
||||
- light-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
|
||||
- dark-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
|
||||
- unique-color-mask-png — rarity (макс. повторов), mode, highlightColor,
|
||||
highlightOpacity
|
||||
- extract-color-from-png — color, tolerance (оставить близкие, остальное
|
||||
прозрачным)
|
||||
|
||||
### Генерация
|
||||
|
||||
- create-empty-png — width, height, transparent, color
|
||||
- single-color-png — width, height, color
|
||||
- random-noise-png — width, height, seed
|
||||
- linear-gradient-png — width, height, fromColor, toColor, direction
|
||||
- text-to-png — text, fontSize, font, bold, color, transparentBg,
|
||||
backgroundColor, padding
|
||||
- emoji-to-png — emoji, size
|
||||
- placeholder-png — width, height, backgroundColor, color, showText
|
||||
- color-spectrum-png — width, height, direction, saturation, lightness
|
||||
- random-colors-png — width, height, blockSize, seed
|
||||
- draw-grid-png — width, height, cols, rows, lineWidth, color, transparentBg
|
||||
|
||||
### Текст
|
||||
|
||||
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin,
|
||||
plate, plateColor, plateOpacity
|
||||
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate,
|
||||
plateColor, plateOpacity
|
||||
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY,
|
||||
font, bold
|
||||
|
||||
### Палитры и цветовые утилиты
|
||||
|
||||
- color-wheel-generator — size, кольца/сектора, показ hex при клике (у нас —
|
||||
статичный свотч-полотно)
|
||||
- complementary-palette — baseColor
|
||||
- monochromatic-palette — baseColor, count
|
||||
- analogous-palette — baseColor, spread
|
||||
- triadic-palette — baseColor
|
||||
- tetradic-palette — baseColor
|
||||
- similar-shades-palette — baseColor, count, range
|
||||
- sort-colors — источник (палитра-картинка или список), порядок (hsl/luma)
|
||||
- mix-colors — colors[], веса?
|
||||
- average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps
|
||||
(три частных случая одного движка)
|
||||
|
||||
---
|
||||
|
||||
## 2. Можно добавить — из onlinepngtools
|
||||
|
||||
### Разложение каналов — остаток
|
||||
|
||||
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея)
|
||||
|
||||
### Края и силуэт — остаток
|
||||
|
||||
- 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 — анимационные (→ идеи)
|
||||
|
||||
### Сжатие и качество — реализовано (см. Конвертация: compress, reduce-to-size; jpeg-artifacts; W8 quantize)
|
||||
|
||||
### Генераторы — остаток
|
||||
|
||||
- multi-color-gradient — список стопов (нужен новый тип параметра → MEDIUM-UI)
|
||||
|
||||
### Конвертеры — остаток
|
||||
|
||||
- png-to-gif — MEDIUM (однокадровый GIF-энкодер руками)
|
||||
- gif-to-frames — MEDIUM (мультифайловый вывод → идея)
|
||||
- change-bit-depth — MEDIUM (пересборка PNG)
|
||||
|
||||
### Прочее единичное
|
||||
|
||||
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется
|
||||
(покрыто)
|
||||
- preview/test на цветных фонах — фича превью, не инструмент (решить позже)
|
||||
- extract-barcode — HARD, вне планов
|
||||
|
||||
---
|
||||
|
||||
## 3. Идеи на рассмотрение (нужна архитектура или спорная ценность)
|
||||
|
||||
- **Region-инструменты** — требуется UI выделения прямоугольника/ластика на
|
||||
превью: censor-region, erase-region, pixelate-area, blur-area, sharpen-area,
|
||||
reverse-colors-area. Один раз делаем selection-компонент — получаем сразу
|
||||
шесть инструментов.
|
||||
- **Мультифайловый вывод** — механизм «результат = набор файлов» (zip)
|
||||
реализован Остальные 1→many: gif-to-frames, separate-colors,
|
||||
multiply-grid-as-files — подключаются по мере нужды.
|
||||
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
|
||||
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
|
||||
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
|
||||
extract-barcode.
|
||||
- **Нишевые серии** (logo/icon/stamp/signature — 107 клонов у источника) —
|
||||
сознательно не копируем: это обычные операции над конкретным контентом, у нас
|
||||
они доступны через базовые инструменты + цепочки.
|
||||
@@ -1,60 +0,0 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "easy-png-tools",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "pnpm --dir web test",
|
||||
"dev": "pnpm --dir web dev",
|
||||
"build": "pnpm --dir web build",
|
||||
"format": "pnpm format:docs && pnpm --dir web format",
|
||||
"format:docs": "prettier --write docs --log-level warn",
|
||||
"check:docs": "prettier --check docs",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
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: {}
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 79 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 73 B |
@@ -0,0 +1 @@
|
||||
this is definitely not a png file
|
||||
@@ -0,0 +1,329 @@
|
||||
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);
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 722 B |
|
After Width: | Height: | Size: 16 B |
@@ -0,0 +1,28 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
static
|
||||
*.log
|
||||
.DS_Store
|
||||
audit
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by
|
||||
[`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
pnpm dlx sv@0.17.0 create --template minimal --types ts --install pnpm web
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or
|
||||
`pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an
|
||||
> [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { expectNoErrors, trackErrors } from "./helpers/page";
|
||||
|
||||
const TOTAL = 122;
|
||||
const GROUPS = 8;
|
||||
|
||||
test("catalog shows total and all groups", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await page.goto("/list-tools");
|
||||
await expect(page.locator(".catalog-total b")).toHaveText(String(TOTAL));
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("catalog search narrows results", async ({ page }) => {
|
||||
await page.goto("/list-tools");
|
||||
await expect(async () => {
|
||||
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card").first()).toContainText("Resize PNG");
|
||||
});
|
||||
|
||||
test("category filter shows only matching group and resets on ALL", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/list-tools");
|
||||
const allButtons = page.locator(".tool-card");
|
||||
const allCount = await allButtons.count();
|
||||
expect(allCount).toBe(TOTAL);
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "CONVERT" }).click();
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".catalog-group")).toContainText("Convert");
|
||||
const convertCards = await page.locator(".tool-card").count();
|
||||
expect(convertCards).toBeGreaterThan(0);
|
||||
expect(convertCards).toBeLessThan(allCount);
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "ALL" }).click();
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card")).toHaveCount(allCount);
|
||||
});
|
||||
|
||||
test("workspace search matches a tool by title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(async () => {
|
||||
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card").first()).toContainText("Flip PNG");
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { expectNoErrors, openTool, trackErrors } from "./helpers/page";
|
||||
|
||||
test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
||||
// FIXME: Генераторы (21/121) открываются, но в UI нет ни полей схемы, ни кнопки
|
||||
// «Generate» — только RU/EN/Reset. Пока баг открыт — fixme с ожидаемым
|
||||
// сценарием; после фикса убрать fixme.
|
||||
test.fixme("single-color-png renders Generate controls and produces a result", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "single-color-png");
|
||||
const generate = page.getByRole("button", { name: "Generate" });
|
||||
await expect(generate).toBeVisible();
|
||||
await generate.click();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("create-empty-png page opens without errors", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "create-empty-png");
|
||||
await expect(page.locator("h1")).toContainText("Create");
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
|
||||
export function makePng(
|
||||
width: number,
|
||||
height: number,
|
||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||
): Buffer {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
const stride = width * 4 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
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)),
|
||||
]);
|
||||
}
|
||||
|
||||
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"),
|
||||
};
|
||||
|
||||
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(" ");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, type Page } from "playwright/test";
|
||||
import type { SourceFile } from "./fixtures";
|
||||
|
||||
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 openTool(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/tools/${id}`);
|
||||
await expect(page.locator(".schema-tool h1")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
||||
await page.locator('.actions input[type="file"]').setInputFiles({
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
buffer: file.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
export async function metaValue(page: Page, caption: string): Promise<string> {
|
||||
const row = page.locator(".meta-row", { hasText: caption });
|
||||
await expect(row).toBeVisible();
|
||||
const value = await row.locator(".meta-value").textContent();
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export async function suggestedDownloadName(
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<string> {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.locator(selector).click(),
|
||||
]);
|
||||
return download.suggestedFilename();
|
||||
}
|
||||
|
||||
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
||||
await expect(page.locator('[role="alert"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
||||
expect(sink.errors, "нет console/page errors").toEqual([]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng } from "./helpers/fixtures";
|
||||
import { openTool, uploadImage } from "./helpers/page";
|
||||
|
||||
test("поиск каталога находит по названию из другой локали", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/list-tools");
|
||||
const input = page.locator(".catalog-search input");
|
||||
const flipCard = page.locator('a.tool-card[href*="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 });
|
||||
await expect(async () => {
|
||||
await ruBtn.click();
|
||||
await expect(page.locator(".schema-tool h1")).toHaveText(
|
||||
"Изменить размер PNG",
|
||||
);
|
||||
}).toPass();
|
||||
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
||||
await enBtn.click();
|
||||
await expect(page.locator(".schema-tool h1")).toHaveText("Resize PNG");
|
||||
});
|
||||
|
||||
// Валится на известном баге resize-png (дефолтные 0-параметры дают ошибку при
|
||||
// аплоаде, см. known-issues.spec.ts). Здесь он используется как детерминированная
|
||||
// ошибка: важно, что её текст локализован, а не сырой ключ.
|
||||
// TODO: найти другую ошибку, эта будет исправлена при фиксе resize-png
|
||||
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
const alert = page.locator("[role='alert']");
|
||||
await expect(alert).toBeVisible();
|
||||
const text = (await alert.textContent()) ?? "";
|
||||
expect(text).not.toMatch(/^errors\./);
|
||||
expect(text.length).toBeGreaterThan(3);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng } from "./helpers/fixtures";
|
||||
import { openTool, uploadImage } from "./helpers/page";
|
||||
|
||||
// FIXME: В файле зарегистрированы баги preview (см. docs/checklist-manual-testing.md
|
||||
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
|
||||
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
|
||||
// тест станет зелёным «сам по себе». (Тест про локализацию ошибок переехал в
|
||||
// i18n.spec.ts — он больше не issue.)
|
||||
|
||||
test.describe("known bugs — documented as fixme", () => {
|
||||
test.fixme("resize-png: upload produces a resized result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme("crop-png: upload produces a cropped result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "crop-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { expectNoErrors, trackErrors } from "./helpers/page";
|
||||
|
||||
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
|
||||
|
||||
for (const route of ROUTES) {
|
||||
test(`page ${route} loads without errors`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
const resp = await page.goto(route);
|
||||
expect(resp?.status()).toBe(200);
|
||||
await page.waitForLoadState("networkidle");
|
||||
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);
|
||||
});
|
||||
|
||||
// FIXME:
|
||||
// Error: expect(received).toBe(expected) // Object.is equality
|
||||
|
||||
// Expected: "dark"
|
||||
// Received: null
|
||||
|
||||
// 36 | expect(["light", "dark"]).toContain(before);
|
||||
// 37 | expect(["light", "dark"]).toContain(after);
|
||||
// > 38 | expect(stored).toBe(after);
|
||||
test.fixme("theme toggle flips preview theme and persists to localStorage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const root = page.locator("main.preview-root");
|
||||
const before = await root.getAttribute("data-theme");
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "Toggle theme" }).click();
|
||||
const after = await root.getAttribute("data-theme");
|
||||
expect(after).not.toBe(before);
|
||||
}).toPass();
|
||||
const after = await root.getAttribute("data-theme");
|
||||
const stored = await page.evaluate(() =>
|
||||
localStorage.getItem("easy-png-tools:theme"),
|
||||
);
|
||||
expect(["light", "dark"]).toContain(before);
|
||||
expect(["light", "dark"]).toContain(after);
|
||||
expect(stored).toBe(after);
|
||||
});
|
||||
|
||||
test("language toggle marks the active button", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const group = page.getByRole("group", { name: "Language" });
|
||||
await expect(async () => {
|
||||
await group.getByRole("button", { name: "EN" }).click();
|
||||
await expect(page.locator(".lang-btn.active")).toHaveText("EN");
|
||||
}).toPass();
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
corruptPng,
|
||||
landscapePng,
|
||||
largePng,
|
||||
onePixelPng,
|
||||
opaquePng,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
metaValue,
|
||||
openTool,
|
||||
suggestedDownloadName,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
} from "./helpers/page";
|
||||
|
||||
test("flip-png: full flow - upload, result, meta, download", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toHaveAttribute(
|
||||
"src",
|
||||
/^(blob:|data:image\/png;base64,)/,
|
||||
);
|
||||
const resultMeta = await metaValue(page, "RESULT");
|
||||
expect(resultMeta).toBe("64 × 16 px");
|
||||
const downloadName = await suggestedDownloadName(
|
||||
page,
|
||||
'button[aria-label="Download result"]',
|
||||
);
|
||||
expect(downloadName).toBe("flip-png.png");
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "convert-png-to-jpg");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
const downloadName = await suggestedDownloadName(
|
||||
page,
|
||||
'button[aria-label="Download result"]',
|
||||
);
|
||||
expect(downloadName).toBe("convert-png-to-jpg.jpg");
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test.fixme("reset clears result but keeps source", async ({ page }) => {
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
||||
await expect(page.locator(".empty")).toContainText("no result yet");
|
||||
await expect(page.locator('img[alt="source"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("blur: changing slider updates result image", async ({ page }) => {
|
||||
await openTool(page, "blur-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
const src1 = await page
|
||||
.locator('img[alt="Result image"]')
|
||||
.getAttribute("src");
|
||||
await expect(async () => {
|
||||
await page.locator('input[type="range"]').fill("20");
|
||||
await expect(page.locator('img[alt="Result image"]')).not.toHaveAttribute(
|
||||
"src",
|
||||
src1 ?? "",
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(window, "Worker", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, corruptPng);
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
||||
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(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
opaquePng,
|
||||
pixelRow,
|
||||
svgMarkup,
|
||||
tinyBase64,
|
||||
transparentPng,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
openTool,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
} from "./helpers/page";
|
||||
|
||||
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(" "),
|
||||
],
|
||||
["svg-to-png", svgMarkup],
|
||||
] as const) {
|
||||
test(`text input → ${id} produces result image`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await expect(async () => {
|
||||
await openTool(page, id);
|
||||
await page.locator(".text-source textarea").fill(input);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
}).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 code = page.locator(".result-pre code");
|
||||
await expect(code).toBeVisible();
|
||||
const text = (await code.textContent()) ?? "";
|
||||
expect(text.length).toBeGreaterThan(20);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
for (const [input, expected] of [
|
||||
[tinyBase64, "Yes — this is a valid PNG signature."],
|
||||
["aGVsbG8=", "No — the signature does not match a PNG file."],
|
||||
] as const) {
|
||||
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
||||
await expect(async () => {
|
||||
await openTool(page, "verify-is-png");
|
||||
await page.locator(".text-source textarea").fill(input);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
await expect(page.locator(".verdict-text")).toContainText(expected);
|
||||
}).toPass({ timeout: 25_000 });
|
||||
});
|
||||
}
|
||||
|
||||
test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — all pixels are fully opaque.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, transparentPng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"Yes — there are transparent or semi-transparent pixels.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-is-grayscale: colored image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-grayscale");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — colored pixels were found.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-orientation: landscape image → Landscape", async ({ page }) => {
|
||||
await openTool(page, "png-orientation");
|
||||
await uploadImage(page, opaquePng); // 64×48 → landscape
|
||||
await expect(page.locator(".verdict-text")).toContainText("Landscape");
|
||||
});
|
||||
|
||||
test("png-file-size: returns a size in KB", async ({ page }) => {
|
||||
await openTool(page, "png-file-size");
|
||||
await uploadImage(page, opaquePng);
|
||||
const verdict = page.locator(".verdict-text");
|
||||
await expect(verdict).toBeVisible();
|
||||
await expect(verdict).toContainText("KB");
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import type { SourceFile } from "./helpers/fixtures";
|
||||
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
||||
import {
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
openTool,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
} 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" },
|
||||
// ── 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" },
|
||||
// ── geometry ───────────────────────────────────────────────
|
||||
{ id: "flip-png", kind: "image" },
|
||||
{ id: "rotate-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 = (): ReturnType<typeof page.locator> => {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return page.locator('img[alt="Result image"]');
|
||||
case "text-out":
|
||||
return page.locator(".result-pre code");
|
||||
case "verdict":
|
||||
return page.locator(".verdict-text");
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
# ESLint-плагины и дизайн-токены: детали
|
||||
|
||||
Эта часть репозитория подробно описана здесь, а не в корневом `AGENTS.md`:
|
||||
рутина-разработка её не требует, детали нужны только при правке самих
|
||||
линт-правил или конфигов.
|
||||
|
||||
## Структура
|
||||
|
||||
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
||||
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
||||
- `isolation/` — правило изоляции old/new UI.
|
||||
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
||||
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
||||
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
||||
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
||||
токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`.
|
||||
- `web/scripts/`:
|
||||
- `lint-all.mjs` — оркестратор `lint:all`.
|
||||
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
||||
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
|
||||
- `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`) навешаны **только на новый код**
|
||||
(`src/lib/components/**`, `src/routes/**`), чтобы старый код не засыпался
|
||||
предсуществующими ошибками. Старый код (C17) — `src/lib/v1/**`,
|
||||
`src/routes/v1/**`. Когда старый дизайн удалён (C19), scoped-блок убирается и
|
||||
`recommended` включается на весь код.
|
||||
- **Scoped-пути двигаются вместе с папками** (см. `plan-redesign.md` §10): не
|
||||
оставлять устаревшие пути в `eslint.config.js` (C17 уже двигал
|
||||
`routes/preview/**` → `routes/**` и `components/kit/**` → `components/**`; на
|
||||
C19 при наведении порядка в `components/` paths останутся `components/**` +
|
||||
`routes/**`).
|
||||
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
||||
`let`).
|
||||
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
||||
там запрещены tseslint) — при добавлении файлов в `__tests__/` /
|
||||
`__fixtures__/` дописать их туда же.
|
||||
- design-tokens правила применяются к тем же scoped-путям
|
||||
(`src/lib/components/**`, `src/routes/**`).
|
||||
- isolation-правило включено на весь `**/*.{ts,svelte}`.
|
||||
|
||||
## Правила плагина 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/dict-consistency`)
|
||||
|
||||
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md`
|
||||
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только
|
||||
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый
|
||||
словарь подхватывается без правки правила.
|
||||
|
||||
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
||||
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
||||
расхождения:
|
||||
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
|
||||
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
|
||||
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
|
||||
пропавшая целая секция репортится один раз (не по каждому потомку);
|
||||
- **пустые значения**: `""` и строка из одних пробелов;
|
||||
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
||||
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
|
||||
— ловит потерянную при переводе переменную ровно один раз, даже когда
|
||||
отклоняется одна-единственная локаль.
|
||||
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
||||
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
||||
(синтакс, без типов).
|
||||
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
||||
(осознанные отклонения до достижения zero-warn).
|
||||
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||
диске.
|
||||
|
||||
## Токены-префиксы (целевой словарь дизайна)
|
||||
|
||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
||||
переменные, которым разрешено быть hex/rgb, остальные цвета — только `hct()` и
|
||||
только в app.css.
|
||||
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
||||
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
|
||||
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в app.css,
|
||||
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
|
||||
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
|
||||
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
|
||||
подтягиваются через `@csstools/postcss-global-data`).
|
||||
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
|
||||
|
||||
Нюансы stylelint:
|
||||
|
||||
- Правило `custom-property-pattern` тестирует паттерн **без** `--` (`--x` →
|
||||
`x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
|
||||
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
|
||||
`ignore`, не в `except`): пустые строки между подряд идущими токенами
|
||||
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
|
||||
токены в app.css отдельными блоками.
|
||||
|
||||
## Токен-аудит (`check-tokens.mjs`)
|
||||
|
||||
Лёгкий оркестратор поверх `web/scripts/token-audit/*`. Проверяет:
|
||||
|
||||
- **Parity**: каждый цветовой токен из `:root` обязан иметь пару в
|
||||
`[data-theme="dark"]` и наоборот. Производные токены (значение содержит
|
||||
`var()`, напр. `hct(from var(--...))`) из пары исключены — они наследуют тему
|
||||
автоматически.
|
||||
- **hct-only для цветов**: ЛЮБОЕ цветовое значение в app.css обязано быть
|
||||
`hct(...)` — и литерал, и производное `hct(from var(...) h c t)`. Исключения:
|
||||
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
|
||||
`color-mix(...)` (единственный легальный способ смешать два токена).
|
||||
`oklch()/rgb()/#hex` в `--color-*` запрещены.
|
||||
|
||||
Выводит **варнинг** о неиспользуемых токенах app.css (определены, но нигде не
|
||||
используются) — выход он не меняет. Провалом (exit 1) считаются только parity и
|
||||
hct-авторство.
|
||||
|
||||
## Тесты кастомных линт-правил
|
||||
|
||||
Линт-правила покрыты юнит-тестами (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__/no-mixed-imports.test.ts` — isolation-правило через `Linter` API с
|
||||
`cwd` на `__fixtures__`: правило резолвит импорты по реальным файлам, поэтому
|
||||
цели импортов обязаны существовать на диске.
|
||||
- Хелпер `__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 .`), поэтому
|
||||
добавление/правка стабов — тоже работа с валидным кодом.
|
||||
|
||||
## Isolation-правило (`isolation/no-mixed-imports`)
|
||||
|
||||
**Полная взаимная изоляция** старого (v1) и нового (корень) UI. В отличие от
|
||||
`no-restricted-imports`, правило **резолвит** каждый импорт (и `$lib/...`, и
|
||||
относительные `./`/`../`) до реального файла и классифицирует стороны по
|
||||
фактическому пути, поэтому относительным импортом правило не обойти.
|
||||
|
||||
- Старое: `routes/v1/**`, `lib/v1/**`.
|
||||
- Новое: `routes/**` без `v1/**`; корневой `lib` целиком (`lib/**`) без
|
||||
`lib/v1/**` и без общих пакетов.
|
||||
- Общее (разрешено обоим) — только служебные пакеты: `core/`, `theme`. v1 не
|
||||
высовывается: из `lib/v1/**` наружу разрешены импорты лишь в общие пакеты и в
|
||||
собственный форк i18n (`lib/v1/i18n/**`).
|
||||
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
|
||||
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
|
||||
glob-паттернов в настройке, а не код правила.
|
||||
- Изоляция уже достигнута: старый `lib/v1/registry.ts`/`registry-helpers.ts` не
|
||||
тянут `registry-schema` (пилоты add-border/add-stroke работают через
|
||||
`params`), `registry.ts` не импортирует `ToolSchema`; новый `list-tools`
|
||||
использует `$lib/tool-icons.ts`, а не старые `lib/v1/tools/tool-icons.ts`.
|
||||
|
||||
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
|
||||
`app_v1.css`) — известный техдолг, его НЕ чинить и не игнорировать правилами.
|
||||
@@ -0,0 +1,11 @@
|
||||
:root {
|
||||
--color-fg: #101010;
|
||||
|
||||
--space-1: calc(var(--size-1) / 2);
|
||||
|
||||
--z-nav: 100;
|
||||
|
||||
--duration-fast: 120ms;
|
||||
|
||||
--text-md: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
|
||||
@@ -0,0 +1 @@
|
||||
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
|
||||
@@ -0,0 +1,24 @@
|
||||
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",
|
||||
},
|
||||
tools: {
|
||||
addBorder: {
|
||||
title: "Rahmen hinzufügen",
|
||||
description: "Zeichnet einen {kind}-Rahmen.",
|
||||
results: {
|
||||
done: "Rahmen {what}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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>;
|
||||
tools: Record<string, ToolStrings>;
|
||||
};
|
||||
|
||||
export type ToolStrings = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
results?: Record<string, string>;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
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",
|
||||
},
|
||||
tools: {
|
||||
addBorder: {
|
||||
title: "Add border",
|
||||
description: "Draws a {kind} frame.",
|
||||
results: {
|
||||
done: "Border {what}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Dict } from "./dict";
|
||||
|
||||
export const ru: Dict = {
|
||||
header: {
|
||||
workspace: "Рабочая область",
|
||||
catalog: "Каталог",
|
||||
},
|
||||
home: {
|
||||
hero: "Привет, {name}!",
|
||||
restoreLast: "Вернуть {title}",
|
||||
},
|
||||
errors: {
|
||||
sourceRequired: "Нужен исходник",
|
||||
},
|
||||
tools: {
|
||||
addBorder: {
|
||||
title: "Добавить рамку",
|
||||
description: "Рисует рамку {kind}.",
|
||||
results: {
|
||||
done: "Рамка {what}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
|
||||
@@ -0,0 +1 @@
|
||||
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
|
||||
@@ -0,0 +1 @@
|
||||
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
|
||||
@@ -0,0 +1,2 @@
|
||||
// Fixture stub for lint-rule tests: content is ignored (the test supplies the
|
||||
// source code), this file only has to EXIST for import resolution.
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
|
||||