Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf4d8156b | ||
|
|
b59a434c65 | ||
|
|
7bca9f3a3a | ||
|
|
6a44a3d018 | ||
|
|
67fdef4363 | ||
|
|
bd941d5ac5 | ||
|
|
8801c3dd91 | ||
|
|
e2bd786d46 | ||
|
|
73bcbcc26a | ||
|
|
ad01ae92d4 | ||
|
|
0073d3d4cc | ||
|
|
6ab59897d1 | ||
|
|
cdb23fe1c1 | ||
|
|
ca016ada43 | ||
|
|
468beec998 | ||
|
|
712fd3cb2e | ||
|
|
746d6a6c8f | ||
|
|
0933f1cdea | ||
|
|
5f77c097e2 | ||
|
|
5313567e70 | ||
|
|
16d4288912 | ||
|
|
473b62ef53 | ||
|
|
c1d2957ba5 | ||
|
|
ebb2176a2c | ||
|
|
acf9f19f1b | ||
|
|
c187619933 | ||
|
|
becf03f416 | ||
|
|
393f1a6835 | ||
|
|
5ce8681a24 | ||
|
|
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,95 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ^11.20.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: |
|
||||||
|
pnpm-lock.yaml
|
||||||
|
web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install web dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run quality gate
|
||||||
|
run: pnpm verify
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ^11.20.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Configure Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm build
|
||||||
|
env:
|
||||||
|
BASE_PATH: /easy-png-tools
|
||||||
|
|
||||||
|
- name: Upload Pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: web/build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: quality-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Fast quality gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ^11.20.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: |
|
||||||
|
pnpm-lock.yaml
|
||||||
|
web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install web dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run quality gate
|
||||||
|
run: pnpm verify
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: E2E (non-blocking baseline)
|
||||||
|
needs: verify
|
||||||
|
continue-on-error: true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ^11.20.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install web dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm exec playwright install --with-deps chromium firefox webkit
|
||||||
|
|
||||||
|
- name: Run E2E
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm test:e2e
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
|
web/coverage
|
||||||
|
|
||||||
|
# Локальные планы с приватной информацией (выбранное имя домена) — не коммитить
|
||||||
|
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,129 @@
|
|||||||
|
# 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 check`, тесты:
|
||||||
|
`pnpm --dir web test` (Vitest), тесты с coverage-гейтом:
|
||||||
|
`pnpm --dir web test:coverage`, тесты lint-правил:
|
||||||
|
`pnpm --dir web test:rules`, 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 .` (код).
|
||||||
|
- Полный быстрый gate: `pnpm verify`; подробная матрица команд и CI —
|
||||||
|
`docs/quality-gates.md`.
|
||||||
|
- После пуша результат CI проверяется явно: смотреть статусы отдельных job, а не
|
||||||
|
общий conclusion прогона. У `e2e` стоит `continue-on-error`, поэтому прогон с
|
||||||
|
упавшим e2e помечается как `success`. `Quality` workflow запускается на каждом
|
||||||
|
пуше в `main` и на `pull_request`, поэтому browser matrix проверяется сама;
|
||||||
|
ручной запуск нужен только для старого коммита.
|
||||||
|
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
|
||||||
|
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
|
||||||
|
выполняется, даже если предыдущий упал. Новые изменения не должны добавлять
|
||||||
|
предупреждения без записи в baseline.
|
||||||
|
|
||||||
|
## Правила кода
|
||||||
|
|
||||||
|
Синтаксические ограничения описывать не нужно — они проверяются линтером (весь
|
||||||
|
список правил и токены-префиксы в `web/eslint-plugins/README.md`, прогон:
|
||||||
|
`pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то, что
|
||||||
|
линтер не умеет.
|
||||||
|
|
||||||
|
### Svelte 5
|
||||||
|
|
||||||
|
Типизация пропсов через локальный `interface Props` закреплена правилом
|
||||||
|
`conventions/interface-props`; руками его нигде дублировать не надо.
|
||||||
|
|
||||||
|
### E2E
|
||||||
|
|
||||||
|
- Сначала используй `role`, `aria-*`, label и стабильный `href`; `data-testid`
|
||||||
|
разрешён только для source/result/verdict/empty-state.
|
||||||
|
- `test.fixme` допускается только для зарегистрированной задачи в backlog или
|
||||||
|
checklist; после исправления тест и ссылку обновляют вместе.
|
||||||
|
- Полные переводы проверяются в unit-тестах; e2e проверяет поведение.
|
||||||
|
- Playwright-прогоны запускаются только по явной команде пользователя, по одному
|
||||||
|
прогону, без циклов повторов и без фонового запуска: локально matrix идёт с
|
||||||
|
`workers: 1`. Отзывчивость компьютера пользователя важнее скорости проверки —
|
||||||
|
подробности в `docs/agent-workflow.md`.
|
||||||
|
|
||||||
|
### Сначала искать
|
||||||
|
|
||||||
|
Новая константа или тип не заводится, пока не произведён поиск существующего
|
||||||
|
определения (по ключу в соседних модулях и по всему `web/src/`). Это правило в
|
||||||
|
первую очередь для ИИ-агентов: дублирующее определение ухудшает правки в
|
||||||
|
нескольких местах и запутывает. (Синтаксис самих определений — под линтером:
|
||||||
|
строка `conventions/no-string-union-alias` и т.д.)
|
||||||
|
|
||||||
|
### Границы изменений
|
||||||
|
|
||||||
|
- Production — только текущие маршруты и `web/src/lib/components/**`. Старый
|
||||||
|
UI (`/v1/**`) удалён и не возвращается: без redirects и совместимого режима;
|
||||||
|
исторические упоминания живят только в `docs/archive/`. Будущий API-контракт
|
||||||
|
`POST /v1/process` из `docs/plan-platform.md` не связан с удалённым UI-v1.
|
||||||
|
- `core/` и `theme/` — общие модули: их изменение проверяется unit-тестами и
|
||||||
|
smoke-сценариями production-потребителей.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Новый пользовательский текст — только через словари и `t()`. Исключения:
|
||||||
|
бренд, форматы и имена файлов, технические значения, registry source strings и
|
||||||
|
`/kit`. Проверяется warn-правилом `i18n/no-hardcoded-user-text`, детали и
|
||||||
|
список исключений — в `web/eslint-plugins/README.md`.
|
||||||
|
- Новые ключи добавляются во **все** локали сразу: паритет ключей и
|
||||||
|
плейсхолдеров проверяет `i18n/dict-consistency`.
|
||||||
|
|
||||||
|
### Комментарии
|
||||||
|
|
||||||
|
- Комментарий объясняет «почему», а не пересказывает код.
|
||||||
|
- `TODO`/`FIXME` — только зарегистрированные задачи: ссылка на `docs/backlog.md`
|
||||||
|
или issue и критерий закрытия. В коде незарегистрированных пометок не
|
||||||
|
оставляем.
|
||||||
|
|
||||||
|
### Новые lint-правила
|
||||||
|
|
||||||
|
Новое правило сначала появляется в report-only/warn-only режиме, получает
|
||||||
|
fixtures и тесты (`test:rules`), и только после очистки baseline может стать
|
||||||
|
blocking. Учитывай: `lint:all` гоняет ESLint с `--max-warnings=0`, поэтому
|
||||||
|
warn-правило обязано приземлиться без срабатываний на текущем коде.
|
||||||
|
|
||||||
|
|
||||||
|
### Дизайн
|
||||||
|
|
||||||
|
Источник правды по визуальному языку — сам код: `web/src/app.css` (дизайн-токены
|
||||||
|
и глобальные стили), `web/src/lib/components/**` (компоненты и локальные стили)
|
||||||
|
и витрина `/kit`. Приёмка — `docs/checklist-manual-testing.md`, ограничения
|
||||||
|
линтера — `web/eslint-plugins/README.md`. Общие правила, которые линтер не
|
||||||
|
проверяет:
|
||||||
|
|
||||||
|
- Новый дизайн живёт в `web/src/app.css` и применяется ко всем
|
||||||
|
production-маршрутам.
|
||||||
|
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
||||||
|
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
||||||
|
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
||||||
|
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
|
||||||
|
- Новый токен появляется вместе с потребителем: `pnpm --dir web lint:tokens`
|
||||||
|
сообщает о неиспользуемых токенах, и это не блокирует CI только потому, что
|
||||||
|
проверка оставлена предупреждением.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- Здесь держать только обязательные инварианты, команды и ссылки на подробные
|
||||||
|
документы; не превращать `AGENTS.md` в полный справочник.
|
||||||
|
- Индекс активных документов: `docs/README.md`; подробный workflow:
|
||||||
|
`docs/agent-workflow.md`; quality gates: `docs/quality-gates.md`; тестовая
|
||||||
|
архитектура: `docs/testing-strategy.md`.
|
||||||
|
- Новые подробные правила, процедуры и таблицы хранить в `docs/`, а детали
|
||||||
|
конкретных lint-правил — в `web/eslint-plugins/README.md`.
|
||||||
|
- Завершённые или устаревшие документы переносить в `docs/archive/`, указывать
|
||||||
|
дату архивирования и срок удаления; архив — только справка, не источник
|
||||||
|
требований.
|
||||||
|
|
||||||
|
- 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,108 @@
|
|||||||
# 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 verify` | Run the full fast quality gate |
|
||||||
|
| `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,89 @@
|
|||||||
|
# Документация проекта
|
||||||
|
|
||||||
|
> Статус: **active**. Это индекс документов и карта источников истины для
|
||||||
|
> разработчиков и агентов. Подробные правила находятся в самих документах, а
|
||||||
|
> обязательный краткий контракт — в `AGENTS.md`.
|
||||||
|
|
||||||
|
## Порядок чтения
|
||||||
|
|
||||||
|
1. `AGENTS.md` — команды и обязательные инварианты.
|
||||||
|
2. Этот файл — выбрать актуальный документ по теме.
|
||||||
|
3. Соответствующий план или checklist темы.
|
||||||
|
4. Исходники, конфигурация и тесты рядом с изменяемым кодом.
|
||||||
|
|
||||||
|
## Активные документы
|
||||||
|
|
||||||
|
| Документ | Роль | Статус |
|
||||||
|
| ---------------------------------- | ---------------------------------------------------- | ------ |
|
||||||
|
| `AGENTS.md` | Краткий обязательный контракт для агента | active |
|
||||||
|
| `docs/agent-workflow.md` | Подробный порядок исследования, изменения и проверки | active |
|
||||||
|
| `docs/testing-strategy.md` | Locator, fixture, i18n и fixme-policy для e2e | active |
|
||||||
|
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
|
||||||
|
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
|
||||||
|
| `docs/roadmap.md` | Общие направления развития | active |
|
||||||
|
| `docs/plan-seo.md` | SEO/GEO-план | active |
|
||||||
|
| `docs/plan-platform.md` | PWA/API/WASM-план | active |
|
||||||
|
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
|
||||||
|
| `docs/tools-map.md` | Живая карта инструментов | active |
|
||||||
|
| `web/eslint-plugins/README.md` | Детали custom lint rules и token policy | active |
|
||||||
|
| `docs/archive/README.md` | Правила архивирования и удаления старых документов | active |
|
||||||
|
|
||||||
|
`docs/archive/` содержит временную историческую справку и не является текущим
|
||||||
|
источником критериев приёмки. Для старых документов действует срок хранения 30
|
||||||
|
дней; после него их удаляют, а при необходимости достают из git history.
|
||||||
|
|
||||||
|
## Где хранить новую информацию
|
||||||
|
|
||||||
|
- Поведение, которое агент обязан соблюдать сразу, — кратко в `AGENTS.md`.
|
||||||
|
- Точные команды, параметры скриптов и критерии качества — в
|
||||||
|
`docs/quality-gates.md` или в README соответствующей подсистемы.
|
||||||
|
- Архитектура, потоки данных и границы модулей — в отдельном architecture-плане
|
||||||
|
или документе, не дублируя существующие планы.
|
||||||
|
- Реализация lint-правил, fixtures и технические детали ESLint — в
|
||||||
|
`web/eslint-plugins/README.md` и исходниках правил.
|
||||||
|
- Продуктовые решения и ручная приёмка — в backlog, плане и checklist.
|
||||||
|
|
||||||
|
У документа должен быть один источник истины: остальные файлы ссылаются на него,
|
||||||
|
а не пересказывают. Подробные материалы хранятся в `docs/`, чтобы не засорять
|
||||||
|
корень репозитория. Не создавать отдельные `docs/commands.md`,
|
||||||
|
`docs/known-issues.md` или копию backlog: команды принадлежат `quality-gates`,
|
||||||
|
известные проблемы — backlog и checklist. `README.md` в корне остаётся кратким
|
||||||
|
обзором продукта, а не вторым руководством агента.
|
||||||
|
|
||||||
|
Новый документ создаётся только когда темы нет в существующем файле; иначе
|
||||||
|
обновляется источник истины. Активный документ имеет статус (`draft`, `active`,
|
||||||
|
`completed`) и понятную область применения.
|
||||||
|
|
||||||
|
Подробные материалы хранятся в `docs/`, а не в корне. Новый файл создаётся
|
||||||
|
только если темы нет в существующем документе; иначе обновляется источник
|
||||||
|
истины.
|
||||||
|
|
||||||
|
## Текущий baseline
|
||||||
|
|
||||||
|
- production registry содержит 122 инструмента;
|
||||||
|
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
||||||
|
- `pnpm --dir web test` — 46 test files / 534 tests, включая тесты token audit,
|
||||||
|
PostCSS HCT-плагина и правил линтинга;
|
||||||
|
- `pnpm --dir web test:rules` — 5 test files / 104 tests;
|
||||||
|
- `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens
|
||||||
|
от token audit;
|
||||||
|
- coverage `src/lib` — statements 91.77%, branches 83.3%, functions 74.31%,
|
||||||
|
lines 92.53% при порогах 90 / 82 / 72 / 91;
|
||||||
|
- `pnpm verify` проходит;
|
||||||
|
- `pnpm --dir web test:e2e` — 276 тестов в четырёх проектах: 276 passed, 0
|
||||||
|
skipped, 0 failed. Отказ декодера битого PNG зафиксирован per-engine в
|
||||||
|
`web/e2e/png-fixtures.spec.ts`.
|
||||||
|
|
||||||
|
Состав проверок и их семантика описаны в `docs/quality-gates.md`, тестовая
|
||||||
|
архитектура — в `docs/testing-strategy.md`. Baseline обновляется здесь при
|
||||||
|
изменении любого из гейтов.
|
||||||
|
|
||||||
|
## Правила актуальности
|
||||||
|
|
||||||
|
- У активного документа есть статус: `draft`, `active`, `completed` или
|
||||||
|
`archived`.
|
||||||
|
- Ссылки на конкретный файл или команду проверяются при изменении источника
|
||||||
|
истины.
|
||||||
|
- Архивный документ не возвращается в live-критерии без явной сверки.
|
||||||
|
- После каждого шага плана агент останавливается, показывает изменённые файлы,
|
||||||
|
проверки и нерешённые решения, и ждёт ревью.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Workflow для агентов
|
||||||
|
|
||||||
|
> Статус: **active**. Подробная процедура работы с репозиторием; краткие
|
||||||
|
> обязательные правила остаются в `AGENTS.md`. Этот документ не заменяет source
|
||||||
|
> of truth конкретной подсистемы.
|
||||||
|
|
||||||
|
## 1. Подготовка к задаче
|
||||||
|
|
||||||
|
1. Прочитать `AGENTS.md` и `docs/README.md`.
|
||||||
|
2. Найти релевантный активный план, backlog или checklist.
|
||||||
|
3. Проверить `git status`: не перезаписывать чужие незакоммиченные изменения.
|
||||||
|
4. Найти существующие типы, константы, helpers и тестовые паттерны до создания
|
||||||
|
нового определения.
|
||||||
|
5. Определить границу изменения:
|
||||||
|
- production UI — `web/src/routes/**` и `web/src/lib/components/**`;
|
||||||
|
- shared — `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts`.
|
||||||
|
|
||||||
|
Изменение shared-кода требует проверки production-потребителей. Не добавлять
|
||||||
|
исключения линтера ради отдельных модулей без записи в baseline.
|
||||||
|
|
||||||
|
## 2. Порядок изменения
|
||||||
|
|
||||||
|
- Сначала исправляются тесты или контракт, который фиксирует неверное поведение,
|
||||||
|
затем production-код — если это не отдельная продуктовая задача.
|
||||||
|
- Один PR решает одну проблему и не объединяет lint, i18n, UI и архитектурный
|
||||||
|
рефакторинг без необходимости.
|
||||||
|
- Изменения держатся небольшими и атомарными, примерно до 500 строк или меньше.
|
||||||
|
- Не добавлять комментарии, которые пересказывают код. Комментарий объясняет
|
||||||
|
причину решения или фиксирует неочевидный инвариант.
|
||||||
|
- Не коммитить изменения автоматически: коммит делает разработчик после ревью.
|
||||||
|
|
||||||
|
## 3. Проверки
|
||||||
|
|
||||||
|
Команды и их область применения перечислены в `AGENTS.md`; точная матрица и
|
||||||
|
текущая политика warnings/errors поддерживаются в `docs/quality-gates.md`.
|
||||||
|
|
||||||
|
| Изменение | Минимальная проверка |
|
||||||
|
| ------------------------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| Только Markdown | `pnpm check:docs` |
|
||||||
|
| TypeScript/Svelte | `pnpm --dir web check`, `pnpm --dir web test` |
|
||||||
|
| Lint-конфиг или custom rule | `pnpm --dir web test:rules`, `pnpm --dir web lint:all` |
|
||||||
|
| Маршрут, загрузка, результат, download, тема или локаль | `pnpm --dir web test:e2e` |
|
||||||
|
| Перед релизом | полный quality gate из плана Q1 |
|
||||||
|
|
||||||
|
`lint:all` может завершиться успешно при warnings. Это не означает чистый
|
||||||
|
baseline: нужно отдельно показать число errors, warnings и token warnings.
|
||||||
|
|
||||||
|
E2E запускается отдельно от быстрых проверок, потому что поднимает build и
|
||||||
|
статический сервер. Для UI-изменений сначала выбирается минимальный
|
||||||
|
пользовательский поток, затем при необходимости расширяется smoke.
|
||||||
|
|
||||||
|
### Нагрузка на машину
|
||||||
|
|
||||||
|
Агент работает на компьютере пользователя, поэтому тяжёлые прогоны не
|
||||||
|
запускаются «по умолчанию»:
|
||||||
|
|
||||||
|
- Playwright-прогоны запускаются только по явной команде пользователя: один
|
||||||
|
прогон за раз, без циклов повторов и без фонового запуска.
|
||||||
|
- Локально `workers: 1` (`web/playwright.config.ts`). Browser matrix из четырёх
|
||||||
|
проектов проходит медленно, зато не забирает память и не мешает работе
|
||||||
|
пользователя. Скорость проверки уступает отзывчивости компьютера.
|
||||||
|
- Нельзя запускать тяжёлый прогон параллельно с другим прогоном или в момент,
|
||||||
|
когда пользователь работает с приложением.
|
||||||
|
- Диагностика флаков идёт от меньшего к большему: один spec, один проект,
|
||||||
|
`--workers=1`, и только затем полный прогон.
|
||||||
|
- Таймауты не подбираются под конкретную машину. Если проверка падает из-за
|
||||||
|
нехватки ресурсов, это фиксируется как ограничение окружения, а не лечится
|
||||||
|
увеличением `expect.timeout`.
|
||||||
|
- При необходимости повторного прогона сначала спрашивается пользователь.
|
||||||
|
|
||||||
|
## 4. Работа с тестами
|
||||||
|
|
||||||
|
- Поведенческий тест проверяет пользовательский контракт, а не текущую
|
||||||
|
реализацию.
|
||||||
|
- Сначала предпочитать `role`, `aria-*`, label, stable id или href.
|
||||||
|
- `data-testid` ограничен source/result/verdict/empty-state; общие
|
||||||
|
locator-паттерны собраны в `web/e2e/helpers/page.ts`, policy — в
|
||||||
|
`docs/testing-strategy.md`.
|
||||||
|
- `data-testid` использовать только там, где семантического locator нет.
|
||||||
|
- Не фиксировать абсолютное число инструментов, если проверка не связана с этим
|
||||||
|
инвариантом.
|
||||||
|
- Точные переводы проверять в словарях и i18n helpers; в e2e проверять поведение
|
||||||
|
и отдельный locale smoke.
|
||||||
|
- `test.fixme` допускается только для зарегистрированной проблемы или явного
|
||||||
|
тестового долга, с ссылкой на backlog или checklist. После исправления тест и
|
||||||
|
его issue обновляются вместе.
|
||||||
|
- Новые fixtures должны быть воспроизводимыми и не требовать ручного создания
|
||||||
|
больших бинарных файлов.
|
||||||
|
|
||||||
|
## 5. Документация
|
||||||
|
|
||||||
|
Источник истины выбирается по теме:
|
||||||
|
|
||||||
|
- обязательное поведение агента — `AGENTS.md`;
|
||||||
|
- индекс и карта документов — `docs/README.md`;
|
||||||
|
- качество и точные команды — `docs/quality-gates.md`;
|
||||||
|
- тестовая архитектура — `docs/testing-strategy.md`;
|
||||||
|
- lint implementation — `web/eslint-plugins/README.md`;
|
||||||
|
- продуктовые решения — backlog и активные планы.
|
||||||
|
|
||||||
|
Не дублировать длинные объяснения в нескольких файлах. Если правило требует
|
||||||
|
пояснения, в `AGENTS.md` оставить короткую понятную формулировку и ссылку на
|
||||||
|
подробный документ.
|
||||||
|
|
||||||
|
### Архивирование завершённых документов
|
||||||
|
|
||||||
|
- Если план или исследование больше не является рабочим источником, перенести
|
||||||
|
файл в `docs/archive/`.
|
||||||
|
- В начало файла добавить `Статус`, `Архивирован`, `Удалить после` и причину.
|
||||||
|
- Архив хранить 30 дней для быстрой справки; после срока удалить после проверки
|
||||||
|
ссылок. Если решение понадобится снова, использовать git history.
|
||||||
|
- Не переносить активный план в архив только потому, что он пока не начат.
|
||||||
|
|
||||||
|
## 6. Протокол ревью между шагами
|
||||||
|
|
||||||
|
После каждого шага плана агент:
|
||||||
|
|
||||||
|
1. останавливается и не начинает следующий Q-этап;
|
||||||
|
2. перечисляет изменённые файлы и принятые решения;
|
||||||
|
3. указывает выполненные проверки и их фактический результат;
|
||||||
|
4. отдельно перечисляет warnings, fixme, долги и вопросы;
|
||||||
|
5. ждёт подтверждения на продолжение.
|
||||||
|
|
||||||
|
Коммит и пуш делает разработчик. После пуша агент проверяет результат CI и
|
||||||
|
сообщает его вместе с локальными проверками, включая случай, когда нужный job не
|
||||||
|
запускался: статусы смотрятся по отдельным job, потому что общий conclusion
|
||||||
|
прогона с `continue-on-error` у `e2e` остаётся зелёным. Процедура описана в
|
||||||
|
`docs/quality-gates.md`.
|
||||||
|
|
||||||
|
Следующий шаг начинается только после ревью текущего изменения.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Архив документов
|
||||||
|
|
||||||
|
> Статус: **active**. Здесь хранятся завершённые, отменённые и явно устаревшие
|
||||||
|
> документы как временная историческая справка.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Архив помогает быстро посмотреть, какое решение принималось раньше и почему. Он
|
||||||
|
не является источником текущих требований, критериев приёмки или команды. Для
|
||||||
|
актуальных правил используются `AGENTS.md`, `docs/README.md` и активные планы.
|
||||||
|
|
||||||
|
Если решение снова понадобится, его можно восстановить из git history или
|
||||||
|
создать новый активный документ.
|
||||||
|
|
||||||
|
## Обязательные метаданные
|
||||||
|
|
||||||
|
При переносе документа в архив добавить в начало:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> Статус: archived
|
||||||
|
> Архивирован: YYYY-MM-DD
|
||||||
|
> Удалить после: YYYY-MM-DD
|
||||||
|
> Причина: краткое объяснение, почему документ больше не активен
|
||||||
|
> Использование: только историческая справка
|
||||||
|
```
|
||||||
|
|
||||||
|
Дата `Удалить после` — дата архивирования плюс 30 дней. Старые файлы, созданные
|
||||||
|
до введения политики, могут не иметь этих дат; они также считаются архивными и
|
||||||
|
не являются активными требованиями. При следующем касании их можно удалить или
|
||||||
|
привести к шаблону.
|
||||||
|
|
||||||
|
## Порядок архивирования
|
||||||
|
|
||||||
|
1. Убедиться, что документ действительно завершён, отменён или заменён более
|
||||||
|
актуальным источником.
|
||||||
|
2. Добавить обязательные метаданные.
|
||||||
|
3. Переместить файл в `docs/archive/`.
|
||||||
|
4. Проверить ссылки из backlog, планов, checklist и `docs/README.md`.
|
||||||
|
5. Не менять архивный документ под новые требования.
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
Через 30 дней после архивирования:
|
||||||
|
|
||||||
|
- проверить, что документ не нужен для текущей работы;
|
||||||
|
- найти и удалить оставшиеся ссылки на него из активных документов;
|
||||||
|
- удалить файл из `docs/archive/`;
|
||||||
|
- при необходимости отметить удаление в истории изменений.
|
||||||
|
|
||||||
|
До истечения срока архивный файл не превращается обратно в live-документ: если
|
||||||
|
он снова актуален, создаётся новый документ или возвращается версия из git
|
||||||
|
истории с новым статусом и датами.
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# Анализ: набор PNG-утилит для браузера
|
||||||
|
|
||||||
|
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
|
||||||
|
> ранний исследовательский разбор, не являющийся текущим планом Использование:
|
||||||
|
> только историческая справка
|
||||||
|
|
||||||
|
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере.
|
||||||
|
Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких
|
||||||
|
загрузок на сервер, всё считается локально. Стек: **SvelteKit +
|
||||||
|
@sveltejs/adapter-static** (статический экспорт, никакого сервера). Все данные
|
||||||
|
обрабатываются на клиенте, ничего не уходит в сеть.
|
||||||
|
|
||||||
|
Источник идеи: типовой перечень утилит, который встречается в онлайн-наборах
|
||||||
|
PNG-инструментов (~296 операций).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ключевые технологии браузера
|
||||||
|
|
||||||
|
| Задача | Средство |
|
||||||
|
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Декодирование изображений | `<img>` / `createImageBitmap()` / `ImageDecoder` — браузер нативно декодирует PNG, JPEG, WebP, GIF, BMP, ICO, AVIF |
|
||||||
|
| Кодирование PNG/JPEG/WebP | `canvas.toBlob()` / `toDataURL()` — нативные кодировщики |
|
||||||
|
| Пиксельная обработка | `ImageData` + `Uint8ClampedArray` (RGBA), read-only canvas |
|
||||||
|
| Чтение/анализ PNG-структуры | Своя мини-библиотека чанков (IHDR/PLTE/tRNS) или `pngjs` |
|
||||||
|
| Сжатие/оптимизация PNG на байтовом уровне | `UPNG.js` (JS) либо `pngquant`/`oxipng` (WASM) |
|
||||||
|
| GIF-анимации (fade/reveal и т.п.) | `gifenc` / `gif.js` (JS), APNG — `UPNG` |
|
||||||
|
| Кодирование GIF/BMP на байтовом уровне | `gifenc`, простой ручной BMP-энкодер |
|
||||||
|
| Тяжёлая математика без блокировки UI | Web Workers, SharedArrayBuffer, WASM (zlib, квантование, ML) |
|
||||||
|
| Экономия памяти при больших изображениях | `ImageBitmap`, работа тайлами, downscale до декорирования |
|
||||||
|
|
||||||
|
**Вывод для архитектуры:** ~90% утилит — это одна и та же связка «декодировать →
|
||||||
|
прогнать через пиксельную функцию → закодировать». Разумно сделать общий ядро
|
||||||
|
(`core/pipeline.ts` + набор операций над `ImageData`) и тонькие страницы-обёртки
|
||||||
|
в SvelteKit. Каждая утилита = отдельный маршрут со своим UI (drag&drop слева,
|
||||||
|
превью, параметры, скачивание справа).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Уровень EASY — canvas + ImageData, простые попиксельные операции и композиция
|
||||||
|
|
||||||
|
Реализация: загрузка в canvas → `getImageData()` → цикл по пикселям /
|
||||||
|
перекомпоновка / отрисовка на выходной canvas → `toBlob('image/png')`. Без
|
||||||
|
сложных алгоритмов, каждая утилита — часы работы.
|
||||||
|
|
||||||
|
### 2.1 Конвертация форматов и текстовые представления
|
||||||
|
|
||||||
|
- **convert-png-to-jpg / convert-jpg-to-png / convert-webp-to-png /
|
||||||
|
convert-png-to-webp / convert-svg-to-png** — рисование на canvas + `toBlob()`
|
||||||
|
нужного типа
|
||||||
|
- **convert-bmp-to-png / convert-ico-to-png / convert-gif-to-png** (первый кадр)
|
||||||
|
— нативное декодирование браузером
|
||||||
|
- **convert-png-to-bmp** — BMP-формат простой, ~30 строк энкодера
|
||||||
|
- **convert-png-to-base64 / convert-base64-to-png / data-uri (в обе стороны)** —
|
||||||
|
`FileReader.readAsDataURL` + строка
|
||||||
|
- **convert-png-to-bytes / convert-bytes-to-png / convert-png-to-hex /
|
||||||
|
convert-hex-to-png / convert-png-to-rgb-values / convert-rgb-values-to-png** —
|
||||||
|
чтение/запись `ImageData` в текст
|
||||||
|
- **convert-png-to-gif** (один кадр) — `gifenc`, тривиально
|
||||||
|
|
||||||
|
### 2.2 Прозрачность и альфа-канал
|
||||||
|
|
||||||
|
- **create-transparent-png, remove-color-from-png(простая версия),
|
||||||
|
transparent-logo-maker, remove-logo/icon-background (по цвету)** — замена
|
||||||
|
цвета на прозрачность по порогу близости
|
||||||
|
- **remove-alpha-channel-from-png, fill-alpha-channel-in-png,
|
||||||
|
replace-alpha-channel-in-png, extract-alpha-channel-from-png,
|
||||||
|
invert-png-alpha-channel** — манипуляции только с A-компонентой
|
||||||
|
- **generate-png-alpha-mask, show-transparent-png-areas,
|
||||||
|
fill-transparent-png-pixels, convert-semi-transparent-png-to-opaque** — пороги
|
||||||
|
и заливки по альфе
|
||||||
|
- **harden-png-edges** — бинаризация альфы по порогу
|
||||||
|
|
||||||
|
### 2.3 Цвет: замена, тон, каналы, цветовые пространства
|
||||||
|
|
||||||
|
- **change-png-color, change-png-color-tone, change-png-opacity,
|
||||||
|
swap-png-colors, change-png-brightness, change-png-contrast,
|
||||||
|
convert-png-to-grayscale, add-sepia-tone-to-png, change-png-hue,
|
||||||
|
invert-png-colors, reverse-png-colors, convert-png-to-two-colors,
|
||||||
|
convert-png-to-black-and-white, create-monochrome-png,
|
||||||
|
generate-single-color-png** — попиксельные арифметики/преобразования
|
||||||
|
- **extract-png-color-channels, swap-rgba-color-channels,
|
||||||
|
split-png-into-rgb-components** — перестановка/выделение RGBA-каналов
|
||||||
|
- **convert-png-to-hsl/hsv/hsi/cmyk/ycbcr/lab-colors** — известные формулы
|
||||||
|
конвертации (Lab — чуть больше математики, но детерминировано)
|
||||||
|
- **generate-\*-color-palette
|
||||||
|
(complementary/monochromatic/analogous/triadic/tetradic),
|
||||||
|
generate-similar-color-shades, color-wheel-generator, mix-multiple-colors,
|
||||||
|
calculate-average-color, simulate-paint-colors, blend-two-colors,
|
||||||
|
step-between-two-colors, convert-color-names-to-png** — генераторы
|
||||||
|
палитр/смешение без работы с изображением
|
||||||
|
- **posterize-png, create-color-spectrum-png, create-multi-color-gradient-png,
|
||||||
|
generate-png-gradient-palette** — простые градиенты/квантование уровней
|
||||||
|
|
||||||
|
### 2.4 Геометрия, композиция, холст
|
||||||
|
|
||||||
|
- **resize-png, zoom-png, crop-png, cut-png, rotate-png, skew-png, shift-png,
|
||||||
|
change-png-canvas-size, change-png-aspect-ratio, fit-png-in-rectangle,
|
||||||
|
convert-landscape/portrait-png-to-portrait/landscape-png** — операции через
|
||||||
|
`CanvasRenderingContext2D` (`drawImage`, `rotate`, `setTransform`)
|
||||||
|
- **flip-png-horizontally/vertically, mirror-png, reverse-png** —
|
||||||
|
отражение/поворот на 180°
|
||||||
|
- **center-png, center-logo, center-icon** — центровка по bounding box
|
||||||
|
альфа-канала
|
||||||
|
- **duplicate-png, clone-png, multiply-png, create-png-tile,
|
||||||
|
create-symmetric-png, split-png-into-parts** — тайлинг/копирование
|
||||||
|
- **create-round-png, create-square-png, create-star-shaped-png,
|
||||||
|
create-wavy-shaped-png, round-png-corners** — маски-«штампы» на альфе
|
||||||
|
- **add-png-border, add-padding-to-png, add-space-around-png,
|
||||||
|
remove-padding-from-png, remove-space-around-png, remove-png-padding** —
|
||||||
|
расширение/усадка холста
|
||||||
|
- **add-png-background, add-signature/logo/icon/stamp-background** — подложка
|
||||||
|
под прозрачность
|
||||||
|
|
||||||
|
### 2.5 Текст, рамки, простые эффекты
|
||||||
|
|
||||||
|
- **add-text-to-png, convert-text-to-png, create-emoji-png, add-png-watermark,
|
||||||
|
wrap-png-in-polaroid, create-logo-from-text, create-icon-from-text/emoji,
|
||||||
|
create-custom-stamp, create-\*-stamp
|
||||||
|
(round/rect/green/red/number/date-time/monogram/from-text/emoji),
|
||||||
|
create-placeholder-png, create-empty-png, create-custom-png,
|
||||||
|
generate-colorful-png, generate-random-png, generate-white-noise-png,
|
||||||
|
generate-1x1-png, draw-png-grid** — рисование примитивов/случайных данных
|
||||||
|
- **add-noise-to-png, create-glitch-png, pixelate-png, censor-png,
|
||||||
|
erase-part-of-png, randomize-png-pixels** — шум/порча байтов/блоки/кисть
|
||||||
|
- **add-shadow-to-png** — смещение + лёгкое размытие + композиция
|
||||||
|
- **add-artifacts-to-png** — простой вариант: JPEG-раундтрип через canvas
|
||||||
|
|
||||||
|
### 2.6 Анализ и проверки
|
||||||
|
|
||||||
|
- **analyze-png, find-png-file-size, find-png-dimensions,
|
||||||
|
check-if-png-is-landscape-or-portrait, find-png-color-count,
|
||||||
|
check-if-png-is-grayscale, check-if-png-is-transparent,
|
||||||
|
verify-if-image-is-png, pick-png-color, pick-logo/icon-color, png-viewer,
|
||||||
|
test-png, preview-png-on-colorful-background,
|
||||||
|
test-png-on-multiple-backgrounds** — чтение заголовков/ImageData/гистограмм,
|
||||||
|
«пипетка» по клику
|
||||||
|
- **sort-colors** — сортировка списка палитры (не изображения)
|
||||||
|
|
||||||
|
### 2.7 Семейства signature/logo/icon/stamp — простые вариации
|
||||||
|
|
||||||
|
Почти все
|
||||||
|
«-maker/-color/-resize/-crop/-rotate/-bg/-space/-padding/-grayscale/-single-color/-white/-black/-semi-transparent/-shadow»
|
||||||
|
для signature/logo/icon/stamp — это переиспользование операций из разделов выше
|
||||||
|
с одним цветом/боксом. Отдельно перечислены только те, что требуют алгоритмов
|
||||||
|
(см. MEDIUM/HARD).
|
||||||
|
|
||||||
|
> Примерный объём: **≈180–190 утилит уровня EASY.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Уровень MEDIUM — нужен настоящий алгоритм (свёртки, морфология, квантование, анимация)
|
||||||
|
|
||||||
|
Реализация: честные алгоритмы на `ImageData`, часто в Web Worker, при
|
||||||
|
необходимости — обвязка вокруг готовых JS-библиотек.
|
||||||
|
|
||||||
|
### 3.1 Фильтры и свёртки
|
||||||
|
|
||||||
|
- **blur-png, sharpen-png** — box/gaussian blur, ядро свёртки (упрощённо можно
|
||||||
|
`ctx.filter`, но качественно — своя свёртка)
|
||||||
|
- **refine-png-edges, feather-png-edges, clean-png-edges** — работа с
|
||||||
|
полупрозрачными краями: размытие альфы, де-контуринг, suppression
|
||||||
|
полупрозрачности
|
||||||
|
- **add-glow-effect-to-png** — расширение альфы + размытие + цветное наложение
|
||||||
|
|
||||||
|
### 3.2 Морфология и контуры (операции над альфа-маской)
|
||||||
|
|
||||||
|
- **add-stroke-to-png, add-outline-to-png, remove-stroke-from-png,
|
||||||
|
remove-outline-from-png, find-png-outline** — dilation/erosion по альфе,
|
||||||
|
извлечение контура
|
||||||
|
- **make-signature-thicker / make-signature-thinner, make-icon-thicker /
|
||||||
|
make-icon-thinner** — dilation/erosion
|
||||||
|
- **convert-outlined-icon-to-filled-icon** — flood-fill замкнутых областей;
|
||||||
|
**convert-filled-icon-to-outlined-icon** — эрозия
|
||||||
|
- **remove-png-border, remove-border-around-icon, remove-stamp-border** — детект
|
||||||
|
однотонной/декоративной рамки и вырезание
|
||||||
|
|
||||||
|
### 3.3 Квантование и цветовая кластеризация
|
||||||
|
|
||||||
|
- **quantize-png, decrease-png-color-count, set-custom-png-color-palette,
|
||||||
|
add-dithering-to-png** — median cut / octree / Wu + Floyd–Steinberg; готовые
|
||||||
|
JS-библиотеки (image-q и т.п.)
|
||||||
|
- **change-png-quality, create-low-quality-png** — симуляция потери качества
|
||||||
|
через квантование + JPEG-раундтрип
|
||||||
|
- **convert-png-to-color-blocks** — мультипликативный блок-эффект/квантование по
|
||||||
|
блокам
|
||||||
|
- **extract-png-color-palette, find-logo-color-scheme, extract-icon-colors** —
|
||||||
|
частотная гистограмма + выбор топ-N / кластеризация
|
||||||
|
- **create-unique/popular/multi-color/outlier-color-mask** — статистика
|
||||||
|
распределения цветов и выделение по маске
|
||||||
|
|
||||||
|
### 3.4 Выделение объектов по цвету / фону
|
||||||
|
|
||||||
|
- **remove-png-chroma-key** — порог по цвету + деспилл (убрать зелёную
|
||||||
|
окантовку)
|
||||||
|
- **remove-color-from-png (качественная версия), extract-color-from-png,
|
||||||
|
separate-png-colors, separate-logo-colors, separate-icon-colors** —
|
||||||
|
сегментация по близости цвета
|
||||||
|
- **convert-png-to-silhouette, convert-png-to-stencil** — порог + заливка
|
||||||
|
силуэта
|
||||||
|
- **extract-stamp-from-image (по цвету печати)** — хромакей/частотный фильтр по
|
||||||
|
цвету чернил
|
||||||
|
- **remove-png-background (вариант без ИИ)** — проверенный цветовой подход,
|
||||||
|
знакомый по популярным онлайн-наборам PNG-утилит. Работает хорошо на
|
||||||
|
однотонном/градиентном фоне и в ряде случаев удобнее ИИ. Опции и алгоритм:
|
||||||
|
- **Background Color** — выбор удаляемого цвета: пипеткой по изображению или
|
||||||
|
hex/RGB
|
||||||
|
- **Percentage (похожесть цвета)** — slider; 0% = убрать только точный
|
||||||
|
цвет, >0% = убрать соседние оттенки в окрестности цвета (в цветовом
|
||||||
|
пространстве)
|
||||||
|
- **Delete Outer Areas (галочка)** — режим «только внешние пиксели»: удаляются
|
||||||
|
только области, связанные с краями изображения (flood-fill/region-growing от
|
||||||
|
границ). Выключена = удалять по всему холсту. Именно эта опция позволяет
|
||||||
|
убрать фон, не задевая «пятна» того же цвета внутри объекта
|
||||||
|
- **Smooth Edge Line + Radius** — постобработка: полупрозрачная полоса
|
||||||
|
толщиной N px по границе удаления (сгладить переход)
|
||||||
|
- **Preview Deleted Background** — двухцветный предпросмотр маски (что
|
||||||
|
удаляется/что остаётся)
|
||||||
|
- Реализация: выбор цвета → порог по цветовому расстоянию → (опц.) flood-fill
|
||||||
|
от краёв → сглаживание краёв → маска поверх превью. Всё на `ImageData`, без
|
||||||
|
ML.
|
||||||
|
|
||||||
|
### 3.5 Геометрические искажения и пиксель-сортировка
|
||||||
|
|
||||||
|
- **add-whirl-to-png** — свирл/вихрь: обратное отображение координат
|
||||||
|
- **sort-png-pixels** — pixel sorting (сортировка строк/столбцов по
|
||||||
|
яркости/цвету)
|
||||||
|
- **upscale-png / downscale-png** — качественный ресемплинг (Lanczos, mipmap)
|
||||||
|
поверх базового canvas-resize
|
||||||
|
- **remove-signature-background, transparent-png-signature-maker,
|
||||||
|
clean-up-signature-edges, improve-low-quality-signature, clean-logo-edges,
|
||||||
|
clean-icon-edges, fix-low-quality-stamp,
|
||||||
|
convert-rubber-stamp-to-digital-stamp** — комбинация «цвет→прозрачность +
|
||||||
|
де-шум + морфология краёв»
|
||||||
|
- **convert-image/png/jpg/icon/logo/signature-to-stamp** — стилизация в
|
||||||
|
двухцветную «печать» (порог + зернистость + края)
|
||||||
|
|
||||||
|
### 3.6 Анимации и GIF-энкодинг
|
||||||
|
|
||||||
|
- **convert-png-to-gif, slowly-reveal-png, create-disappearing-png, fade-in-png,
|
||||||
|
fade-out-png, create-scrolling-png, create-png-sequence-from-gif** —
|
||||||
|
декомпозиция кадров GIF / генерация последовательности кадров +
|
||||||
|
`gifenc`/`UPNG` (APNG)
|
||||||
|
|
||||||
|
> Примерный объём: **≈71 утилита уровня MEDIUM.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Уровень HARD — WASM, ML, длительная обработка, тонкая подгонка алгоритма
|
||||||
|
|
||||||
|
Реализация: WASM-модули, ML-модели в браузере, длительная обработка с
|
||||||
|
прогресс-барами.
|
||||||
|
|
||||||
|
- **compress-png / reduce-png-file-size / optimize-png** — сжатие на уровне
|
||||||
|
формата: нужен качественный PNG-энкодер с контролем zlib-фильтров/битовой
|
||||||
|
глубины и оптимизаторы. Решение: **pngquant / oxipng / zopfli (WASM)** +
|
||||||
|
подгонка под «качество→размер» (это главный кандидат на HARD — именно здесь
|
||||||
|
тюнинг бесконечен)
|
||||||
|
- **remove-png-background (вариант с ИИ)** — опциональное улучшение к
|
||||||
|
MEDIUM-версии: ML-сегментация (Rembg/DeepLab в WASM или ONNX) для произвольных
|
||||||
|
фото, где цветовой матчинг бессилен. Начинать можно без него — не-ИИ версия
|
||||||
|
уже даёт рабочий инструмент
|
||||||
|
- **extract-signature-from-image** — детект подписи на скане/фото с произвольным
|
||||||
|
фоном: де-шум, контраст, ML-детекция
|
||||||
|
- **convert-handwritten-signature-to-digital-signature** — очистка и «оцифровка»
|
||||||
|
почерка (ML, денойзинг, выравнивание)
|
||||||
|
- **extract-barcode-from-image** — декодирование штрих-кодов/QR: готовая
|
||||||
|
библиотека **zxing-wasm**, сопряжение с камерой/фото
|
||||||
|
|
||||||
|
> Итого **6 утилит уровня HARD** (все — опциональные улучшения/надстройки). Всё
|
||||||
|
> остальное (≈290) — EASY/MEDIUM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Итоговая сводка
|
||||||
|
|
||||||
|
**EASY — ~185 утилит.** canvas + ImageData. Примеры: crop, resize, rotate,
|
||||||
|
конвертации, альфа-канал, палитры, текст, рамки, анализ.
|
||||||
|
|
||||||
|
**MEDIUM — ~71 утилита.** Алгоритмы, Web Workers, gifenc. Примеры: blur,
|
||||||
|
sharpen, морфология, квантование, дизеринг, pixel sorting, анимации GIF,
|
||||||
|
remove-background (без ИИ).
|
||||||
|
|
||||||
|
**HARD — 6 утилит.** WASM (pngquant/oxipng/zxing), ML (ONNX/Rembg). Примеры:
|
||||||
|
compress/optimize, remove-background (ИИ), extract-signature, barcode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Рекомендации по реализации
|
||||||
|
|
||||||
|
1. **Единое ядро обработки** — `src/lib/core/`: загрузка файла → `ImageData`,
|
||||||
|
библиотека операций (`color.ts`, `alpha.ts`, `geometry.ts`, `filters.ts`,
|
||||||
|
`morphology.ts`, `quantize.ts`, `channels.ts`), экспорт через `toBlob`.
|
||||||
|
Каждая утилита — тонкая SvelteKit-страница + общие компоненты (DropZone,
|
||||||
|
Preview, Download).
|
||||||
|
2. **Порядок внедрения:** MVP = ~40 топовых EASY (crop, resize, rotate, convert,
|
||||||
|
alpha, grayscale, brightness, палитры, watermark, add-text, add-background,
|
||||||
|
pick-color, analyze). Затем остальные EASY → MEDIUM пакетами по темам
|
||||||
|
(фильтры, морфология, квантование, анимации). `remove-png-background` (без
|
||||||
|
ИИ) — отличная «витринная» MEDIUM-фича ранних этапов: не требует ML и
|
||||||
|
переиспользует `remove-color` + flood-fill + сглаживание краёв. HARD — в
|
||||||
|
последнюю очередь.
|
||||||
|
3. **Производительность:** вся пиксельная работа и тяжёлые алгоритмы — в Web
|
||||||
|
Worker; большие изображения декодировать сразу в downscale для превью;
|
||||||
|
прогресс-бары для MEDIUM/HARD.
|
||||||
|
4. **SEO/структура:** SvelteKit + static adapter — каждая утилита отдельный
|
||||||
|
маршрут (`/crop-png`, `/rotate-png`), что даёт удобные адреса и повторяет
|
||||||
|
распространённую структуру наборов PNG-инструментов.
|
||||||
|
5. **Семейства logo/icon/stamp/signature** — переиспользуют общие операции; в
|
||||||
|
коде это конфиг-опции той же страницы (например `/resize-png` и
|
||||||
|
`/resize-icon` = один компонент с разными подписями/дефолтами). Не плодить
|
||||||
|
дублирующий код.
|
||||||
@@ -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,647 @@
|
|||||||
|
# План: линтеры, тесты и качество кодовой базы
|
||||||
|
|
||||||
|
> Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
|
||||||
|
> Причина: все этапы Q0–Q8 выполнены или закрыты решениями; рабочие части
|
||||||
|
> перенесены в `docs/quality-gates.md`, `docs/testing-strategy.md`,
|
||||||
|
> `docs/agent-workflow.md`, `AGENTS.md`, `web/eslint-plugins/README.md` и
|
||||||
|
> `docs/backlog.md`. Использование: только историческая справка
|
||||||
|
>
|
||||||
|
> Выполнены Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3; открытых шагов не осталось.
|
||||||
|
>
|
||||||
|
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
||||||
|
> инфраструктурных скриптов. Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3 выполнены.
|
||||||
|
> Решения, важные за пределами плана, живут в `docs/quality-gates.md`,
|
||||||
|
> `docs/testing-strategy.md` и `AGENTS.md`.
|
||||||
|
>
|
||||||
|
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
||||||
|
> проверок, стабилизировать тестовые контракты и только после этого расширять
|
||||||
|
> автоматические правила.
|
||||||
|
|
||||||
|
## 1. Границы и принципы
|
||||||
|
|
||||||
|
1. Сначала stabilизировать существующие проверки и тестовые контракты, а не
|
||||||
|
добавлять новые сложные lint-правила.
|
||||||
|
2. Старый UI удалён: production работает на корневых маршрутах, а `/v1/**` и
|
||||||
|
v1-specific код больше не являются частью приложения.
|
||||||
|
3. Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты.
|
||||||
|
4. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и
|
||||||
|
smoke-сценариями production-потребителей.
|
||||||
|
5. Изменения делаются небольшими атомарными частями, в соответствии с
|
||||||
|
`AGENTS.md`.
|
||||||
|
6. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме,
|
||||||
|
получает fixtures и тесты, и только после очистки baseline становится
|
||||||
|
blocking.
|
||||||
|
7. Детали реализации правил остаются в `web/eslint-plugins/README.md`;
|
||||||
|
`AGENTS.md` содержит краткие обязательные политики, понятные агенту без
|
||||||
|
дополнительных поисков, и ссылки на подробные документы.
|
||||||
|
|
||||||
|
### Политика удаления legacy v1
|
||||||
|
|
||||||
|
- Q3 — отдельная атомарная cleanup-задача; поддержка v1 не возвращается.
|
||||||
|
- Старые маршруты и UI-файлы удаляются без redirects и без совместимого
|
||||||
|
maintenance-режима.
|
||||||
|
- В рабочей кодовой базе не оставлять UI-v1-пути, исключения или документацию,
|
||||||
|
которые выглядят как действующий источник совместимости.
|
||||||
|
- Исторические упоминания допустимы только в `docs/archive/`.
|
||||||
|
- Будущий API-контракт `POST /v1/process` из `docs/plan-platform.md` не связан с
|
||||||
|
удалённым UI-v1 и остаётся в планах.
|
||||||
|
|
||||||
|
## 2. Текущее состояние quality-контура
|
||||||
|
|
||||||
|
- `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование,
|
||||||
|
docs и production build; deploy выполняет его перед сборкой.
|
||||||
|
- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; после Q7 вычищены
|
||||||
|
4 неиспользуемых design token, token audit тоже чист.
|
||||||
|
- E2E-контракт использует semantic locators и общий source-aware schema
|
||||||
|
resolver; активных known-issue `test.fixme` нет.
|
||||||
|
- `ToolError` хранится как key/vars и переводится только при выводе; смена
|
||||||
|
локали обновляет уже видимый alert.
|
||||||
|
- E2E гоняется в четырёх Playwright-проектах: полный Chromium, Firefox и WebKit
|
||||||
|
для browser-critical спецификаций и mobile Chromium 390×844. Job остаётся
|
||||||
|
non-blocking, число воркеров ограничено из-за памяти.
|
||||||
|
|
||||||
|
## 3. Этапы выполнения
|
||||||
|
|
||||||
|
### Q0. Зафиксировать baseline и синхронизировать документацию
|
||||||
|
|
||||||
|
**Статус:** готов к ревью. **Оценка:** S. **Зависимости:** нет.
|
||||||
|
|
||||||
|
- Проверить фактические команды и зафиксировать их в quality-gate документации.
|
||||||
|
- Вынести расширенный workflow агента в `docs/agent-workflow.md`, оставив в
|
||||||
|
`AGENTS.md` только обязательные инварианты и ссылки.
|
||||||
|
- Обновить `docs/backlog.md`:
|
||||||
|
- отметить dictionary linter как реализованный, но не доведённый до
|
||||||
|
zero-warning;
|
||||||
|
- убрать противоречие по кнопке Generate;
|
||||||
|
- синхронизировать числа инструментов и тестовых сценариев.
|
||||||
|
- Восстановить или исправить ссылку на раздел G в
|
||||||
|
`docs/checklist-manual-testing.md`.
|
||||||
|
- Убрать устаревшее утверждение про hardcoded debt в `kit` из `AGENTS.md`, если
|
||||||
|
оно больше не соответствует коду.
|
||||||
|
- Перенести явно устаревшие `docs/analysis.md` и `docs/sorting-backend.md` в
|
||||||
|
`docs/archive/`, добавить даты архивирования и удаления через 30 дней.
|
||||||
|
- Создать `docs/README.md` как индекс активных документов и карту источников
|
||||||
|
истины; подробные правила не дублировать в корневых файлах.
|
||||||
|
|
||||||
|
**Готово, когда:** один документ описывает текущие команды, актуальный baseline
|
||||||
|
и все известные `test.fixme` имеют ссылки на зарегистрированный баг.
|
||||||
|
|
||||||
|
### Q1. Единый quality gate и CI
|
||||||
|
|
||||||
|
**Статус:** готов к ревью. **Проверено:** `pnpm verify` проходит; E2E оставлен
|
||||||
|
отдельным non-blocking job. **Оценка:** S–M. **Зависимости:** Q0.
|
||||||
|
|
||||||
|
Добавить read-only `verify`-команду, которая запускает:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm --dir web check
|
||||||
|
pnpm --dir web test
|
||||||
|
pnpm --dir web test:rules
|
||||||
|
pnpm --dir web lint:all
|
||||||
|
pnpm --dir web exec prettier --check .
|
||||||
|
pnpm check:docs
|
||||||
|
pnpm --dir web build
|
||||||
|
```
|
||||||
|
|
||||||
|
E2E оставить отдельным тяжёлым гейтом:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm --dir web test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
- Не дублировать вручную аргументы ESLint/Stylelint в нескольких скриптах;
|
||||||
|
оркестратор должен вызывать package scripts или единый общий модуль.
|
||||||
|
- Описать фактические команды и semantics ошибок/warnings в
|
||||||
|
`docs/quality-gates.md`; здесь нужна точная процедура, а не длинный дубль в
|
||||||
|
`AGENTS.md`.
|
||||||
|
- На первом этапе явно разделить `errors` и `warnings`.
|
||||||
|
- После очистки i18n baseline добавить строгий режим `--max-warnings=0`.
|
||||||
|
- Добавить отдельный workflow для pull request: typecheck, unit, rule-тесты,
|
||||||
|
lint, format и build.
|
||||||
|
- E2E сначала запускать отдельным job; сделать его blocking после закрытия
|
||||||
|
известных ошибок и стабилизации baseline. Условия перехода и текущая политика
|
||||||
|
описаны в `docs/quality-gates.md`, раздел «Политика e2e в CI».
|
||||||
|
|
||||||
|
**Готово, когда:** одна команда воспроизводит все быстрые проверки, а deploy не
|
||||||
|
может пройти при type/unit/lint/build-ошибке.
|
||||||
|
|
||||||
|
### Q2. Политика i18n и zero-warning baseline
|
||||||
|
|
||||||
|
**Статус:** готов к ревью. **Проверено:** i18n lint даёт 0 warnings, rule-тесты
|
||||||
|
и полный `pnpm verify` проходят. **Оценка:** M. **Зависимости:** Q0, Q1.
|
||||||
|
|
||||||
|
- `BASE_LOCALE` — единственный источник registry-English fallback для
|
||||||
|
`tools.*.title` и `tools.*.description`; остальные локали требуют физические
|
||||||
|
ключи.
|
||||||
|
- Временно неиспользуемые `tools.*.params` исключены pattern-based policy до
|
||||||
|
отдельной очистки мёртвого словаря; пустые значения и placeholders не
|
||||||
|
подавляются.
|
||||||
|
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые
|
||||||
|
option-группы из `ru.ts`.
|
||||||
|
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается
|
||||||
|
`BASE_LOCALE`.
|
||||||
|
- `lint-all` запускает ESLint с `--max-warnings=0`; unused-token сообщение
|
||||||
|
оставалось неблокирующим и закрыто в Q7.
|
||||||
|
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим
|
||||||
|
этапом.
|
||||||
|
|
||||||
|
### Q3. Удалить UI-v1 (cleanup завершён)
|
||||||
|
|
||||||
|
**Статус:** выполнен. **Проверено:** `pnpm verify` и production E2E smoke
|
||||||
|
проходят; после проверки обновлены конфиги и активные документы. **Оценка:** M.
|
||||||
|
**Зависимости:** Q0, Q1, Q2.
|
||||||
|
|
||||||
|
- Удалены `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific
|
||||||
|
i18n/tests и fixtures.
|
||||||
|
- Удалены isolation plugin, его тест и все v1-исключения из lint-конфигураций.
|
||||||
|
- Убраны v1-ссылки из production-кода и активных документов; исторические
|
||||||
|
упоминания остаются в `docs/archive/`.
|
||||||
|
- Проверены production build, verify и отдельный E2E smoke production-маршрутов.
|
||||||
|
|
||||||
|
**Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют;
|
||||||
|
следующий этап — Q5.
|
||||||
|
|
||||||
|
### Q4. Тестовый контракт e2e
|
||||||
|
|
||||||
|
**Статус:** выполнен. **Проверено:** `pnpm verify` и полный Playwright smoke
|
||||||
|
проходят; 105 тестов passed, 4 known-issue теста skipped. **Оценка:** M.
|
||||||
|
**Зависимости:** Q0–Q3.
|
||||||
|
|
||||||
|
- Добавлен `docs/testing-strategy.md` с locator, fixture, i18n и `test.fixme`
|
||||||
|
policy.
|
||||||
|
- В production markup добавлены только разрешённые hooks для source/result/
|
||||||
|
verdict/empty-state; текстовые элементы получили accessible labels, фильтры —
|
||||||
|
`aria-pressed`, а каталог — семантические heading/status landmarks.
|
||||||
|
- E2E переведён на `role`, label и stable `href`; удалены `TOTAL`/`GROUPS`,
|
||||||
|
exact fuzzy counts, internal CSS selectors, `alt`-locators и полные verdict
|
||||||
|
strings. Точные verdict-переводы перенесены в unit-тесты.
|
||||||
|
- Helpers централизованы в `web/e2e/helpers/page.ts`; исправлены stale
|
||||||
|
theme/source/locale checks. Theme fixme снят.
|
||||||
|
- Q5-фиксы resize/crop, generator, Reset и error-сценария не смешивались с этим
|
||||||
|
этапом.
|
||||||
|
|
||||||
|
**Результат:** изменение формулировки, класса или порядка элементов не ломает
|
||||||
|
поведенческий тест без изменения пользовательского контракта; следующий этап —
|
||||||
|
Q6.
|
||||||
|
|
||||||
|
### Q5. Закрыть известные preview-баги
|
||||||
|
|
||||||
|
**Статус:** выполнен. **Проверено:** unit, Q5 E2E smoke и полный `pnpm verify`
|
||||||
|
проходят; активных known-issue `test.fixme` нет. **Оценка:** M–L.
|
||||||
|
**Зависимости:** Q4.
|
||||||
|
|
||||||
|
- Добавлен общий opt-in `defaultFromSource` resolver в `registry-schema`: он
|
||||||
|
принимает source dimensions, обновляет только source-aware поля при upload и
|
||||||
|
используется в UI, executor и worker. `resize-png` и `crop-png` получают
|
||||||
|
размеры текущего source; static/generator/text schemas не затронуты.
|
||||||
|
- Reset сохраняет source/text, восстанавливает source-aware defaults и запускает
|
||||||
|
deferred auto-run через debounce.
|
||||||
|
- Генераторы проверены в auto-run без кнопки Generate; кнопка не добавляется.
|
||||||
|
- `ToolError` хранится как key/vars, переводится derived-значением при выводе и
|
||||||
|
обновляется при смене локали. E2E использует независимый invalid pixel-count
|
||||||
|
сценарий.
|
||||||
|
- Resize/crop, generator, Reset и localization fixme сняты; Q6a расширяет
|
||||||
|
покрытие, не смешиваясь с исправлением этих контрактов.
|
||||||
|
|
||||||
|
**Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и
|
||||||
|
`docs/checklist-manual-testing.md`; Q6a выполнен, следующий этап — Q6b.
|
||||||
|
|
||||||
|
### Q6. Расширение unit и browser-покрытия
|
||||||
|
|
||||||
|
**Оценка:** M–L. **Зависимости:** Q4, Q5.
|
||||||
|
|
||||||
|
#### Q6a. Executor protocol, fallback и debounce
|
||||||
|
|
||||||
|
**Статус:** выполнен. Unit-тесты покрывают debounce, worker image/string/
|
||||||
|
verdict/files protocol, `ToolError` transport, constructor/postMessage/onerror/
|
||||||
|
malformed fallback, source-aware sanitization и DOM metadata. Browser smoke
|
||||||
|
проверяет rapid auto-run и no-worker fallback; deferred transport fallback
|
||||||
|
покрыт unit-тестами.
|
||||||
|
|
||||||
|
- `execute(tool, ctx)` сохраняет публичный API; `createExecutor` и protocol
|
||||||
|
handler являются внутренними test seams.
|
||||||
|
- `ToolError` не запускает direct retry повторно; transport/protocol failures
|
||||||
|
отключают worker и переходят в direct fallback.
|
||||||
|
- DOM-зависимые registry entries помечены `domOnly`, поэтому зелёный smoke не
|
||||||
|
скрывает нормальный worker fallback.
|
||||||
|
- `debounce` покрыт fake timers; E2E подтверждает последние значения при быстрых
|
||||||
|
последовательных изменениях генератора.
|
||||||
|
|
||||||
|
#### Q6b. FileResult → ZIP → download
|
||||||
|
|
||||||
|
**Статус:** выполнен. Q6a закрыл worker-транспорт; Q6b добавил проверяемый ZIP
|
||||||
|
seam, unit round-trip для `FileResult`, защиту пустого результата и error/busy
|
||||||
|
контракт download. Отдельный browser-сценарий скачивает архив и проверяет имена
|
||||||
|
частей и PNG-сигнатуры.
|
||||||
|
|
||||||
|
- `buildZipEntries` не зависит от DOM; `downloadZip` остаётся UI-обёрткой.
|
||||||
|
- `SchemaToolView` переиспользует `downloadBlob`, ловит ошибки кодирования и
|
||||||
|
блокирует повторный download на время сборки.
|
||||||
|
- E2E использует `downloadResultFile` и `unzipSync`, не добавляя новые
|
||||||
|
`data-testid`; files-инструменты не проходят через image-smoke.
|
||||||
|
|
||||||
|
#### Q6c. Output MIME/quality
|
||||||
|
|
||||||
|
**Статус:** выполнен. Q6c покрывает текущий фиксированный output-контракт
|
||||||
|
JPG/WebP/BMP, не превращаясь в UX-райз кнопки Download. Registry проверяет
|
||||||
|
согласованность MIME/extension/quality-поля, unit — границы quality, browser —
|
||||||
|
реальные сигнатуры файлов и разницу размера при изменении quality.
|
||||||
|
|
||||||
|
- `ToolEntry.output` остаётся декларативным описанием формата; новый селектор
|
||||||
|
форматов не добавляется.
|
||||||
|
- `validateOutputQuality` отделяет проверку 0..1 от DOM-рендеринга; JPEG/WebP
|
||||||
|
используют quality `1..100` из schema, BMP не имеет quality-параметра.
|
||||||
|
- E2E проверяет JPEG `FF D8 FF`, WebP `RIFF/WEBP`, BMP `BM` и PNG-фолбэк.
|
||||||
|
- Ошибки кодирования и unsupported MIME используют существующие i18n-ключи.
|
||||||
|
|
||||||
|
#### Q6d. Malformed и special PNG fixtures
|
||||||
|
|
||||||
|
**Статус:** выполнен. Runtime-fixtures покрывают CRC, truncated, palette и
|
||||||
|
16-bit без бинарных файлов в git. E2E фиксирует браузерный контракт: critical
|
||||||
|
CRC/truncated отклоняются без падения, ancillary CRC и хвост после `IEND`
|
||||||
|
допускаются, palette/16-bit декодируются. Unit фиксирует контракт анализаторов
|
||||||
|
для RGBA-данных, которые браузер отдаёт после нормализации.
|
||||||
|
|
||||||
|
- `web/e2e/helpers/fixtures.ts` собирает indexed, 16-bit, CRC-mutation,
|
||||||
|
truncation и tail-варианты из уже существующих `crc32`/`chunk`.
|
||||||
|
- Отклонение битых файлов — свойство декодера движка, а не единый продуктовый
|
||||||
|
контракт: Q6f.3 зафиксировал, что Firefox и WebKit декодируют `idat-cut` и
|
||||||
|
`truncated-no-iend`, а WebKit ещё и `crc-bad-idat`. Это ожидаемое поведение,
|
||||||
|
которое закреплено per-engine в `web/e2e/png-fixtures.spec.ts`.
|
||||||
|
- Обязательное требование при любом исходе: файл не роняет страницу, ошибка
|
||||||
|
показывается alert-ом, а не «пустым» результатом.
|
||||||
|
- Поведение `createImageBitmap` является частью browser-контракта, а не
|
||||||
|
самостоятельного PNG-парсера product-кода.
|
||||||
|
- Сырое сообщение браузера при decode-ошибке не локализуется в Q6d; это
|
||||||
|
отдельная задача в backlog.
|
||||||
|
|
||||||
|
#### Q6e. Registry-wide smoke
|
||||||
|
|
||||||
|
**Статус:** выполнен. Smoke расширен до всех 122 production tools из registry:
|
||||||
|
image→image, image→text, text→image, generator input→image и verdict. FileResult
|
||||||
|
и malformed/special PNG не дублируются — они уже покрыты Q6b/Q6d.
|
||||||
|
|
||||||
|
- `tools-smoke.spec.ts` расширен image/text-output кейсами, включая все
|
||||||
|
незакрытые color/geometry инструменты.
|
||||||
|
- `generators.spec.ts` использует единый data-harness для всех генераторов;
|
||||||
|
`single-color-png` сохраняет отдельную проверку auto-run/размеров.
|
||||||
|
- Text-input сценарии используют общий `renderText` helper, включая
|
||||||
|
`data-uri-to-png`.
|
||||||
|
- Component-тесты не добавлены: Vitest работает в node без DOM/Testing Library,
|
||||||
|
а текущий e2e-контракт уже покрывает поведение preview. Выбор test seam или
|
||||||
|
DOM-окружения перенесён в Q6f.
|
||||||
|
|
||||||
|
#### Q6f.1. Layout resolver seam
|
||||||
|
|
||||||
|
**Статус:** выполнен. Чистая логика группировки полей вынесена из
|
||||||
|
`SchemaFields.svelte` в `resolveLayoutGroups` и покрыта unit-тестами на
|
||||||
|
дедупликацию, неизвестные поля, пустые группы, `cols` и default-хвост.
|
||||||
|
|
||||||
|
- Компонент использует общий resolver, поэтому unit-тесты блокируют изменения
|
||||||
|
layout-логики без DOM/Testing Library.
|
||||||
|
- `SchemaPreview` view-model и DOM-компоненты не менялись; их seam остаётся
|
||||||
|
отдельным решением Q6f.2.
|
||||||
|
- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
|
||||||
|
|
||||||
|
#### Q6f.2. SchemaPreview model seam
|
||||||
|
|
||||||
|
**Статус:** выполнен. Вычисления `sourceValue`, `resultValue`, `formatValue` и
|
||||||
|
`hasResult` вынесены в `buildSchemaPreviewModel`; Svelte-компонент использует
|
||||||
|
модель, а unit-тесты проверяют все input/result-ветки без DOM.
|
||||||
|
|
||||||
|
- Translator передаётся в модель явно, поэтому unit-тесты не зависят от locale
|
||||||
|
state и проверяют только view-model.
|
||||||
|
- image/text/verdict/files и пустые результаты покрыты в `pnpm verify`.
|
||||||
|
- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
|
||||||
|
|
||||||
|
#### Q6f.3. Browser matrix и mobile viewport
|
||||||
|
|
||||||
|
**Статус:** выполнен. Playwright запускает четыре проекта: полный Chromium,
|
||||||
|
Firefox и WebKit для browser-critical спецификаций и mobile Chromium 390×844 с
|
||||||
|
`hasTouch` для навигации, каталога, генераторов и pipeline.
|
||||||
|
|
||||||
|
- `web/playwright.config.ts`: `browserContractTests` — `pipeline.spec.ts`,
|
||||||
|
`png-fixtures.spec.ts`, `text-and-verdicts.spec.ts`; `mobileTests` —
|
||||||
|
`navigation.spec.ts`, `catalog.spec.ts`, `generators.spec.ts`,
|
||||||
|
`pipeline.spec.ts`.
|
||||||
|
- `workers` — 1 локально и 2 в CI. Browser matrix ограничена памятью: при 12
|
||||||
|
воркерах на машине с 16 GB RAM браузеры не успевают поднять worker, и тесты
|
||||||
|
падают без ошибок (проверено и в Chromium). Локально matrix поэтому медленная,
|
||||||
|
но почти не нагружает компьютер. Per-project лимита воркеров в Playwright нет.
|
||||||
|
- Per-project `expect.timeout` не задаётся: Firefox и WebKit отдают результат
|
||||||
|
так же быстро, а расхождения объясняются памятью, а не скоростью движка.
|
||||||
|
- Декодеры движков расходятся, и это зафиксировано как ожидаемое поведение:
|
||||||
|
`png-fixtures.spec.ts` хранит список движков, отклоняющих каждый битый файл, и
|
||||||
|
не пропускает проверки. В любом браузере файл не должен ронять страницу, а
|
||||||
|
отказ декодера показывается alert-ом.
|
||||||
|
- CI устанавливает `chromium firefox webkit`; job остаётся non-blocking.
|
||||||
|
- Локальный прогон: 276 passed, 0 skipped, 0 failed. Visual snapshots и coverage
|
||||||
|
thresholds не добавлялись — это Q6f.4.
|
||||||
|
|
||||||
|
#### Q6f.4. Coverage thresholds
|
||||||
|
|
||||||
|
**Статус:** выполнен для чистой логики. Vitest считает покрытие только по
|
||||||
|
`src/lib`, с явным списком исключений, и `pnpm verify` падает при падении ниже
|
||||||
|
порога.
|
||||||
|
|
||||||
|
- `web/vitest.config.ts`: provider `v8`, `reporter: text-summary`, пороги
|
||||||
|
statements 90, branches 82, functions 72, lines 91.
|
||||||
|
- Пороги поставлены на 1.5–2.5 п.п. ниже измеренного baseline (91.77 / 83.3 /
|
||||||
|
74.31 / 92.53), поэтому gate ловит регрессию, а не текущий долг.
|
||||||
|
- Scope — логика, которую node-Vitest вообще может исполнить. Исключены
|
||||||
|
браузерные модули `core/domText.ts` и `core/io.ts` (canvas,
|
||||||
|
`createImageBitmap`), data-модули `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts`,
|
||||||
|
rune-файлы `*.svelte.ts`, entry worker-а и type-only `components/define.ts`.
|
||||||
|
Причина каждого исключения зафиксирована в конфиге и в
|
||||||
|
`docs/testing-strategy.md`.
|
||||||
|
- `registry/*` (47–81% statements, 25–47% functions) в scope остаётся: это
|
||||||
|
обычная непокрытая логика, а не ограничение окружения. Покрывается e2e-smoke
|
||||||
|
Q6e, но unit-порог её не скрывает.
|
||||||
|
- `verify` теперь выполняет `test:coverage` вместо `test`: набор тестов тот же,
|
||||||
|
двойного прогона нет.
|
||||||
|
- Порог functions самый слабый (74.31% против 91.77%): добавление инструмента с
|
||||||
|
непокрытым `run` быстрее всего опускает именно его. Это ожидаемо, порог держит
|
||||||
|
запас.
|
||||||
|
- DOM-тесты `SchemaToolView`/controls сознательно не начаты: node-Vitest не
|
||||||
|
имеет DOM, а e2e уже покрывает поведение. Задача живёт в `docs/backlog.md`.
|
||||||
|
|
||||||
|
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
|
||||||
|
file-result-инструментам.
|
||||||
|
|
||||||
|
#### Q6f.5. Visual snapshots
|
||||||
|
|
||||||
|
**Статус:** решено не делать. Обоснование, чем закрыта потребность сейчас и при
|
||||||
|
каком условии стоит вернуться — в `docs/testing-strategy.md`, раздел 8.
|
||||||
|
|
||||||
|
### Q7. Укрепление lint-инфраструктуры
|
||||||
|
|
||||||
|
**Оценка:** M. **Зависимости:** Q1, Q2, Q3.
|
||||||
|
|
||||||
|
#### Q7a. Очистка неиспользуемых design tokens
|
||||||
|
|
||||||
|
**Статус:** выполнен. Token audit чист: `All app.css tokens are used somewhere`.
|
||||||
|
|
||||||
|
- Удалены `--color-text-accent-muted` (light и dark, иначе ломается parity),
|
||||||
|
`--space-text-s: 0`, `--size-content-max: 880px` и `--duration-m: 0.3s`.
|
||||||
|
- Проверено, что мёртвые токены — это пыль, а не признак хардкода: дублирующих
|
||||||
|
значений (`max-width`, `0.3s`, muted-цвет) в `src` нет. Потребители
|
||||||
|
`space-text-m/xl/2xl`, `size-content-max-wide` и `size-workspace-min`
|
||||||
|
используются, поэтому шкалы не тронуты.
|
||||||
|
- Шкалы остались неполными намеренно (`duration-s/l` без `m`, `space-text` без
|
||||||
|
`s`): мёртвый элемент шкалы хуже, чем «дырявая» шкала, а вернуть его можно
|
||||||
|
вместе с потребителем.
|
||||||
|
- Режим unused-аудита решён в Q7d: он остаётся предупреждением.
|
||||||
|
|
||||||
|
#### Q7b. Тесты token audit и PostCSS HCT-плагина
|
||||||
|
|
||||||
|
**Статус:** выполнен. У обоих не было ни одного теста, хотя HCT-плагин
|
||||||
|
(`web/scripts/postcss-hct.mjs`, 430 строк) пересчитывает всю палитру.
|
||||||
|
|
||||||
|
- `web/scripts/__tests__/postcss-hct.test.mjs` — 17 тестов: literal и
|
||||||
|
производный `hct()`, seed из `:root` и из hex, цепочка hct-токенов, refs
|
||||||
|
`h/c/t`, `calc/min/max/clamp`, обёртка hue, клампы tone/chroma, несколько
|
||||||
|
вызовов в одном объявлении, а также warn-and-keep для неизвестного seed,
|
||||||
|
циклической ссылки и битого тела `hct()`.
|
||||||
|
- `web/scripts/__tests__/token-audit.test.mjs` — 15 тестов на parity
|
||||||
|
(light-only, dark-only, derived, не-цвет), authorship (hct, color-mix,
|
||||||
|
seed-токены, хардкод с локацией, игнор не-цветов и обычных свойств), unused и
|
||||||
|
предикаты `isColorValue`/`isDerived`.
|
||||||
|
- Ожидания не зашивают вывод плагина, а сравнивают его с эквивалентной
|
||||||
|
литеральной записью: `hct(from var(--seed) h c calc(t + 10))` должен совпасть
|
||||||
|
с `hct(120 40 60)`, если seed — `hct(120 40 50)`. Иначе тест просто
|
||||||
|
зафиксировал бы текущее поведение.
|
||||||
|
- Единственный seam: `checkUnused(root, used = collectUsedTokens())` —
|
||||||
|
зависимость от чтения `src` стала инъецируемой, production-вызов не изменился.
|
||||||
|
- `vitest.config.ts` подхватывает `scripts/**/*.test.mjs`, поэтому скрипты
|
||||||
|
покрываются обычным прогоном и `verify`; `test:rules` не трогали.
|
||||||
|
- Проверка мутацией выявила дыру: первая версия тестов не ловила кламп tone в
|
||||||
|
ветке `from` (смена `clamp(t, 0, 100)` на `0, 90` проходила зелёной). Добавлен
|
||||||
|
отдельный тест на производный кламп; после этого мутация падает.
|
||||||
|
|
||||||
|
#### Q7c. Базовый JS recommended для инфраструктуры линтинга
|
||||||
|
|
||||||
|
**Статус:** выполнен. `eslint-plugins/**` и `scripts/**` больше не вне линта.
|
||||||
|
|
||||||
|
- `web/eslint.config.js`:
|
||||||
|
`toolingFiles = ["**/eslint-plugins/**/*.js", "**/scripts/**/*.mjs"]` получают
|
||||||
|
`js.configs.recommended` плюс `languageOptions` с `globals.node` (иначе
|
||||||
|
`no-undef` ругается на `console`, `process` и `URL` в Node-скриптах).
|
||||||
|
- Полные TS/svelte-наборы туда не подключаются: это `.mjs` и конфиги, а не
|
||||||
|
продуктовый код.
|
||||||
|
- Масштаб оказался небольшим: 28 проблем, из них 26 — `no-undef` на
|
||||||
|
Node-глобалах и только 2 настоящие: мёртвые импорты `existsSync` и `resolve` в
|
||||||
|
`scripts/serve-static.mjs`. Линтер сразу нашёл пыль в инфраструктуре, для
|
||||||
|
которой его раньше не было.
|
||||||
|
- `eslint-plugins/**` дал ноль проблем, а новые тесты `scripts/__tests__` тоже
|
||||||
|
попали под правило.
|
||||||
|
- Проверено пробой: после включения неиспользуемая переменная и вызов
|
||||||
|
неопределённого глобала в `scripts/` дают ошибки, то есть правило применилось,
|
||||||
|
а не молча пропустило файлы.
|
||||||
|
|
||||||
|
#### Q7d. Решения по оставшимся вопросам
|
||||||
|
|
||||||
|
**Статус:** закрыт тремя решениями.
|
||||||
|
|
||||||
|
1. **Обычный CSS под те же токен-правила, что Svelte styles — узко.** В `src`
|
||||||
|
всего один CSS-файл, и он же словарь токенов, поэтому переносить туда
|
||||||
|
`no-hardcoded-in-svelte` и подобные нечего: stylelint уже проверяет
|
||||||
|
`src/**/*.css` (префиксы, запрет `!important`, цвет только у brand-токенов),
|
||||||
|
а `check-tokens.mjs` дублирует проверку цвета. Реальный пробел был один —
|
||||||
|
`var()` может ссылаться на несуществующий токен, — его закрыл новый модуль
|
||||||
|
`web/scripts/token-audit/refs.mjs` (см. Q7e). Широкую унификацию механизмов
|
||||||
|
решено не делать: это лишний слой поверх дублирующихся проверок.
|
||||||
|
2. **Конфиги под рекомендации — только JS.** `*.config.js`
|
||||||
|
(`eslint`/`postcss`/`stylelint`) добавлены в `toolingFiles`; по измерению они
|
||||||
|
дают 0 проблем, поэтому это бесплатно. TS-конфиги (`vite`, `vitest`,
|
||||||
|
`playwright`) не подключены: им потребовался бы `tseslint`-парсер и запись
|
||||||
|
`vite.config.ts` в `allowDefaultProject`, а выигрыш близок к нулю. Попутно
|
||||||
|
удалена мёртвая запись `svelte.config.js` в `allowDefaultProject` — такого
|
||||||
|
файла нет.
|
||||||
|
3. **Unused-аудит остаётся предупреждением.** Правило «новый токен — вместе с
|
||||||
|
потребителем» закреплено в `AGENTS.md`. Причина: `verify` входит в
|
||||||
|
deploy-workflow, поэтому стилевой долг блокировал бы деплой сайта; кроме
|
||||||
|
того, чтение токена из JS (`getPropertyValue`) аудит не видит и дал бы ложное
|
||||||
|
срабатывание. Baseline чистый, пыль вычищена в Q7a.
|
||||||
|
|
||||||
|
#### Q7e. Проверка резолва `var()` в `app.css`
|
||||||
|
|
||||||
|
**Статус:** выполнен. Аудит падает, если `var(--x)` в `app.css` указывает на
|
||||||
|
токен, которого нет в этом же файле.
|
||||||
|
|
||||||
|
- `web/scripts/token-audit/refs.mjs` + вызов в `check-tokens.mjs`; режим —
|
||||||
|
ошибка, а не предупреждение, потому что это дефект (объявление молча
|
||||||
|
отбрасывается), а не стилистический долг.
|
||||||
|
- `var(--x, fallback)` не считается ошибкой: fallback легален без определения
|
||||||
|
токена.
|
||||||
|
- 7 тестов в `scripts/__tests__/token-audit.test.mjs`: резолв в обеих темах,
|
||||||
|
опечатка с локацией, fallback, дедуп повторов, ссылка внутри определения
|
||||||
|
токена, `var()` в обычном свойстве.
|
||||||
|
- Проверено на живом файле: временная опечатка `var(--color-bordr)` дала
|
||||||
|
`Unresolved var() references` и код выхода 1; `app.css` восстановлен байт в
|
||||||
|
байт.
|
||||||
|
|
||||||
|
### Q8. Новые автоматические правила
|
||||||
|
|
||||||
|
**Оценка:** L. **Зависимости:** Q4, Q7. **Разбивка:** три атомарных шага, каждый
|
||||||
|
заканчивается либо работающим warn-правилом с нулевым baseline, либо измерением
|
||||||
|
с записью вывода. Механически важно: `lint-all` запускает ESLint с
|
||||||
|
`--max-warnings=0`, поэтому warn-правило обязано приземлиться без срабатываний
|
||||||
|
на текущем коде.
|
||||||
|
|
||||||
|
#### Q8.1. Пользовательские тексты вне i18n
|
||||||
|
|
||||||
|
**Статус:** выполнен. Пилот `i18n/no-hardcoded-user-text` (warn) с нулевым
|
||||||
|
baseline; 2 реальных нарушения исправлены.
|
||||||
|
|
||||||
|
- Измерение до правила: 42 строковых атрибута пользовательского текста, все — в
|
||||||
|
`/kit`; вне кита текстовые узлы дали 2 находки. Итог после исключений: 2
|
||||||
|
настоящих бага и 2 бренда, которые план требовал исключить.
|
||||||
|
- `web/eslint-plugins/i18n/no-hardcoded-user-text.js`: узлы `SvelteText` плюс
|
||||||
|
строковые литералы пользовательских атрибутов; `<style>`/`<script>`
|
||||||
|
пропускаются.
|
||||||
|
- Исключения из плана реализованы списками, а не эвристиками: форматы/единицы/
|
||||||
|
имена файлов/версии/размеры, ALL CAPS идентификаторы (registry source
|
||||||
|
strings), `/kit` через scope, бренд через опцию `allowWords` (в конфиге, а не
|
||||||
|
в коде правила).
|
||||||
|
- Исправлены 2 бага: `ColorPairControl.svelte` показывал английские `From`/`To`.
|
||||||
|
Ключи `ui.from`/`ui.to` уже существовали в обоих словарях — компонент их
|
||||||
|
просто не использовал, поэтому новые ключи не понадобились.
|
||||||
|
- 30 тестов в `__tests__/no-hardcoded-user-text.test.ts`; мутацией проверено,
|
||||||
|
что правило не декоративное: с пустым `allowWords` бренд начинает репортиться.
|
||||||
|
- Известные слепые зоны, зафиксированные как границы пилота: текст, собранный в
|
||||||
|
скрипте и переданный выражением, и текст вне Svelte-шаблонов (например, в
|
||||||
|
атрибутах, приходящих из registry). Расширение поверхности — отдельная работа,
|
||||||
|
а не молчаливое расширение правила.
|
||||||
|
|
||||||
|
#### Q8.2. Дублирование HTML/Svelte-разметки
|
||||||
|
|
||||||
|
**Статус:** измерено, решение — правило не делать.
|
||||||
|
|
||||||
|
Одноразовый аудит (скрипт после измерения удалён) по 65 `.svelte` в
|
||||||
|
`components/**` и `routes/**`: при пороге 5 нормализованных строк — **одна**
|
||||||
|
пара, при пороге 3 строки — 17 пар, и все они шум (`</label>`, `{/each}`,
|
||||||
|
`{#if children}`). Единственная осмысленная находка — побайтово идентичные
|
||||||
|
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte`; она заведена в
|
||||||
|
`docs/backlog.md` (идея 16).
|
||||||
|
|
||||||
|
Почему автоматика отменена:
|
||||||
|
|
||||||
|
- построчное сравнение Svelte-разметки принципиально ненадёжно: одинаковый
|
||||||
|
контейнер с разным содержимым даёт ложное срабатывание, а намеренно общий
|
||||||
|
класс — ложное отсутствие сигнала;
|
||||||
|
- чтобы не ловить шум, порог пришлось бы поднять до 12+ строк, и тогда правило
|
||||||
|
срабатывает ровно один раз — на паре страниц, которые по замыслу должны
|
||||||
|
различаться; дальше нужна либо allowlist-запись, либо принудительный
|
||||||
|
рефакторинг;
|
||||||
|
- контроль остаётся на ревью: расхождение видно в диффе, а поведение ловит e2e
|
||||||
|
(`navigation.spec.ts` открывает все маршруты, `catalog.spec.ts` проверяет
|
||||||
|
каталог), а извлечение общей разметки уже закреплено конвенцией в `AGENTS.md`.
|
||||||
|
|
||||||
|
#### Q8.3. Согласованность комментариев
|
||||||
|
|
||||||
|
**Статус:** решение — автоматический lint не делать, это process policy.
|
||||||
|
|
||||||
|
Описания инструментов различаются осознанно, и ловить это текстовым правилом
|
||||||
|
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
|
||||||
|
`TODO`/`FIXME` из кода перенесены в `docs/backlog.md` (7 записей), в коде не
|
||||||
|
осталось ни одного незарегистрированного. Правило-детектор не добавлялось.
|
||||||
|
|
||||||
|
## 4. Что описать в `AGENTS.md`
|
||||||
|
|
||||||
|
В `AGENTS.md` добавить только следующие неочевидные правила:
|
||||||
|
|
||||||
|
1. Какой набор команд считается полным quality gate и что означает успешный
|
||||||
|
результат при warnings.
|
||||||
|
2. Production-scope и политику удаления v1: новые изменения относятся только к
|
||||||
|
production UI; исторические UI-v1-упоминания остаются в архиве.
|
||||||
|
3. Политику e2e: semantic locators, допустимый `data-testid`, запрет
|
||||||
|
бессмысленных counts/classes/alt/exact prose.
|
||||||
|
4. Политику i18n: новые пользовательские тексты через i18n; registry source,
|
||||||
|
brand, formats и `/kit` — явные исключения.
|
||||||
|
5. Жизненный цикл `test.fixme`: только для зарегистрированного бага, с ссылкой;
|
||||||
|
исправление и удаление теста происходят вместе.
|
||||||
|
6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на
|
||||||
|
работу.
|
||||||
|
7. Shared-модули `core`/`theme` требуют проверки production-потребителей.
|
||||||
|
|
||||||
|
Синтаксические списки ESLint-правил и подробные токены остаются в
|
||||||
|
`web/eslint-plugins/README.md`.
|
||||||
|
|
||||||
|
### Политика документации для агентов
|
||||||
|
|
||||||
|
`AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем
|
||||||
|
только обязательный рабочий контракт: команды, production-scope, ключевые
|
||||||
|
инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять
|
||||||
|
причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка;
|
||||||
|
длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в
|
||||||
|
`docs/` или в README рядом с соответствующей подсистемой.
|
||||||
|
|
||||||
|
Принцип выбора места документа:
|
||||||
|
|
||||||
|
1. **Поведение агента, которое нельзя пропустить** — кратко в `AGENTS.md`.
|
||||||
|
2. **Точная команда, скрипт, параметр или критерий качества** — в
|
||||||
|
`docs/quality-gates.md` или в README соответствующей подсистемы.
|
||||||
|
3. **Архитектурное решение, поток данных и границы модулей** — в
|
||||||
|
`docs/architecture-overview.md` или существующем плане.
|
||||||
|
4. **Реализация конкретного lint-правила и его fixtures** — только в
|
||||||
|
`web/eslint-plugins/README.md` и исходниках правила.
|
||||||
|
5. **Продуктовая задача, backlog и ручная приёмка** — в `docs/backlog.md`,
|
||||||
|
активном плане или `docs/checklist-manual-testing.md`.
|
||||||
|
|
||||||
|
Подробные документы не дублируются в нескольких местах: у документа должен быть
|
||||||
|
один источник истины, а остальные файлы ссылаются на него. Все подробные
|
||||||
|
материалы хранятся в `docs/`, чтобы не засорять корень репозитория.
|
||||||
|
|
||||||
|
Активный документ должен иметь статус (`draft`, `active`, `completed` или
|
||||||
|
`archived`) и понятную область применения. Архивные планы не используются как
|
||||||
|
текущие критерии приёмки без отдельной сверки с кодом. Новый документ создаётся
|
||||||
|
только когда темы нет в существующем файле; иначе обновляется существующий.
|
||||||
|
|
||||||
|
### Архивирование и срок хранения
|
||||||
|
|
||||||
|
Завершённые, отменённые или явно устаревшие документы переносятся в
|
||||||
|
`docs/archive/`, а не остаются в корне `docs/` как рабочие планы. При переносе
|
||||||
|
добавляются дата архивирования, дата удаления и причина. Архивный документ —
|
||||||
|
временная справка для быстрого просмотра прошлых решений, но не источник текущих
|
||||||
|
требований.
|
||||||
|
|
||||||
|
Срок хранения после архивирования — 30 дней. После этой даты документ можно
|
||||||
|
удалить после проверки ссылок; если он снова нужен, его восстанавливают из git
|
||||||
|
history или пересоздают как новый активный документ. Подробная процедура и
|
||||||
|
шаблон метаданных находятся в `docs/archive/README.md`.
|
||||||
|
|
||||||
|
### Какие документы подготовить
|
||||||
|
|
||||||
|
| Документ | Назначение | Когда создавать/обновлять |
|
||||||
|
| ------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа |
|
||||||
|
| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` |
|
||||||
|
| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI |
|
||||||
|
| `docs/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры |
|
||||||
|
| `docs/architecture-overview.md` | Карта `core`, registry, executor, production UI и shared-модулей | Q5 или перед крупным рефакторингом; не дублировать существующие планы |
|
||||||
|
| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами |
|
||||||
|
| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики |
|
||||||
|
|
||||||
|
Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию
|
||||||
|
backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и
|
||||||
|
checklist. `README.md` в корне остаётся кратким обзором продукта, а не вторым
|
||||||
|
руководством агента.
|
||||||
|
|
||||||
|
## 5. Порядок PR
|
||||||
|
|
||||||
|
1. **Q0** — синхронизация backlog/checklist/AGENTS.
|
||||||
|
2. **Q1** — `verify` и PR quality gate.
|
||||||
|
3. **Q2** — i18n parity и strict warning gate.
|
||||||
|
4. **Q3** — удалить v1 отдельной атомарной задачей.
|
||||||
|
5. **Q4** — тестовый контракт и stale-тесты для production UI.
|
||||||
|
6. **Q5** — preview-баги и снятие `fixme`.
|
||||||
|
7. **Q6** — расширенное покрытие.
|
||||||
|
8. **Q7** — тесты lint-инфраструктуры и unused tokens.
|
||||||
|
9. **Q8** — report-only duplicate/text audits; blocking — только после baseline.
|
||||||
|
|
||||||
|
Каждый PR должен быть атомарным и небольшим; не объединять `SchemaPreview`
|
||||||
|
refactor, новые i18n-правила и исправление продуктовых багов в одну работу.
|
||||||
|
|
||||||
|
Продуктовые направления, которые план не начинает (платформа PWA/API/WASM,
|
||||||
|
plural-формы, batch/архивы, region-инструменты), живут в `docs/backlog.md` и
|
||||||
|
`docs/roadmap.md`; границы этого плана — в разделе 1.
|
||||||
@@ -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,335 @@
|
|||||||
|
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
|
||||||
|
> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в
|
||||||
|
> backlog. Использование: только историческая справка
|
||||||
|
|
||||||
|
# План: i18n в preview (новый UI)
|
||||||
|
|
||||||
|
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
|
||||||
|
> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2
|
||||||
|
> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и
|
||||||
|
> ограничения — в `docs/testing-strategy.md` и `docs/backlog.md`.
|
||||||
|
|
||||||
|
## 1. Что получается
|
||||||
|
|
||||||
|
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
|
||||||
|
`v1`) использует модуль `lib/i18n`: переключатель RU/EN в шапке работает по
|
||||||
|
локали, строки и ошибки локализованы. Остаточные задачи — довести паритет
|
||||||
|
словарей до zero-warning и спроектировать plural-формы.
|
||||||
|
|
||||||
|
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
|
||||||
|
копия `lib/i18n` → `lib/v1/i18n`, у нового UI — своя независимая ветка
|
||||||
|
`lib/i18n`, которую дальше меняем без оглядки на v1 (Фаза 0). Переключённая
|
||||||
|
локаль (localStorage) остаётся общей для обеих версий — язык переживает переход
|
||||||
|
между страницами.
|
||||||
|
|
||||||
|
## 2. Текущее состояние (исследование)
|
||||||
|
|
||||||
|
### Уже есть
|
||||||
|
|
||||||
|
- `lib/i18n/locale.svelte.ts` — состояние локали (runes), персист в
|
||||||
|
localStorage, обновление `document.documentElement.lang`.
|
||||||
|
- `lib/i18n/t.ts` — перевод по точечному ключу с фолбэком на базовую локаль и
|
||||||
|
интерполяцией `{var}`.
|
||||||
|
- `lib/i18n/dict.ts` — тип `Dict`, `BASE_LOCALE = "en"` (база — английский, ru —
|
||||||
|
перевод), `LOCALES = ["ru", "en"]`.
|
||||||
|
- `lib/i18n/ru.ts` (~1351 строка) и `en.ts` (188 строк) — полные переводы
|
||||||
|
инструментов v1 по id (title/description/params/options/results).
|
||||||
|
- `lib/i18n/tool-strings.ts` — хелперы для v1
|
||||||
|
(`toolTitle/toolDescription/ paramLabel/optionLabel/searchDoc`).
|
||||||
|
- `lib/i18n/matching.ts` — кросс-языковой поиск (ё→е, диакритика, скоринг).
|
||||||
|
- Тесты: `i18n.test.ts`, `tools-i18n.test.ts`, `matching.test.ts`,
|
||||||
|
`smoke-i18n.test.ts`, `search-coverage.test.ts`.
|
||||||
|
- `+layout.svelte` уже вызывает `initLocale()` на клиенте.
|
||||||
|
|
||||||
|
### Пробелы в новом UI
|
||||||
|
|
||||||
|
- `LangToggle.svelte` — заглушка с локальным `let lang = $state("RU")`, не
|
||||||
|
связана с `locale.svelte.ts`.
|
||||||
|
- Новый UI почти целиком на хардкод-английском:
|
||||||
|
- ~150+ строк по ~30 компонентам и маршрутам (заголовки, кнопки, аria,
|
||||||
|
плейсхолдеры, подписи секций);
|
||||||
|
- 122 инструмента с английскими `title`/`description` в `lib/registry/*.ts`;
|
||||||
|
- ~60+ подписей опций select;
|
||||||
|
- ~30 заголовков layout-групп (`title: "Canvas"`, `"Background"`, …);
|
||||||
|
- вердикты анализаторов — raw English строки (`"Yes — valid PNG signature."`),
|
||||||
|
при том что v1 возвращает ключи словаря (`"verifyYes"`) и рендерит их через
|
||||||
|
`tools[id].results`;
|
||||||
|
- `labelOf(id)` в `SchemaFields.svelte` генерирует подписи полей из field ID
|
||||||
|
(keepAspect → "Keep Aspect");
|
||||||
|
- категории в `catalog.ts` (`GROUP_LABELS`) — English uppercase, хотя в `dict`
|
||||||
|
уже есть ключи `categories.X`.
|
||||||
|
- Из всех новых компонентов `t()` вызывает только `SchemaToolView.svelte`, и
|
||||||
|
только для ошибок (`ToolError.key`).
|
||||||
|
|
||||||
|
### Совпадение регистров
|
||||||
|
|
||||||
|
- Новый registry и старый v1 registry используют **одинаковые id** инструментов
|
||||||
|
(resize-png, flip-png, …) — переводы title/description из v1 `ru.ts` служат
|
||||||
|
исходником для новой ветки `lib/i18n/ru.ts` (копирование данных при наполнении
|
||||||
|
Фазы 2, не общий модуль).
|
||||||
|
- **Расхождение**: param ids в ru.ts (`width`/`height`/`keepAspect`) не равны
|
||||||
|
field ids новоно schema (`size` dimension, `anchor`). Нужны новые переводы
|
||||||
|
полей.
|
||||||
|
- `ru.ts` tools[id].results для анализаторов уже содержит ключи (`verifyYes`,
|
||||||
|
`grayscaleNo`, `orientationPortrait` и т.д.).
|
||||||
|
|
||||||
|
### Архитектурные ограничения
|
||||||
|
|
||||||
|
- Изоляция old↔new (`isolation/no-mixed-imports`): сейчас `lib/i18n/` — shared.
|
||||||
|
По решению задачи (Фаза 0) i18n **форкается**: v1 получает полную копию
|
||||||
|
`lib/v1/i18n`, а `lib/i18n` переходит в ветку «new» (правило изоляции запретит
|
||||||
|
старому коду импортить новый i18n). До форка в общем модуле два скрытых
|
||||||
|
v1-импорта: `dict.ts` тянет `CategoryId` из `../v1/categories`,
|
||||||
|
`tool-strings.ts` — `$lib/v1/registry`; после форка оба живут только в копии.
|
||||||
|
Новому UI нужен параллельный `schema-tool-strings.ts` поверх `$lib/registry`.
|
||||||
|
- Tone detection в `SchemaTextResult.svelte`: `/^(yes|true)/i` по value —
|
||||||
|
сломается при русских вердиктах («Да», «Нет»).
|
||||||
|
- `tools-i18n.test.ts` покрывает только v1 TOOLS; нужен аналог для нового
|
||||||
|
registry.
|
||||||
|
- `known-issues.spec.ts`:
|
||||||
|
`test.fixme("error message is localized, not a raw i18n key")`.
|
||||||
|
|
||||||
|
## 3. Как делаем
|
||||||
|
|
||||||
|
Принципы:
|
||||||
|
|
||||||
|
- Мелкие атомарные коммиты (правило AGENTS.md); самому не коммитить — коммитит
|
||||||
|
разработчик после ревью.
|
||||||
|
- Изменения «снизу вверх»: сначала общий слой i18n, потом словари, потом UI.
|
||||||
|
- Каждый этап заканчивается зелёными `pnpm --dir web lint` +
|
||||||
|
`pnpm --dir web check` + тесты.
|
||||||
|
- Решения по §5 зафиксированы и учитываются с первых правок словарей.
|
||||||
|
|
||||||
|
## 4. Фазы
|
||||||
|
|
||||||
|
### Фаза 0: Форк i18n для v1 + подготовка общего слоя
|
||||||
|
|
||||||
|
Выполняется строго до остальных фаз, без изменения поведения UI. Цель — два
|
||||||
|
независимых i18n-слоя: v1 получает замороженную копию, новый UI — свою ветку,
|
||||||
|
которую дальше меняем свободно. На выходе фазы — зелёные `lint`, `check`,
|
||||||
|
`test`, `test:rules`.
|
||||||
|
|
||||||
|
- **0.1** Форк: скопировать `web/src/lib/i18n/` → `web/src/lib/v1/i18n/` (полный
|
||||||
|
снимок: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
|
||||||
|
`matching.ts`, `tool-strings.ts` + все 5 тестов). v1 работает дальше только с
|
||||||
|
копией — поздние правки нового i18n её не затрагивают.
|
||||||
|
- **0.2** Привести копию к v1-канону (относительные пути от нового места):
|
||||||
|
- копия `dict.ts`: импорт `CategoryId` `../v1/categories` → `../categories` (=
|
||||||
|
`lib/v1/categories`);
|
||||||
|
- копии тестов (`tools-i18n.test.ts`, `search-coverage.test.ts`,
|
||||||
|
`smoke-i18n.test.ts`): `../v1/registry` → `../registry` (=
|
||||||
|
`lib/v1/registry`);
|
||||||
|
- импорт `$lib/v1/registry` в копии `tool-strings.ts` остаётся валидным;
|
||||||
|
- перепроверить остальные относительные и `$lib`-пути в копии.
|
||||||
|
- **0.3** Переключить импорты старого UI: все `$lib/i18n/*` в `routes/v1/**` и
|
||||||
|
`lib/v1/components/**` (39 импортов в 23 файлах) → `$lib/v1/i18n/*`.
|
||||||
|
- **0.4** Очистить новый `lib/i18n/` от v1-специфики, иначе isolation поймает
|
||||||
|
new→old:
|
||||||
|
- удалить `tool-strings.ts` (завязан на v1-типы `ParamDef`/`ToolEntry`; для
|
||||||
|
нового UI вместо него `schema-tool-strings.ts`, см. 0.8);
|
||||||
|
- удалить v1-центричные тесты `tools-i18n.test.ts`, `search-coverage.test.ts`,
|
||||||
|
`smoke-i18n.test.ts` (они импортируют `../v1/registry`; живут только в
|
||||||
|
копии);
|
||||||
|
- остаются: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
|
||||||
|
`matching.ts`, `i18n.test.ts`, `matching.test.ts`.
|
||||||
|
- **0.5** Обновить правило изоляции и конфиги:
|
||||||
|
- `isolation/no-mixed-imports.js`: добавить `lib/i18n/**` в `DEFAULT_NEW`
|
||||||
|
(копия по-прежнему old через `lib/v1/**`);
|
||||||
|
- `eslint-plugins/__tests__/no-mixed-imports.test.ts`: кейсы «old → i18n
|
||||||
|
чисто» (строки 83–89) и «lib/i18n — shared» (строки 99–105) обновить под
|
||||||
|
новую классификацию: old→новый i18n = нарушение; добавить фикстуру
|
||||||
|
`__fixtures__/src/lib/v1/i18n/t.ts` для кейса old-копия;
|
||||||
|
- `eslint.config.js`: в `allowDefaultProject` добавить фикстуру
|
||||||
|
`eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts`; поправить комментарий
|
||||||
|
изоляции «Общее (core/, i18n/, theme)» → «Общее (core/, theme)»;
|
||||||
|
- `eslint-plugins/README.md`: убрать i18n из списка shared (§ Isolation).
|
||||||
|
- **0.6** Новый `lib/i18n/dict.ts`: `CategoryId` переключить с
|
||||||
|
`../v1/categories` на `../categories` (новый структурно-идентичный тип) —
|
||||||
|
скрытых v1-зависимостей в новом i18n не остаётся.
|
||||||
|
- **0.7** Проверка форка: `pnpm --dir web lint`, `check`, `test` (v1-копия
|
||||||
|
тестов подхватывается `vitest include: src/**/*.test.ts`), `test:rules`;
|
||||||
|
смоук: v1-страницы и оба переключателя RU/EN работают как раньше, языки обеих
|
||||||
|
версий согласованы.
|
||||||
|
- **0.8** Добавить `lib/i18n/schema-tool-strings.ts` поверх `$lib/registry`
|
||||||
|
(новый тип `ToolEntry`):
|
||||||
|
- `toolTitle(tool)`, `toolDescription(tool)` с фолбэком на registry English;
|
||||||
|
- `fieldLabel(field, id)` — перевод по ключу из `fields`-секции словаря (см.
|
||||||
|
решение C), фолбэк на `labelOf(id)`;
|
||||||
|
- `groupLabel(title)` — перевод по ключу из `groups`-секции словаря (см.
|
||||||
|
решение A);
|
||||||
|
- `verdictText(toolId, key)` — рендер вердикта по ключу;
|
||||||
|
- `verdictTone(key)` — `success`|`danger`|`info` по имени ключа
|
||||||
|
(`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке.
|
||||||
|
- **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`):
|
||||||
|
- секция `preview` **не заводится**: строки нового UI живут в собственных
|
||||||
|
категориях — расширяем существующие (`catalog`, `header`, `dropZone`,
|
||||||
|
`sourceCard`, `resultCard`, `paramsCard`, `textResult`, `ui`) и заводим две
|
||||||
|
маленькие новые: `actions` (Generate / Open image / Download result…) и
|
||||||
|
`textSource` (placeholder, Try sample…);
|
||||||
|
- новая глобальная секция `fields: Record<string, string>` — переводы подписей
|
||||||
|
полей по ключу из схемы (решение C);
|
||||||
|
- новая глобальная секция `groups: Record<string, string>` — переводы
|
||||||
|
заголовков групп полей (решение A).
|
||||||
|
|
||||||
|
### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем)
|
||||||
|
|
||||||
|
- **1.1** Заполнить `en.ts` и `ru.ts` секциями `actions`, `textSource`,
|
||||||
|
`fields`, `groups` и ключами недостающих строк в существующих секциях.
|
||||||
|
- **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и
|
||||||
|
ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить
|
||||||
|
title/description в ru.ts новой ветки (по категориям — несколько коммитов).
|
||||||
|
- **1.3** Добавить в `ru.ts` ключи `fields`/`groups` для используемых в схемах
|
||||||
|
ключей полей и заголовков групп (по ходу проставления ключей в schema, Фаза
|
||||||
|
5).
|
||||||
|
- **1.4** Добавить `tools[id].options[fieldId][value]` для select-полей, чьи
|
||||||
|
опции нужно локализовать.
|
||||||
|
- **1.5** Тест полноты (задел на Фазу 6): для каждого ключа label в schema есть
|
||||||
|
перевод в обоих словарях.
|
||||||
|
|
||||||
|
### Фаза 2: LangToggle → реальный locale
|
||||||
|
|
||||||
|
- **2.1** `LangToggle.svelte`: убрать локальный `lang`, подключить
|
||||||
|
`getLocale()`, `setLocale()`, `LOCALES`. Модель — language switch из
|
||||||
|
`routes/v1/+layout.svelte`.
|
||||||
|
- **2.2** Проверить e2e `navigation.spec.ts` «language toggle marks the active
|
||||||
|
button» — должен проходить без изменений.
|
||||||
|
|
||||||
|
### Фаза 3: Routes + layout
|
||||||
|
|
||||||
|
- **3.1** `+layout.svelte`: breadcrumbs через `t()`, динамический `lang`.
|
||||||
|
- **3.2** `+page.svelte`, `list-tools/+page.svelte`: footer-строка →
|
||||||
|
`t("header.footerNote")`.
|
||||||
|
- **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`.
|
||||||
|
|
||||||
|
### Фаза 4: Компоненты — основной блок (коммиты кучками по смыслу)
|
||||||
|
|
||||||
|
Все текстовые подписи в компонентах — информативные, сохраняем и переводим на
|
||||||
|
`t`/хелперы. Каждый коммит: замена строк + lint/check.
|
||||||
|
|
||||||
|
- `SchemaToolView.svelte`: title/description →
|
||||||
|
`toolTitle()`/`toolDescription()`, перевести `TOOL SETTINGS`, «Configure
|
||||||
|
output» и «This tool has no schema yet.».
|
||||||
|
- `SchemaSourceTile.svelte`: перевести figcaption `SOURCE`, «choose an
|
||||||
|
image»/«no source…» и alt.
|
||||||
|
- `SchemaResultTile.svelte`: перевести figcaption `RESULT`, «no result yet» и
|
||||||
|
alt; суффикс «parts» — в `PreviewTile`.
|
||||||
|
- `SchemaPreview.svelte`: перевести панельный лейбл `PREVIEW PANEL` и
|
||||||
|
meta-капшены `SOURCE / RESULT / FORMAT`; значения «parts»/«text» → `t()`.
|
||||||
|
- `SchemaActions.svelte`: Generating…, Generate, Open image, Download result.
|
||||||
|
- `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)` →
|
||||||
|
`fieldLabel(field)`.
|
||||||
|
- `SchemaTextSource.svelte`: placeholder, Try sample, Render text.
|
||||||
|
- `SchemaTextResult.svelte`: перевести лейбл `RESULT` у вердикта, Copy /
|
||||||
|
Download .txt; tone detection → `verdictTone()`.
|
||||||
|
- `CatalogToolbar.svelte`: фильтры → `t("categories.all")` +
|
||||||
|
`t("categories.X")`, поисковый placeholder.
|
||||||
|
- `CatalogHeader.svelte`: перевести heading/lead и счётчик «TOOLS AVAILABLE».
|
||||||
|
- `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS».
|
||||||
|
- `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`.
|
||||||
|
- Schema field controls (по одному или группой): Width/Height, From/To, Backing
|
||||||
|
plate, Opacity, Font/Size/Color/Bold, Sans/Serif/Mono, Angle, «+ Add color»,
|
||||||
|
«Remove color».
|
||||||
|
- `TopNav.svelte`, `ThemeToggle.svelte`, `CodeBlock.svelte`, `StepCard.svelte`,
|
||||||
|
`Footer.svelte`.
|
||||||
|
|
||||||
|
### Фаза 5: Вердикты + ключи в schema
|
||||||
|
|
||||||
|
- **5.1** Анализаторы в `registry/analyze.ts`: возвращают ключи (`"verifyYes"`
|
||||||
|
вместо raw English).
|
||||||
|
- **5.2** `SchemaPreview.svelte`: рендер вердикта через
|
||||||
|
`verdictText(tool.id, key)`.
|
||||||
|
- **5.3** Layout groups в `registry/*.ts` (решение A): `title` → ключ
|
||||||
|
(`title: "groups.canvas"`); в `SchemaFields.svelte` — `groupLabel(title)`. В
|
||||||
|
`Dict` — глобальная секция `groups`.
|
||||||
|
- **5.4** Лейблы полей в `registry/*.ts` (решение C): на поле в схеме
|
||||||
|
проставляется `label`-ключ (`field.number({ label: "fields.thickness", … })`),
|
||||||
|
вывод в UI — `fieldLabel(field)` = `t(label)`. В `Dict` — глобальная секция
|
||||||
|
`fields`.
|
||||||
|
- **5.5** Категории в `catalog.ts`: `GROUP_LABELS` → `t("categories.X")` (ключи
|
||||||
|
уже есть в dict).
|
||||||
|
|
||||||
|
### Фаза 6: Тесты
|
||||||
|
|
||||||
|
- **6.1** `tools-i18n.test.ts` — полнота словарей для текущего registry:
|
||||||
|
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
|
||||||
|
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
|
||||||
|
- **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются.
|
||||||
|
- **6.3** В новой ветке `lib/i18n` появится собственный `smoke-i18n.test.ts`
|
||||||
|
(аналог v1: переключение локали, `html lang`, контент без смеси языков) —
|
||||||
|
v1-копия теста не трогается.
|
||||||
|
|
||||||
|
### Фаза 7: Кросс-языковой поиск
|
||||||
|
|
||||||
|
Переключить поиск в новом каталоге с `toLowerCase()` на
|
||||||
|
`scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см.
|
||||||
|
решение D). Последний шаг, аккуратный отдельный коммит.
|
||||||
|
|
||||||
|
### Фаза 8: Линтер словарей (i18n key linter)
|
||||||
|
|
||||||
|
Замена ручной проверки ключей на автоматическую. Инфраструктура уже есть:
|
||||||
|
`eslint-plugins/` умеет читать соседние файлы (`isolation/no-mixed-imports`) и
|
||||||
|
гоняться фикстурами (`test:rules`).
|
||||||
|
|
||||||
|
- **8.1** Новое правило `i18n/dict-consistency` в `web/eslint-plugins/`.
|
||||||
|
Применяется к файлам-словарям (конвенция: экспортируют `Dict`, лежат в
|
||||||
|
`lib/i18n/`); новые локали подхватываются автоматически — правка правила не
|
||||||
|
нужна.
|
||||||
|
- **8.2** Проверки — **только `warn`, никогда `error`** (пропущенный перевод не
|
||||||
|
валит сборку):
|
||||||
|
- **Паритет дерева ключей**: каждый ключ (dot-path `a.b.c`) присутствует во
|
||||||
|
всех остальных локалях, не только против BASE. Нет —
|
||||||
|
`missing key "x.y" in en`.
|
||||||
|
- **Пустые значения**: `""` и строка из одних пробелов —
|
||||||
|
`empty value "x.y" in en`.
|
||||||
|
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
|
||||||
|
всех локалях — поймать потерянную при переводе переменную.
|
||||||
|
- **8.3** Политика источников: `tools.*.title` и `tools.*.description` в
|
||||||
|
`BASE_LOCALE` намеренно берутся из registry English fallback; остальные локали
|
||||||
|
обязаны иметь физические переводы. Старый `tools.*.params` не используется
|
||||||
|
новым UI и пока исключён policy-pattern, а не списком id. Target — zero-warn
|
||||||
|
без молчаливых подавлений.
|
||||||
|
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
|
||||||
|
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
|
||||||
|
|
||||||
|
### Фаза 9: Множественные числа (plural-формы)
|
||||||
|
|
||||||
|
- **9.1** Тип значения словаря расширяем: `string | PluralForms`, где
|
||||||
|
`PluralForms = { other: string } & Partial<{ one, few, many }>` по
|
||||||
|
CLDR-правилам локали (ru: обязательны `one/few/many`; `other` — всегда, как
|
||||||
|
фолбэк). Новая функция `tPlural(path, n)` в `lib/i18n/`: выбирает категорию
|
||||||
|
через `Intl.PluralRules(locale).select(n)` и подставляет её с интерполяцией
|
||||||
|
`{n}`.
|
||||||
|
- **9.2** Первые потребители: «часть/части/частей» (split-into-parts-png),
|
||||||
|
счётчики инструментов (`catalog.toolsCount`), «N параметров», «N файлов».
|
||||||
|
- **9.3** Паритет в линтере (Фаза 8): если ключ — plural-объект в одной локали,
|
||||||
|
он обязан быть plural-объектом во всех остальных (тип-паритет; string в одной
|
||||||
|
из локалей — warn). Набор форм между локалями не сравнивается дословно — он
|
||||||
|
проверяется по правилам конкретной локали (`other` обязателен; en: one/other,
|
||||||
|
ru: one/few/many).
|
||||||
|
- **9.4** Открытый вопрос на этапе реализации: неоднозначность «объект-секция»
|
||||||
|
(`Record<string, string>`) vs plural-объект в дереве словаря. Варианты:
|
||||||
|
отдельная секция `plurals` в словаре, конвенция имён ключей, вывод типа из
|
||||||
|
контекста. Фаза начинается с проработки этого вопроса, не с кода.
|
||||||
|
|
||||||
|
## 5. Решения
|
||||||
|
|
||||||
|
- **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме
|
||||||
|
(`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря
|
||||||
|
(`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` =
|
||||||
|
`t(title)` с фолбэком на сырой ключ.
|
||||||
|
- **C. Лейблы полей**: в схеме задаём i18n-ключ
|
||||||
|
(`field.number({ label: "fields.thickness", … })`), в UI выводится перевод —
|
||||||
|
`fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря,
|
||||||
|
фолбэк на текущий `labelOf(id)` для несопоставленных.
|
||||||
|
- **D. Объём**: переводим всё сразу — и каркас, и контент реестра, и вердикты, и
|
||||||
|
кросс-языковой поиск (Фаза 7). Крупные изменения кода разбиваем на небольшие
|
||||||
|
атомарные шаги/коммиты.
|
||||||
|
|
||||||
|
## 6. Проверки
|
||||||
|
|
||||||
|
- `pnpm --dir web lint`
|
||||||
|
- `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`
|
||||||
|
- `pnpm --dir web test`
|
||||||
|
- `pnpm --dir web test:rules` (если менялся eslint/isolation-конфиг)
|
||||||
|
- Смоук: переключатель RU/EN на новом UI; страницы каталога/инструмента, ошибка,
|
||||||
|
вердикт — без смеси языков; `html lang` следует за локалью.
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# План: переезд на новый дизайн (refs) — параллельная сборка
|
||||||
|
|
||||||
|
> Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
|
||||||
|
> Причина: переезд на новый дизайн завершён (C17/C18/Q3), C20 и C21 выполнены,
|
||||||
|
> пункты полировки разобраны и разложены по `docs/backlog.md`. Источник правды
|
||||||
|
> по визуальному языку — `web/src/app.css`, `web/src/lib/components/**`, витрина
|
||||||
|
> `/kit` и приёмка в `docs/checklist-manual-testing.md`. Использование: только
|
||||||
|
> историческая справка
|
||||||
|
|
||||||
|
> Статус: C17 **выполнен**, C18 **выполнен**, Q3 (удаление старого UI)
|
||||||
|
> **выполнен**, C20 **выполнен**, C21 **выполнен**. Открытых шагов не осталось.
|
||||||
|
> Покупка домена НЕ была блокером C17: домен нужен для S1 (`plan-seo.md`) —
|
||||||
|
> canonical, sitemap, снятие `noindex`.
|
||||||
|
>
|
||||||
|
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
||||||
|
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
||||||
|
>
|
||||||
|
> **Историческая стратегия перехода:** сначала новый UI собирался отдельно,
|
||||||
|
> затем стал production, после чего legacy-ветка была удалена Q3. Текущие
|
||||||
|
> критерии относятся только к production UI; старые решения и детали перехода
|
||||||
|
> остаются в архивных планах.
|
||||||
|
|
||||||
|
## 0. Почему меняем стратегию
|
||||||
|
|
||||||
|
- Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить
|
||||||
|
независимо; теперь production использует только новые маршруты и `app.css`.
|
||||||
|
- Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout
|
||||||
|
для удалённого UI-v1 больше не нужны.
|
||||||
|
|
||||||
|
## 1. Источники и их оценка
|
||||||
|
|
||||||
|
### `refs-html/` — витрина разметки
|
||||||
|
|
||||||
|
Покрытие достаточное:
|
||||||
|
|
||||||
|
| Файл | Что показывает | Оценка |
|
||||||
|
| ------------------------- | ----------------------------------------- | ---------------- |
|
||||||
|
| `index.html` | Главная: pipeline-воркспейс + превью | ✅ |
|
||||||
|
| `demo.html` | Зеркало `index.html` (идентичны) | ⚠️ дубликат |
|
||||||
|
| `gradient.html` | Инструмент «Градиент»: настройки + превью | ✅ (тёмная тема) |
|
||||||
|
| `background-remover.html` | Инструмент «Удаление фона»: source/result | ✅ (тёмная тема) |
|
||||||
|
| `list-tools.html` | Каталог инструментов | ✅ |
|
||||||
|
|
||||||
|
- **Статика без JS** — интерактив (тумблеры/слайдеры/перетаскивание) брать из
|
||||||
|
кода `refs/`, не из HTML. HTML — только визуальная правда по разметке/классам.
|
||||||
|
- По 4 файлам собран инвентарь классов — это и есть спецификация kit'а:
|
||||||
|
`app-shell`, `page-grid`, `eyebrow`, `brand-mark`, `topbar`, `footer`,
|
||||||
|
`panel-heading`, `setting-group`, `settings-footer`, `settings-panel`,
|
||||||
|
`segmented`, `status`, `step-card` (`step-index`/`step-heading`/`step-type`/
|
||||||
|
`step-body`/`drag`), `color-field` (`swatch`/`native-color`),
|
||||||
|
`preview-toolbar`/`checker`
|
||||||
|
(`tile-canvas`/`remover-canvas`/`comparison-grid`), `result-meta`/`meta-row`,
|
||||||
|
`tool-card`/`image-card` (`tool-icon`/`tool-index`/ `tool-copy`),
|
||||||
|
`code-block`, `icon-btn`, `download-btn`, `reset-btn`, `lede`, `auto-note`,
|
||||||
|
`label`, `toggle`/`toggle-group`, `direction-grid`, `gradient-bar`.
|
||||||
|
- **Дыры в refs-html:** нет состояний empty/error/loading, нет глобальных
|
||||||
|
модалок/тултипов, i18n не представлен (все лейблы — английские брендовые).
|
||||||
|
Решения — см. §9.
|
||||||
|
|
||||||
|
## 2. Токены (фундамент нового дизайна)
|
||||||
|
|
||||||
|
Живут в `src/lib/styles/design2.css` как обычный `:root { … }` (плюс
|
||||||
|
`[data-theme='dark'] { … }`). Файл грузится **только** layout'ом новой ветки
|
||||||
|
(§5), поэтому глобально не конфликтует со старым `app.css`. Маппинг старых
|
||||||
|
токенов `web/src/app.css` → новые:
|
||||||
|
|
||||||
|
| Старый | Новый | Light | Dark |
|
||||||
|
| -------------- | -------------- | --------- | ------------------------ |
|
||||||
|
| `--bg` | `--background` | `#eef1f4` | `#11171d` |
|
||||||
|
| `--surface` | `--panel` | `#f8fafb` | `#182129` |
|
||||||
|
| `--text` | `--foreground` | `#17212b` | `#e8eef2` |
|
||||||
|
| `--text-muted` | `--muted` | `#6d7883` | `#91a0ac` |
|
||||||
|
| `--border` | `--line` | `#cbd3da` | `#33414c` |
|
||||||
|
| `--accent` | `--blue` | `#1769d2` | `#54a2ff` |
|
||||||
|
| — | `--cyan` | `#00a8c7` | `#00a8c7` |
|
||||||
|
| — | `--amber` | `#bd7411` | `#bd7411` |
|
||||||
|
| — | `--success` | `#25a96a` | `#25a96a` (статус-точки) |
|
||||||
|
| `--danger` | без изменений | `#e5484d` | `#ff7479` |
|
||||||
|
|
||||||
|
Прочее:
|
||||||
|
|
||||||
|
- Радиусы: один `--radius: 4px` (мягкость старой темы уходит).
|
||||||
|
- Шрифты: `IBM Plex Sans` + `IBM Plex Mono`, self-host через `@fontsource`
|
||||||
|
(offline-first, CDN нельзя). Mono — рабочий шрифт для лейблов/метаданных/
|
||||||
|
цифр; Sans — заголовки и текст. Задаются на `.app-shell` (корень новой ветки),
|
||||||
|
чтобы не зависеть от старых глобальных правил `body`.
|
||||||
|
- Фон-сетка (32px blueprint-grid) и шахматка канвасов — утилиты `.page-grid` и
|
||||||
|
`.checker-canvas` внутри `.ds2`, не токены.
|
||||||
|
- Тёмная тема — на `[data-theme='dark']` (инлайн-скрипт уже есть в `app.html`);
|
||||||
|
класс `.dark-mode` из рефа не переносим, используем наш механизм. На
|
||||||
|
переходный период **никаких алиасов старых токенов** (старые страницы сами
|
||||||
|
живут на своём `app.css`).
|
||||||
|
|
||||||
|
## 3. Базовые примитивы дизайн-системы (все — компоненты)
|
||||||
|
|
||||||
|
Правило фазы 1: **любой повторяющийся паттерн — компонент**, даже «просто div с
|
||||||
|
двумя стилями». Никакой копипасты классов между страницами. Каждый примитив ниже
|
||||||
|
— отдельный `.svelte` в `src/lib/components/kit/`.
|
||||||
|
|
||||||
|
- `MonoLabel` — микро-лейбл: mono 10px, uppercase, letter-spacing .1em, muted;
|
||||||
|
слот-модификатор accent (синие eyebrow/version).
|
||||||
|
- `StatusDot` / `StatusLine` — точка `--success` + подпись («AUTO PIPELINE»,
|
||||||
|
«LIVE PREVIEW»).
|
||||||
|
- `Panel` / `PanelHeading` / `SettingGroup` / `SettingsFooter` — панель с
|
||||||
|
1px-бордером, составные части с разделителями.
|
||||||
|
- `CheckerCanvas` — рабочая поверхность превью: тёмная подложка + шахматка.
|
||||||
|
- `MetaRow` / `MetaList` — пары «CAPTION значение».
|
||||||
|
- `Segmented` — сегмент-переключатель (язык, тип градиента, пресеты).
|
||||||
|
- `Badge` — тег типа шага (BACKGROUND/TRANSFORM/STYLE), чип «✓ AUTO».
|
||||||
|
- `StepCard` — нумерованная карточка этапа: индекс `01`, drag-handle, Badge
|
||||||
|
типа, заголовок, кнопка удаления; слот для параметров.
|
||||||
|
- `ToolCard` — карточка каталога (icon, index, title, copy, arrow).
|
||||||
|
- `ImageCard` / `PreviewTile` — label сверху + canvas-превью.
|
||||||
|
- `AppShell` — корневой контейнер `.app-shell` + `.page-grid` (обёртка новой
|
||||||
|
ветки маршрутов); задаёт шрифты и фон нового дизайна.
|
||||||
|
- `BlueprintGrid` — фоновая сетка (используется в `AppShell`).
|
||||||
|
- `IconButton`, `Icon` (обёртка `lucide-svelte`, 16–17px, muted).
|
||||||
|
|
||||||
|
## 4. Минимальный UI-kit (`src/lib/components/kit/`)
|
||||||
|
|
||||||
|
Контролы, поверх примитивов §3. API проектируем сразу под новый дизайн (обратной
|
||||||
|
совместимости со старым `ui/` не нужно — старые страницы его продолжают
|
||||||
|
использовать).
|
||||||
|
|
||||||
|
| Компонент | Назначение |
|
||||||
|
| ------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `Button.svelte` | `primary` (solid blue) / `secondary` (outline) / `ghost` + `icon` |
|
||||||
|
| `Field.svelte` | обёртка: `MonoLabel` + слот значения/контрола |
|
||||||
|
| `SliderField.svelte` | range + `<output>`, опц. строка подсказок |
|
||||||
|
| `ColorField.svelte` | swatch + hex-input + нативный picker |
|
||||||
|
| `TextField.svelte` | текстовый ввод в стиле дизайна |
|
||||||
|
| `SelectField.svelte` | селект в стиле дизайна |
|
||||||
|
| `CheckboxField.svelte` | чекбокс → switch-toggle 32×18 |
|
||||||
|
| `Toggle.svelte` | переключатель (для toggle-group) |
|
||||||
|
| `SegmentedControl.svelte` | на базе `Segmented` — RU/EN, Linear/Radial, пресеты |
|
||||||
|
| `Badge.svelte` | на базе `Badge` |
|
||||||
|
| `MetaList.svelte` | на базе `MetaRow` |
|
||||||
|
| `EmptyState.svelte` | пустое состояние под новый стиль |
|
||||||
|
| `CodeBlock.svelte` | блок кода (gradient-art / direction-grid) |
|
||||||
|
| `DownloadButton.svelte` | кнопка скачивания (можно переиспользовать идею старой) |
|
||||||
|
|
||||||
|
Иконки: `lucide-svelte` (те же пути, MIT), 16–17px, stroke, muted.
|
||||||
|
|
||||||
|
## 5. Изоляция старого и нового (критично)
|
||||||
|
|
||||||
|
Без префиксов и обёрток — только за счёт того, какие layout грузят какой CSS.
|
||||||
|
|
||||||
|
- **Корневой layout `src/routes/+layout.svelte`** перестаёт импортировать
|
||||||
|
`../app.css`. В нём остаётся только общая логика (init темы/locale) и
|
||||||
|
нейтральный `<div>{children}</div>` без глобальных стилей дизайна.
|
||||||
|
- **Старая ветка `src/routes/(old)/+layout.svelte`** импортирует `app.css` и
|
||||||
|
содержит текущую шапку/подвал (разметку из нынешнего корневого layout). Все
|
||||||
|
старые страницы переносятся внутрь `(old)/` (группа в скобках не меняет URL):
|
||||||
|
`(old)/+page.svelte`, `(old)/demo/...`, `(old)/list-tools/...`,
|
||||||
|
`(old)/tools/[id]/...`.
|
||||||
|
- **Новая ветка `src/routes/preview/+layout.svelte`** импортирует `design2.css`
|
||||||
|
и рендерит `<AppShell>`. Сюда же — новые страницы (§6).
|
||||||
|
- Благодаря code-splitting CSS в SvelteKit: на старых маршрутах грузится только
|
||||||
|
`app.css`, на `preview/*` — только `design2.css`. Скоуп стилей
|
||||||
|
Svelte-компонентов добавляет второй уровень изоляции. Никаких `.ds2`.
|
||||||
|
- Переключатель для ручной проверки: временный флаг `previewDesign` в
|
||||||
|
`localStorage` + кнопка в старой шапке («Preview v2»), ведущая на `/preview`.
|
||||||
|
На фазе 4 флаг убираем.
|
||||||
|
|
||||||
|
> Почему не `(new)`-группа с теми же путями вместо `preview/*`: две группы не
|
||||||
|
> могут одновременно определять один и тот же URL (`(old)/+page.svelte` и
|
||||||
|
> `(new)/+page.svelte` оба → `/` = конфликт маршрутов). Поэтому новая ветка
|
||||||
|
> живёт под префиксом `preview/`, а на фазе 4 файлы переносятся на реальные пути
|
||||||
|
> и группа `(old)` удаляется.
|
||||||
|
>
|
||||||
|
> Это совпадает с рекомендованным в документации SvelteKit паттерном: «put your
|
||||||
|
> entire app inside one or more groups except the routes that should not inherit
|
||||||
|
> the common layouts» — старое целиком в `(old)`, новое снаружи. Синтаксис
|
||||||
|
> `@`-breakout (`+layout@.svelte`, `+page@.svelte`) здесь не требуется:
|
||||||
|
> «выломаться» можно лишь до корня, а нам нужно, чтобы именно у корня НЕ было
|
||||||
|
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
|
||||||
|
> пригодится для сброса родительского layout.
|
||||||
|
|
||||||
|
## 6. Маршрутизация: историческая схема перехода
|
||||||
|
|
||||||
|
Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были
|
||||||
|
перенесены на production-маршруты. Эта схема нужна только для восстановления
|
||||||
|
контекста C17; текущая структура описана в разделе 7, отдельных preview- или
|
||||||
|
legacy-layouts в production больше нет.
|
||||||
|
|
||||||
|
## 7. Фазы выполнения
|
||||||
|
|
||||||
|
- **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и
|
||||||
|
интерактивные состояния приняты; детали перехода остаются в архивном
|
||||||
|
`plan-design-fix.md`.
|
||||||
|
- **C17/C18 — production-переход.** Новые страницы перенесены на реальные
|
||||||
|
корневые маршруты; `app.css` стал единственным production-стилем.
|
||||||
|
- **Q3/C19 — cleanup.** Удалены UI-v1, legacy CSS, v1-тесты, fixtures и
|
||||||
|
isolation-конфигурация; production build и verify проверяются отдельно.
|
||||||
|
- **C20 — следующий этап.** Финальный проход по брейкпоинтам и визуальной
|
||||||
|
приёмке production UI.
|
||||||
|
- **C21 — отдельная уборка.** Удалить `refs/` и `refs-html/`, если они больше не
|
||||||
|
нужны.
|
||||||
|
|
||||||
|
Критерии каждого этапа — зелёные `test`/`check`/`build`, отсутствие остаточных
|
||||||
|
legacy-путей в production-коде и ревью в браузере для UI-изменений.
|
||||||
|
|
||||||
|
## 8. Смоук (после фазы 3, контроль на фазе 4)
|
||||||
|
|
||||||
|
1. Светлая и тёмная тема: сетка фона, панели, шахматка канвасов читаются,
|
||||||
|
контраст mono-лейблов достаточный.
|
||||||
|
2. Инструмент с параметрами (градиент): slider/color/toggle в стиле
|
||||||
|
setting-groups, автозапуск не регрессировал.
|
||||||
|
3. Удаление фона: сравнение source/result, маска, пипетка работают на новой
|
||||||
|
шахматке.
|
||||||
|
4. Chain из 3+ инструментов: step-cards рендерятся, удаление/сброс работают.
|
||||||
|
5. Узкий экран 800px: настройки над превью, sticky выключается как в рефе.
|
||||||
|
6. RU/EN: новые лейблы локализованы; mono-лейблы рефа — либо ключи i18n, либо
|
||||||
|
осознанно английские брендовые элементы (см. §9).
|
||||||
|
|
||||||
|
## 9. Открытые вопросы
|
||||||
|
|
||||||
|
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
|
||||||
|
разложил его в `components/` (подпапки fields/layout/ui). На C21 — только
|
||||||
|
навести порядок внутри `components/`, переименование не требуется.
|
||||||
|
- **Единый production CSS:** `app.css` подключается в корневом layout; отдельных
|
||||||
|
legacy-layouts и переключателей версий больше нет.
|
||||||
|
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
|
||||||
|
ключи, но допустить часть английских брендовых («приборная панель»).
|
||||||
|
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
|
||||||
|
мелочь, решить при сборке topbar.
|
||||||
|
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
|
||||||
|
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
|
||||||
|
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
|
||||||
|
не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка;
|
||||||
|
их можно удалить в C21, они не являются частью рантайма.
|
||||||
|
|
||||||
|
## 10. План исполнения (мелкие коммиты)
|
||||||
|
|
||||||
|
Ветка `feat/redesign`. Каждый шаг — отдельный коммит, цель < ~500 строк. Перед
|
||||||
|
стартом: `test`/`build` зелёные на базе (якорь регрессии).
|
||||||
|
|
||||||
|
### Шаг 0. Изоляция layout (без дизайна)
|
||||||
|
|
||||||
|
- [x] **[C1]** Сделать корневой `routes/+layout.svelte` нейтральным: убрать
|
||||||
|
`import '../app.css'`, оставить только init темы/locale и
|
||||||
|
`<div>{children}</div>`.
|
||||||
|
- [x] **[C2]** Создать `routes/(old)/+layout.svelte` (импорт `app.css` + старая
|
||||||
|
шапка/подвал) и перенести старые страницы (`+page`, `demo`, `list-tools`,
|
||||||
|
`tools/[id]`) в `(old)/`. URL не меняются. Проверка: старый сайт
|
||||||
|
идентичен. _(файлы — move, объём строк минимален)_
|
||||||
|
|
||||||
|
### Шаг 1. Фундамент нового дизайна
|
||||||
|
|
||||||
|
- [x] **[C3]** Создать `src/lib/styles/design2.css` (токены `:root` +
|
||||||
|
`[data-theme='dark']` по §2) и подключить `@fontsource/ibm-plex-sans` +
|
||||||
|
`@fontsource/ibm-plex-mono` (self-host, offline-first). подключить шрифты
|
||||||
|
в `design2.css` (self-host, offline-first).
|
||||||
|
- [x] **[C4]** _(слит в C3: шрифты нельзя подключить до создания `design2.css`)_
|
||||||
|
`[data-theme='dark']` (таблица §2). Без утилитарных классов.
|
||||||
|
- [x] **[C5]** Примитивы batch 1: `AppShell`, `BlueprintGrid`, `CheckerCanvas`,
|
||||||
|
`MonoLabel`, `StatusDot`/`StatusLine`.
|
||||||
|
- [x] **[C6]** Примитивы batch 2: `Panel`, `PanelHeading`, `SettingGroup`,
|
||||||
|
`SettingsFooter`, `MetaRow`/`MetaList`, `Segmented`, `Badge`, `StepCard`,
|
||||||
|
`ToolCard`, `ImageCard`/`PreviewTile`.
|
||||||
|
- [x] **[C7]** Контролы: `Icon`, `IconButton`, `Button`, `Field`, `SliderField`,
|
||||||
|
`ColorField`, `TextField`, `SelectField`, `CheckboxField`, `Toggle`,
|
||||||
|
`SegmentedControl` (= `Segmented` из C6), `EmptyState`, `CodeBlock`,
|
||||||
|
`DownloadButton`.
|
||||||
|
|
||||||
|
### Шаг 2. Витрина kit (проверка компонентов)
|
||||||
|
|
||||||
|
- [x] **[C8]** `preview/+layout.svelte` (импорт `design2.css` + `<AppShell>` +
|
||||||
|
переключатель темы) и временная `preview/kit` страница со всеми
|
||||||
|
состояниями (light/dark, hover/disabled, варианты Button/Segmented/Badge и
|
||||||
|
пр.)
|
||||||
|
|
||||||
|
### Шаг 3. Новые страницы
|
||||||
|
|
||||||
|
- [x] **[C9]** `/preview` — воркспейс (eyebrow, h1, lede, поиск, «открыть
|
||||||
|
последний»). Поиск фильтрует `TOOLS` из `$lib/registry`; карточки
|
||||||
|
результата пока ведут на `#` (реальные `/preview/tools/[id]` подключим на
|
||||||
|
C12, иначе prerender падает на 404).
|
||||||
|
- [x] **[C10]** `/preview/list-tools` — каталог карточек (группировка по
|
||||||
|
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
|
||||||
|
подключим на C12).
|
||||||
|
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью),
|
||||||
|
статичный приёмочный стенд.
|
||||||
|
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel +
|
||||||
|
preview-panel (gradient live, остальные — плейсхолдер); `NumberField`,
|
||||||
|
`entries()` для prerender, ToolCard-ссылки подключены.
|
||||||
|
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source
|
||||||
|
инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`);
|
||||||
|
охватывает background-remover и прочие.
|
||||||
|
- [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги
|
||||||
|
(параметры inline) + «Add step» (chainable), живой прогон пайплайна на
|
||||||
|
исходнике, превью каждого шага + итог + Download.
|
||||||
|
|
||||||
|
### Шаг 4. Переключатель и проверка
|
||||||
|
|
||||||
|
- [x] **[C15]** В старой шапке ссылка «Preview v2» → `/preview`, ставит
|
||||||
|
`localStorage['easy-png-tools:preview-v2']='1'`.
|
||||||
|
- [x] **[C16]** База под дизайн: рабочий переключатель тем (persist в
|
||||||
|
localStorage, применяется на `<html>`; проверено — фон
|
||||||
|
`#eef1f4`↔`#11171d`), `TopBar` приведён к виду `demo.html` (бренд + синий
|
||||||
|
хлебный крош `/* страница */`, безрамочные иконки, язык — active =
|
||||||
|
`--foreground`/`--background`), страницы `/preview/*` на всю ширину (убран
|
||||||
|
`max-width`). Точечное выравнивание к рефу отложено: реф-дизайн
|
||||||
|
неконсистентен. (доказательство отсутствия регресса). Фиксы по результатам
|
||||||
|
— отдельными мелкими коммитами.
|
||||||
|
|
||||||
|
### Шаг 5. Сделать новый дизайн основным
|
||||||
|
|
||||||
|
> C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты
|
||||||
|
> перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов
|
||||||
|
> после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и
|
||||||
|
> код. Остались C20 и C21.
|
||||||
|
|
||||||
|
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
||||||
|
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
|
||||||
|
shell. Убрать флаг/кнопку. **Снять `noindex, nofollow`** со страницы
|
||||||
|
инструмента (`preview/tools/[id]/+page.svelte`) — страницы становятся
|
||||||
|
боевыми лендингами ровно в момент переезда (SEO: `plan-seo.md`, S1a).
|
||||||
|
**Временное решение:** если домен ещё не куплен, `noindex` можно оставить
|
||||||
|
до S1 — тогда C17 делается без снятия `noindex`, а снятие происходит
|
||||||
|
вместе с подключением canonical в S1. **И обновить `newCode` в
|
||||||
|
`web/eslint.config.js`:** заменить `src/routes/preview/**` на
|
||||||
|
`src/routes/**` (после переезда все маршруты — новый дизайн), компонентный
|
||||||
|
glob `src/lib/components/kit/**` пока оставить. _(Итог: `newCode` обновлён
|
||||||
|
(`components/**`, `routes/**`), `noindex` оставлен до S1 — домен ещё не
|
||||||
|
куплен. Детали: `docs/archive/plan-c17.md`.)_
|
||||||
|
- [x] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
|
||||||
|
_(Билд собирается без ошибок, линт/свелт-чека нет — выполнен.)_
|
||||||
|
|
||||||
|
### Шаг 6. Проверка нового дизайна
|
||||||
|
|
||||||
|
- [x] **[C20]** Финальный проход: grep по старым токенам и классам пуст (0
|
||||||
|
совпадений по `--bg`/`--surface`/`--text`/`--blue`/`--panel` в `web/src`),
|
||||||
|
`app.css` — единственный production-стиль, `components/` разложен как в
|
||||||
|
C17 (`fields`/`layout`/`ui`), production-маршруты и пользовательские
|
||||||
|
потоки покрыты e2e. Визуальная приёмка живёт в
|
||||||
|
`docs/checklist-manual-testing.md`.
|
||||||
|
|
||||||
|
### Шаг 7. Cleanup
|
||||||
|
|
||||||
|
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
||||||
|
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
|
||||||
|
из конфигурации и активной документации.
|
||||||
|
- [x] **[C21]** `refs/` и `refs-html/` удалены — каталогов в репозитории нет.
|
||||||
|
- [x] Навести порядок в `components/`: подпапки `fields`/`layout`/`ui`.
|
||||||
|
|
||||||
|
> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
|
||||||
|
> После удаления проверяются только production-маршруты и общие модули.
|
||||||
|
>
|
||||||
|
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
||||||
|
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
||||||
|
> чтобы регрессия локализовалась одним шагом назад.
|
||||||
|
>
|
||||||
|
> **Атомарность и точки «поломки» между коммитами.**
|
||||||
|
>
|
||||||
|
> - C1+C2 уже сделаны вместе (связаны: нейтральный root без `(old)`-layout
|
||||||
|
> ломает старый сайт). Аналогично связанных пар больше нет.
|
||||||
|
> - C3 и C4 слиты: шрифты подключаются внутри `design2.css`, поэтому файл должен
|
||||||
|
> существовать в том же коммите.
|
||||||
|
> - Коммиты **C3→C16 не трогают старый сайт** `(old)/` — они только добавляют
|
||||||
|
> новые файлы/маршруты, сломать старый сайт на них нельзя.
|
||||||
|
> - **C17 — атомарный переключатель**: перенос `preview/*` на реальные пути +
|
||||||
|
> удаление `(old)/` + смена root-layout — в ОДНОМ коммите. Снятие `noindex`
|
||||||
|
> также в C17, **если домен уже куплен**; иначе — отложить на S1. Не дробить
|
||||||
|
> сам C17.
|
||||||
|
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны
|
||||||
|
> существовать до страниц, которые их импортируют (C8–C14).
|
||||||
|
|
||||||
|
## Бэклог полировки — закрыт по итогам реализации
|
||||||
|
|
||||||
|
Пять пунктов проверены по коду: четыре сняты как выполненные, один перенесён в
|
||||||
|
`docs/backlog.md`.
|
||||||
|
|
||||||
|
- `Toggle` прямоугольный — выполнено (`border-radius: 0`).
|
||||||
|
- `Segmented`: последняя кнопка не растянута — выполнено (`flex: 1` у пунктов).
|
||||||
|
- Отступ в `DownloadButton` между текстом и size-чипом — снято как неактуальное:
|
||||||
|
size-чипа больше нет, компонент тонкая обёртка над `Button`.
|
||||||
|
- Паддинги в полях — выполнено: `TextField` и `SelectField` имеют
|
||||||
|
`padding: var(--space-s) var(--space-m)`.
|
||||||
|
- Радиусы бордеров против референсов — перенесено в `docs/backlog.md`.
|
||||||
@@ -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,81 @@
|
|||||||
|
# Хранить данные для сортировки по популярности инструментов
|
||||||
|
|
||||||
|
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
|
||||||
|
> ранний исследовательский вариант, не являющийся текущим планом Использование:
|
||||||
|
> только историческая справка
|
||||||
|
|
||||||
|
Идея выложить на Vercel и использовать Vercel KV (на базе Upstash Redis) — это
|
||||||
|
отличный, рабочий и очень быстрый в реализации вариант. Для 150 инструментов
|
||||||
|
объем данных будет крошечным, но здесь есть пара критически важных нюансов по
|
||||||
|
лимитам, о которых нужно знать на старте, чтобы бесплатный тариф не отключился в
|
||||||
|
первый же месяц. [1]
|
||||||
|
|
||||||
|
## ⚠️ Подводный камень: Лимиты Vercel KV на Free-тарифе
|
||||||
|
|
||||||
|
На бесплатном тарифе (Hobby) для Vercel KV действуют следующие ограничения: [2]
|
||||||
|
|
||||||
|
- 30 000 запросов в месяц (суммарно чтение + запись).
|
||||||
|
- Ограничение около 3 000 запросов в день. [2, 3]
|
||||||
|
|
||||||
|
Если делать запрос к базе на каждый просмотр страницы («посещение»), лимит в 30
|
||||||
|
тысяч улетит мгновенно при первой же паре сотен посетителей в день.
|
||||||
|
|
||||||
|
## 💡 Как сделать это правильно и бесплатно
|
||||||
|
|
||||||
|
Чтобы не выйти за рамки лимитов, архитектуру нужно построить экономно:
|
||||||
|
|
||||||
|
## 1. Чтение: Сортировка на этапе сборки (ISR / SSG)
|
||||||
|
|
||||||
|
Не нужно запрашивать KV из Edge-функции при каждом открытии сайта пользователем.
|
||||||
|
|
||||||
|
- Используйте Incremental Static Regeneration (ISR) (если вы делаете на Next.js)
|
||||||
|
или раз в сутки запускайте Cron-сид (Vercel Cron).
|
||||||
|
- Сайт раз в несколько часов запрашивает весь список из KV (всего 1 запрос на
|
||||||
|
получение общей структуры), сортирует инструменты и кеширует эту страницу как
|
||||||
|
статическую.
|
||||||
|
- Результат: 100 000 пользователей увидят отсортированный список, а вы потратите
|
||||||
|
всего пару запросов к KV в день.
|
||||||
|
|
||||||
|
## 2. Запись: Защита от накрутки и экономия («Добавление в избранное»)
|
||||||
|
|
||||||
|
«Избранное» — идеальная метрика для KV, так как её нажимают редко. А вот
|
||||||
|
«Посещения» лучше отправлять не на каждый клик, а пачками (батчингом) или
|
||||||
|
использовать для просмотров бесплатную Vercel Web Analytics (там лимит 25 000
|
||||||
|
событий в месяц обновляется отдельно).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Оптимальная структура данных в Redis (KV)
|
||||||
|
|
||||||
|
Вместо 150 отдельных ключей, храните всё в одной хэш-таблице (Hash) или
|
||||||
|
сортированном множестве (Sorted Set). Это сэкономит количество запросов. Вариант
|
||||||
|
структуры через Hash:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Запись / инкремент (в API роуте Vercel)
|
||||||
|
import { kv } from "@vercel/kv";
|
||||||
|
//Когда пользователь зашел на инструмент 'crop-png'
|
||||||
|
await kv.hincrby("tools:visits", "crop-png", 1);
|
||||||
|
// Когда добавил в избранное
|
||||||
|
await kv.hincrby("tools:favorites", "crop-png", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
Как забрать всё одним запросом для сортировки:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Получаем сразу все просмотры (вернет объект вида { 'crop-png': '120', 'resize-png': '85' })
|
||||||
|
const allVisits = await kv.hgetall("tools:visits");
|
||||||
|
const allFavorites = await kv.hgetall("tools:favorites");
|
||||||
|
// Дальше соединяем в массив на клиенте/сервере и сортируем:
|
||||||
|
// Популярность = Просмотры + (Избранное * 5) <- так лайки весят больше
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ Альтернатива: Cloudflare Workers + KV
|
||||||
|
|
||||||
|
Если вы боитесь упереться в 30 000 запросов на Vercel, рассмотрите связку
|
||||||
|
Cloudflare Workers + Workers KV. Там на бесплатном тарифе дают: [4]
|
||||||
|
|
||||||
|
- 100 000 запросов на запись в сутки.
|
||||||
|
- 10 000 000 (10 млн) запросов на чтение в сутки.
|
||||||
|
- Для пет-проекта этого хватит навсегда, даже если вы будете делать запросы
|
||||||
|
напрямую с фронтенда без кеширования.
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
## lint и прочие правила
|
||||||
|
|
||||||
|
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||||
|
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||||
|
правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`.
|
||||||
|
Правило реализовано; Q2 перевёл i18n baseline к zero-warning через
|
||||||
|
policy-aware fallback.
|
||||||
|
- [x] Проверка текстов в svelte/ts/html — реализовано правилом
|
||||||
|
`i18n/no-hardcoded-user-text` (warn, baseline 0): ловит текст в шаблоне и
|
||||||
|
в пользовательских атрибутах, исключения — бренд, форматы, имена файлов,
|
||||||
|
registry-строки и `/kit`. Детали в `web/eslint-plugins/README.md`.
|
||||||
|
- [ ] `tseslint.config(...)` deprecated: сигнатура
|
||||||
|
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
|
||||||
|
устаревшей, нужен переход на `defineConfig` из `typescript-eslint` —
|
||||||
|
`web/eslint.config.js`.
|
||||||
|
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
|
||||||
|
до этого Q2 держит pattern-based policy-исключение
|
||||||
|
|
||||||
|
## Мелочи всякие
|
||||||
|
|
||||||
|
- [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют
|
||||||
|
количество инструментов, точное совпадение текстов, поиск по
|
||||||
|
`img[alt="Result image"]`, svelte классам и тому подобные
|
||||||
|
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||||
|
выделенной области
|
||||||
|
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||||
|
какой размер и 2 сетки)
|
||||||
|
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||||
|
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||||
|
- [ ] можно ли в тестах получать i18n ключ вместо текста?
|
||||||
|
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
|
||||||
|
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
||||||
|
должны быть настраиваемой 0-100%, а не переключателем
|
||||||
|
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
||||||
|
единую структуру, позволит упростить некоторые вещи - например
|
||||||
|
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
|
||||||
|
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
||||||
|
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
||||||
|
`ondownloadtxt`, `ondownload`,
|
||||||
|
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
||||||
|
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
||||||
|
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть
|
||||||
|
в кнопке Download
|
||||||
|
- [ ] Кнопка download должна сразу писать какой формат сейчас, быть с селектором
|
||||||
|
формата и подсвечивать предупреждение если формат не совместим (альфа
|
||||||
|
канал)
|
||||||
|
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
||||||
|
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
||||||
|
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
||||||
|
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
||||||
|
паспортом, семейства с кириллицей)
|
||||||
|
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
||||||
|
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
|
||||||
|
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
|
||||||
|
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
||||||
|
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
||||||
|
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
||||||
|
значения
|
||||||
|
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
|
||||||
|
изменено
|
||||||
|
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
||||||
|
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
||||||
|
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
||||||
|
- [ ] обновить страницу kit или сделать storybook
|
||||||
|
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
|
||||||
|
удаление, добавление инструментов. переход один инструмент - pipeline без
|
||||||
|
перезагрузки
|
||||||
|
- [ ] Слаги инструментов (`*-png`) — надо переименовть, они работают с любыми
|
||||||
|
изображениями. Надо сделать отдельный интерфейс маппинга инструмент ->
|
||||||
|
ссылка
|
||||||
|
- [ ] `CatalogToolbar`: фильтры используют сырой `<button>`, а не `ui/Button` —
|
||||||
|
единый контрол для всей библиотеки
|
||||||
|
(`web/src/lib/components/CatalogToolbar.svelte`).
|
||||||
|
- [ ] Вынести `ToneVariant`/варианты в общий модуль: `const`-объект с тонами
|
||||||
|
продублирован в `ui/Badge.svelte` и кнопках
|
||||||
|
(`web/src/lib/components/ui/Badge.svelte`).
|
||||||
|
- [ ] `PreviewTile`: canvas должен сам выбирать соотношение сторон в диапазоне
|
||||||
|
16:9…9:16 вместо фиксированного
|
||||||
|
(`web/src/lib/components/layout/PreviewTile.svelte`).
|
||||||
|
- [ ] `ThemeToggle`: свои анимации иконок и своя кнопка вместо `IconButton`
|
||||||
|
(`web/src/lib/components/ui/ThemeToggle.svelte`).
|
||||||
|
- [ ] Радиусы бордеров не совпадают с референсом: в `refs` радиусы были 0, а мы
|
||||||
|
взяли ненулевой `--radius-m` для всех бордеров. Проверить токен против
|
||||||
|
референсов; возможно он должен быть 0, а скругления оставить только там,
|
||||||
|
где они реально есть в дизайне.
|
||||||
|
- [ ] `Footer`: версию брать из `package.json`, а не писать `v0.1.0` вручную
|
||||||
|
(`web/src/lib/components/layout/Footer.svelte`).
|
||||||
|
|
||||||
|
## Переезд
|
||||||
|
|
||||||
|
- [x] добавить i18n в preview
|
||||||
|
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||||
|
`tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog
|
||||||
|
|
||||||
|
## 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, оффлайн-режим, установка (после S1)
|
||||||
|
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||||
|
(после wasm-фаз 5–7)
|
||||||
|
|
||||||
|
## Качество production UI
|
||||||
|
|
||||||
|
Q5–Q6 и Q7–Q8 закрывают известные preview-проблемы, special fixtures, smoke,
|
||||||
|
layout/view-model инварианты, browser matrix, coverage-гейт и линтеры; активных
|
||||||
|
`test.fixme` в e2e больше нет.
|
||||||
|
|
||||||
|
- [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`.
|
||||||
|
- [x] `resize-png` и `crop-png` используют размеры текущего source через общий
|
||||||
|
source-aware schema resolver — `web/e2e/pipeline.spec.ts`.
|
||||||
|
- [x] Reset сохраняет source/text, восстанавливает defaults и запускает deferred
|
||||||
|
auto-run — `web/e2e/pipeline.spec.ts`.
|
||||||
|
- [x] Ошибки хранят i18n key/vars и переводятся при выводе; изменение языка
|
||||||
|
обновляет alert — `web/e2e/i18n.spec.ts`.
|
||||||
|
- [x] Malformed/special PNG fixtures: CRC, truncated, palette и 16-bit проходят
|
||||||
|
через upload без краша — `web/e2e/png-fixtures.spec.ts`.
|
||||||
|
- [x] Registry-wide smoke покрывает все 122 инструмента по input/result-типу —
|
||||||
|
`web/e2e/tools-smoke.spec.ts`, `generators.spec.ts`,
|
||||||
|
`text-and-verdicts.spec.ts`.
|
||||||
|
- [x] Layout-группировка вынесена в `resolveLayoutGroups` и покрыта
|
||||||
|
unit-тестами; `SchemaFields.svelte` использует общий seam.
|
||||||
|
- [x] `SchemaPreview` view-model вынесена в `buildSchemaPreviewModel` и покрыта
|
||||||
|
unit-тестами для всех input/result-типов.
|
||||||
|
- [x] Browser matrix: полный Chromium, Firefox/WebKit для browser-critical
|
||||||
|
спецификаций и mobile Chromium 390×844 — `web/playwright.config.ts`.
|
||||||
|
- [x] Расхождение декодеров зафиксировано как ожидаемое поведение: Firefox и
|
||||||
|
WebKit декодируют `truncated-no-iend` и `idat-cut`, а WebKit ещё и
|
||||||
|
`crc-bad-idat`. Таблица движков живёт в `web/e2e/png-fixtures.spec.ts`,
|
||||||
|
продукт не ужесточается, и любая выгрузка битого файла обязана давать
|
||||||
|
alert, а не падение или «пустой» результат.
|
||||||
|
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
||||||
|
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
||||||
|
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
||||||
|
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ. Это
|
||||||
|
продуктовая задача, не Q6d.
|
||||||
|
|
||||||
|
## Идеи
|
||||||
|
|
||||||
|
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) в `web/src/lib/registry/types.ts`;
|
||||||
|
`SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены;
|
||||||
|
`png-to-bmp` переведён сюда же). Q6c покрывает этот фиксированный контракт
|
||||||
|
unit/E2E-проверками; переключатель форматов и quality остаётся отдельным
|
||||||
|
UX-райзом. **Осталось (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. **Контраст текста на акцентных кнопках — частично закрыт**. Для dark-темы
|
||||||
|
`--color-main`/`--color-accent` подняты до tone 56, чтобы пройти текущий
|
||||||
|
порог `palette.test.ts` 4.0; targeted-тест проходит. Light-тема сохраняет
|
||||||
|
контраст ~4.1–4.3, что ниже AA 4.5 для обычного текста, поэтому после
|
||||||
|
завершения редизайна нужно повторно поднять тона и вернуть порог 4.5. Также
|
||||||
|
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
|
||||||
|
light-danger tone 48 = 4.25 на границе.
|
||||||
|
|
||||||
|
14. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
|
||||||
|
реализовать процесс показа маски результата. **Контекст:** в старом
|
||||||
|
контракте поле `preview` у `remove-color`/`remove-background` показывало
|
||||||
|
быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что
|
||||||
|
будет вырезано. При миграции на единый `run(ctx)` поле убрано (шаг 8,
|
||||||
|
`web/src/lib/registry/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`).
|
||||||
|
Q6b закрывает контракт ZIP/download: unit-тесты `web/src/lib/zip.test.ts` и
|
||||||
|
browser-сценарий `web/e2e/pipeline.spec.ts` проверяют имена частей и
|
||||||
|
содержимое архива. **1 → many:** подключение остальных потребителей
|
||||||
|
(gif-to-frames, separate-colors) при необходимости; формат/качество файлов
|
||||||
|
внутри zip — отложено до UX-райза «Download» (п.7). **many → 1** (вход =
|
||||||
|
архив/несколько картинок → единый результат): не реализовано. Зависит от
|
||||||
|
batch-обработки архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна
|
||||||
|
отдельная проработка входа (распаковка zip в браузере, порядок файлов,
|
||||||
|
поведение в пайплайне — такие инструменты терминальны, как и 1 → many).
|
||||||
|
|
||||||
|
16. **Главная должна отличаться от каталога** — оценка S. Сейчас
|
||||||
|
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
|
||||||
|
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
|
||||||
|
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
|
||||||
|
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
|
||||||
|
Замысел: главная — крупнее и с более простым поиском, без категорий; каталог
|
||||||
|
остаётся группировкой с фильтрами. Найдено измерением дублирования разметки
|
||||||
|
по 65 `.svelte`-файлам: файлы побайтово идентичны.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Чек-лист ручного тестирования production UI
|
||||||
|
|
||||||
|
> Вспомогательный документ к e2e-тестам (`web/e2e/`, `pnpm --dir web test:e2e`)
|
||||||
|
> и стратегии `docs/testing-strategy.md`. Автоматизированная часть закрывает
|
||||||
|
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
|
||||||
|
>
|
||||||
|
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
|
||||||
|
> → `node scripts/serve-static.mjs`).
|
||||||
|
>
|
||||||
|
> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания»,
|
||||||
|
> сообщения об ошибках человекочитаемые**.
|
||||||
|
|
||||||
|
## A. Краевые PNG-файлы
|
||||||
|
|
||||||
|
> Фикстуры для этого раздела генерируются скриптом: `pnpm generate:fixtures` →
|
||||||
|
> `tests/fixtures/manual-edge-cases/`.
|
||||||
|
|
||||||
|
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
|
||||||
|
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
|
||||||
|
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
|
||||||
|
remove-background, feather-edges, clean-edges дают корректные края.
|
||||||
|
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется
|
||||||
|
(авто-покрытие: `web/e2e/png-fixtures.spec.ts`).
|
||||||
|
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
||||||
|
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
||||||
|
`pipeline.spec.ts` и `png-fixtures.spec.ts`).
|
||||||
|
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
|
||||||
|
прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`.
|
||||||
|
|
||||||
|
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
||||||
|
|
||||||
|
| Файл | Описание |
|
||||||
|
| ------------------------ | ------------------------------------------------------------ |
|
||||||
|
| `1x1.png` | Минимальное изображение 1×1, сплошной красный |
|
||||||
|
| `1x1-transparent.png` | 1×1, полностью прозрачный (alpha=0) |
|
||||||
|
| `2x2-extreme.png` | 2×2: чёрный, белый, полупрозрачный красный, прозрачный синий |
|
||||||
|
| `1024x1024-checker.png` | 1024×1024, шахматка ≈1 мегапиксель |
|
||||||
|
| `1920x1080-gradient.png` | 1920×1080, градиент ≈2 мегапикселя |
|
||||||
|
| `alpha-checkerboard.png` | 64×64, чёрно-белая шахматка с чередующейся альфой |
|
||||||
|
| `alpha-gradient.png` | 128×64, альфа-градиент 0→255 по ширине |
|
||||||
|
| `sparse-alpha.png` | 100×100, 10% пикселей с alpha=0 |
|
||||||
|
| `all-black-opaque.png` | 64×64, сплошной чёрный |
|
||||||
|
| `all-white-opaque.png` | 64×64, сплошной белый |
|
||||||
|
| `all-transparent.png` | 64×64, полностью прозрачное |
|
||||||
|
| `palette-4colors.png` | 32×32, палитровый 4 цвета (RGBW) |
|
||||||
|
| `palette-256.png` | 64×64, палитровый полная палитра 256 цветов |
|
||||||
|
| `16bit-subtle.png` | 32×32, 16-bit RGBA, тонкий градиент |
|
||||||
|
| `noise-pattern.png` | 100×100, шумовая текстура |
|
||||||
|
| `1x4000-strip.png` | 1×4000, длинная вертикальная полоса |
|
||||||
|
| `4000x1-strip.png` | 4000×1, широкая горизонтальная полоса |
|
||||||
|
| `corrupt-bad-crc.png` | 4×4, повреждённый CRC в IDAT |
|
||||||
|
| `truncated.png` | Обрезанный файл (только PNG-заголовок + часть IHDR) |
|
||||||
|
| `fake-png.txt` | Текстовый файл «.png» — не-PNG |
|
||||||
|
|
||||||
|
## B. Края параметров
|
||||||
|
|
||||||
|
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate — не
|
||||||
|
«залипает» running.
|
||||||
|
- [ ] Пустые/NaN поля (напр. width/height) — санитизация (silent repair), нет
|
||||||
|
красного alert-текста в виде `errors.*`.
|
||||||
|
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
|
||||||
|
именные цвета) — silent repair или понятная ошибка.
|
||||||
|
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет.
|
||||||
|
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
||||||
|
|
||||||
|
## C. Буфер обмена и drag-n-drop
|
||||||
|
|
||||||
|
- [ ] В реальном браузере кнопка copy на текстовом результате (png-to-base64 и
|
||||||
|
т.п.) кладёт текст в буфер (в Playwright не тестируем из-за permissions).
|
||||||
|
- [ ] Drag-n-drop файла на dropzone страницы инструмента (не только файл-диалог)
|
||||||
|
— UX-ощущения и корректность.
|
||||||
|
|
||||||
|
## D. Производительность и отзывчивость
|
||||||
|
|
||||||
|
- [ ] Долгие авто-рераны на больших картинках (jpeg-artifacts, dithering,
|
||||||
|
quantize на 4К) — UI не фризит, канселяция/дебаунс срабатывает.
|
||||||
|
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
|
||||||
|
залипаний, панели не прыгают.
|
||||||
|
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
|
||||||
|
(утечек blob-URL/bitmap быть не должно).
|
||||||
|
|
||||||
|
## E. Визуальный слой (доделываем по ходу)
|
||||||
|
|
||||||
|
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
|
||||||
|
desktop) на всех 4 маршрутах.
|
||||||
|
- [ ] Тема light/dark: контраст вердиктов (Yes/No), статус «LIVE PREVIEW»,
|
||||||
|
мета-инфо бликов не даёт.
|
||||||
|
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
|
||||||
|
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
|
||||||
|
|
||||||
|
## F. Субъективная корректность
|
||||||
|
|
||||||
|
- [ ] dithering / two-colors / quantize — результат «по ощущениям» соответствует
|
||||||
|
описанию.
|
||||||
|
- [ ] watermark-tile / add-text / date-stamp на кириллице — рендер текста
|
||||||
|
корректный (не «кракозябры»).
|
||||||
|
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
||||||
|
|
||||||
|
## G. Закрытые Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1/Q6f.2/Q6f.3-проблемы
|
||||||
|
|
||||||
|
> Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1/Q6f.2/Q6f.3-проблемы закрыты автоматическими тестами;
|
||||||
|
> для ручной проверки остаются субъективные сценарии разделов A–F.
|
||||||
|
|
||||||
|
- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без
|
||||||
|
кнопки Generate. `web/e2e/generators.spec.ts`.
|
||||||
|
- [x] **resize-png/crop-png:** размеры текущего source используются по
|
||||||
|
умолчанию, alert не появляется. `web/e2e/pipeline.spec.ts`.
|
||||||
|
- [x] **Reset:** defaults восстанавливаются, source сохраняется, deferred
|
||||||
|
auto-run завершается результатом. `web/e2e/pipeline.spec.ts`.
|
||||||
|
- [x] **Локализация ошибок:** alert хранит key/vars, переводится при выводе и
|
||||||
|
обновляется при смене языка. `web/e2e/i18n.spec.ts`.
|
||||||
|
- [x] **FileResult → ZIP:** `split-into-parts-png` скачивает
|
||||||
|
`split-into-parts-png.zip` с именованными PNG-частями; содержимое и
|
||||||
|
сигнатуры проверяются `web/src/lib/zip.test.ts` и
|
||||||
|
`web/e2e/pipeline.spec.ts`.
|
||||||
|
- [x] **Output MIME/quality:** JPG/WebP/BMP имеют ожидаемые расширения и
|
||||||
|
сигнатуры, quality 10/90 меняет размер файла; `web/e2e/pipeline.spec.ts`.
|
||||||
|
- [x] **Malformed/special PNG:** CRC, truncated, palette и 16-bit fixtures
|
||||||
|
проходят через upload без краша; `web/e2e/png-fixtures.spec.ts`.
|
||||||
|
- [x] **Registry smoke:** все 122 production tools имеют специализированный
|
||||||
|
browser smoke по input/result-типу; `web/e2e/tools-smoke.spec.ts`,
|
||||||
|
`generators.spec.ts`, `text-and-verdicts.spec.ts`.
|
||||||
|
- [x] **Layout resolver:** `resolveLayoutGroups` покрыт unit-тестами; UI
|
||||||
|
группировка полей использует тот же seam.
|
||||||
|
- [x] **Preview model:** `buildSchemaPreviewModel` покрывает source/result
|
||||||
|
values, формат и `hasResult` для image/text/verdict/files.
|
||||||
|
- [x] **Browser matrix:** pipeline, verdicts и PNG-fixtures проходят в Firefox и
|
||||||
|
WebKit, mobile viewport 390×844 — в Chromium; отказ декодера битого PNG
|
||||||
|
зафиксирован per-engine. См. раздел 7 `docs/testing-strategy.md`.
|
||||||
|
|
||||||
|
## Как долго
|
||||||
|
|
||||||
|
- Полный прогон: ~40 минут (все разделы, включая мегапиксели и 4К) в двух
|
||||||
|
браузерах.
|
||||||
|
- Быстрый смоук (< 15 мин): A (кроме мегапикселей), B (выборочно), C, F
|
||||||
|
выборочно, G обязательно.
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
# План: исправления по аудиту 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. Source-aware defaults для dimension-полей
|
||||||
|
|
||||||
|
Q5 добавил общий `defaultFromSource` resolver в `registry-schema`. Для
|
||||||
|
`resize-png` и `crop-png` дефолт после загрузки равен размерам текущего source;
|
||||||
|
до загрузки используется положительный fallback `1×1`. Один-sided `0` у resize
|
||||||
|
сохраняет режим auto.
|
||||||
|
|
||||||
|
### 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. Source-aware bounds (отдельный следующий шаг)
|
||||||
|
|
||||||
|
Q5 реализовал только defaults dimension-полей через `SchemaContext` и
|
||||||
|
`defaultFromSource`. Динамические min/max для crop x/y, shift и resize остаются
|
||||||
|
отдельной задачей: bounds не следует смешивать с default resolver.
|
||||||
|
|
||||||
|
**Что нужно спроектировать и завести:**
|
||||||
|
|
||||||
|
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
||||||
|
→ `PixelImage` (width/height); для bounds понадобится передать этот context в
|
||||||
|
`SchemaFields` и контролы.
|
||||||
|
|
||||||
|
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
|
||||||
|
источника вместо жёстких чисел. Кандидат:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
x: field.slider({
|
||||||
|
label: "fields.x",
|
||||||
|
bound: "sourceWidth", // min/max = ±sourceWidth
|
||||||
|
default: 0,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoundSpec = "sourceWidth" | "sourceHeight" | "minSide" | "maxSide"`,
|
||||||
|
применимо к `NumberSpec`, `SliderSpec`, `OffsetSpec`, `DimensionSpec`.
|
||||||
|
|
||||||
|
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
|
||||||
|
размера источника; при смене картинки диапазон пересчитывается, текущее
|
||||||
|
значение пере-клампится.
|
||||||
|
|
||||||
|
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
|
||||||
|
(offsetX/offsetY), resize (max → maxSide).
|
||||||
|
|
||||||
|
Это отдельный refactor после Q6/Q7; source-aware defaults из Q5 не заменяют
|
||||||
|
bounds.
|
||||||
|
|
||||||
|
### 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,131 @@
|
|||||||
|
# План: платформа — общее ядро, 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 (после S1)
|
||||||
|
|
||||||
|
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
||||||
|
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
||||||
|
- [ ] **[P2]** Service worker (SvelteKit `src/service-worker.ts`): precache
|
||||||
|
app-shell; immutable-ассеты (build-hash) — cache-first; навигация по
|
||||||
|
инструментам — stale-while-revalidate; offline-fallback страница. Спайком
|
||||||
|
оценить вес полного precache: страницы маленькие, тяжёлое тащить по
|
||||||
|
требованию.
|
||||||
|
- [ ] **[P3]** Оффлайн-UX: индикатор «offline — работаете локально», своя кнопка
|
||||||
|
установки (на `beforeinstallprompt`), стратегия обновления версии (prompt
|
||||||
|
vs skipWaiting — решить на спайке).
|
||||||
|
- [ ] **[P4]** SEO-безопасность: SW не перехватывает запросы ботов и не меняет
|
||||||
|
HTML-ответы; canonical/prerendered-страницы не затрагиваются. Проверка:
|
||||||
|
Lighthouse PWA + SEO зелёные после внедрения.
|
||||||
|
- Гейт: каталог и топ-инструменты полностью работают в airplane-mode; повторный
|
||||||
|
визит без сети открывается мгновенно.
|
||||||
|
|
||||||
|
## 3. Порядок и связи
|
||||||
|
|
||||||
|
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
|
||||||
|
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
|
||||||
|
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
||||||
|
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
||||||
|
GEO-текстов (`llms.txt`, описания категорий).
|
||||||
|
|
||||||
|
## 4. Шрифты: лицензирование и DOM-free рендер текста
|
||||||
|
|
||||||
|
Текст — единственный класс операций, который 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,357 @@
|
|||||||
|
# План: SEO + GEO — индексация и конкуренция в image tools
|
||||||
|
|
||||||
|
> **Статус (2026-09-11):** план к выполнению. Переезд `preview/*` на реальные
|
||||||
|
> маршруты выполнен (в `noindex` сохраняются до запуска S1). Сейчас применять
|
||||||
|
> можно 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 страницы
|
||||||
|
инструментов находятся в корне на `/tools/[id]`, каталог — на `/list-tools`;
|
||||||
|
текущую схему не менять без отдельного SEO-плана: после индексации
|
||||||
|
переименование URL означает 301 и потерю позиций.
|
||||||
|
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или
|
||||||
|
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя
|
||||||
|
подрывать собственными скриптами.
|
||||||
|
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
|
||||||
|
каждому инструменту, с ручной вычиткой.
|
||||||
|
|
||||||
|
## 1. Аудит (2026-09-09)
|
||||||
|
|
||||||
|
Фундамент уже есть:
|
||||||
|
|
||||||
|
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
|
||||||
|
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
|
||||||
|
JS. Это главный технический козырь.
|
||||||
|
- 122 инструмента в `web/src/lib/registry` с «SEO-шными» id (`crop-png`,
|
||||||
|
`add-border-png`, …) — URL уже повторяют паттерн конкурента.
|
||||||
|
- На корневых страницах инструментов есть базовые `<title>` и `robots`; полный
|
||||||
|
SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2.
|
||||||
|
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
|
||||||
|
инструментов против конкурента: `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 | `web/src/routes/tools/[id]/+page.svelte` | S1 |
|
||||||
|
| Контент страницы (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 запросов.
|
||||||
|
Покрытие инструментов у нас почти полное (122); приоритет — контент и
|
||||||
|
качество страниц ядра, затем хвосты.
|
||||||
|
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: без домена (параллельно с quality-планом)
|
||||||
|
|
||||||
|
- [ ] **[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-сайтом. Вести
|
||||||
|
параллельно с текущими quality-задачами.
|
||||||
|
- [ ] **[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` реестра (`web/src/lib/registry`) + статических
|
||||||
|
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в
|
||||||
|
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`),
|
||||||
|
учёт опционального `BASE_PATH`.
|
||||||
|
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
|
||||||
|
cleanup — вернуть минимальный allow-all.
|
||||||
|
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
||||||
|
актуальному маршруту), OG-теги (title/description/type/url/image),
|
||||||
|
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
||||||
|
для абсолютных URL в canonical и OG:url.
|
||||||
|
|
||||||
|
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d. Гейты: build
|
||||||
|
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
|
||||||
|
> canonical/OG присутствуют на всех страницах; e2e зелёные.
|
||||||
|
|
||||||
|
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
|
||||||
|
|
||||||
|
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
||||||
|
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
||||||
|
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
|
||||||
|
вручную пачками по категориям (самый трудоёмкий этап: ~122 × 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` (122 реализовано; у конкурента 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 можно делать параллельно с текущими quality-задачами (не
|
||||||
|
блокируют). R2 (домен) — блокер только для S1-domain (canonical + sitemap +
|
||||||
|
снятие `noindex`). После индексации переименование = переезд с 301, потеря
|
||||||
|
позиций и ссылок — не делать.
|
||||||
|
|
||||||
|
## 7. Что осознанно НЕ делаем
|
||||||
|
|
||||||
|
- Клонирование 311 URL конкурента (низкокачественные дубли + нет ресурсов на
|
||||||
|
контент) — вместо этого ядро + обоснованные хвосты.
|
||||||
|
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
|
||||||
|
- GA/реклама/трекинг — противоречит позиционированию.
|
||||||
|
- Дорвеи, программные зеркала, покупные ссылки.
|
||||||
|
- Массово-сгенерированные форматные клоны (`crop-jpeg`/`crop-webp` как копии
|
||||||
|
`crop-png` с подстановкой формата в шаблонный текст) — doorway-паттерн, риск
|
||||||
|
deindex всего сайта. Форматная страница — только уникальный контент + вордстат
|
||||||
|
(§5); редиректы — только для опечаток/переименований.
|
||||||
|
- URL-алиасы без `-png` для существующих инструментов и зеркальные
|
||||||
|
страницы-конвертеры «на каждый формат» без вордстата.
|
||||||
|
|
||||||
|
## 8. Связанные документы
|
||||||
|
|
||||||
|
- `docs/tools-map.md` — карта «наш инструмент ↔ интент конкурента».
|
||||||
|
- `docs/backlog.md` — пункты SEO/GEO в общем бэклоге.
|
||||||
|
- `docs/roadmap.md` — фазы 2–8 (ядро TS→Rust/wasm, CLI); фазы 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,240 @@
|
|||||||
|
# Quality gates
|
||||||
|
|
||||||
|
> Статус: **active**. Каноническое описание быстрых проверок репозитория.
|
||||||
|
> Краткие обязательные команды остаются в `AGENTS.md`.
|
||||||
|
|
||||||
|
## Каноническая команда
|
||||||
|
|
||||||
|
Запускать из корня репозитория:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify` выполняет последовательно:
|
||||||
|
|
||||||
|
1. `pnpm --dir web check` — SvelteKit sync и `svelte-check`.
|
||||||
|
2. `pnpm --dir web test:coverage` — полный Vitest-набор вместе с
|
||||||
|
coverage-гейтом.
|
||||||
|
3. `pnpm --dir web test:rules` — отдельный быстрый прогон тестов ESLint-правил.
|
||||||
|
4. `pnpm --dir web lint:all` — ESLint, Stylelint и token audit.
|
||||||
|
5. `pnpm --dir web exec prettier --check .` — форматирование кода `web/`.
|
||||||
|
6. `pnpm check:docs` — форматирование Markdown.
|
||||||
|
7. `pnpm --dir web build` — production build.
|
||||||
|
|
||||||
|
Полный `test` уже включает тесты lint-правил; отдельный `test:rules` в `verify`
|
||||||
|
оставлен как явный быстрый диагностический этап.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
`pnpm --dir web test:coverage` считает покрытие только по `src/lib` и падает
|
||||||
|
ниже порога. Scope и список исключений — в `web/vitest.config.ts`, обоснование —
|
||||||
|
в `docs/testing-strategy.md`.
|
||||||
|
|
||||||
|
| Метрика | Порог | Baseline |
|
||||||
|
| ---------- | ----- | -------- |
|
||||||
|
| statements | 90 | 91.77% |
|
||||||
|
| branches | 82 | 83.3% |
|
||||||
|
| functions | 72 | 74.31% |
|
||||||
|
| lines | 91 | 92.53% |
|
||||||
|
|
||||||
|
Порог — нижняя граница от текущего baseline, а не оценка качества: он блокирует
|
||||||
|
регрессию и не требует сначала закрывать весь долг. Локальный замер занимает ~30
|
||||||
|
с, поэтому отдельного прогона в `verify` нет — `test:coverage` заменяет `test`.
|
||||||
|
|
||||||
|
## E2E
|
||||||
|
|
||||||
|
E2E не входит в `verify`, потому что Playwright поднимает build и статический
|
||||||
|
сервер. Locator, fixture, i18n и `test.fixme`-политика описаны в
|
||||||
|
`docs/testing-strategy.md`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm --dir web test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Запускать его обязательно для изменений маршрутов, загрузки файла, результата,
|
||||||
|
download, темы или локализации. Изменения runtime fixtures/загрузки файла
|
||||||
|
проверяются отдельным `web/e2e/png-fixtures.spec.ts`; ручные бинарники из
|
||||||
|
`tests/fixtures/` не входят в `verify`. В текущем CI это отдельный non-blocking
|
||||||
|
baseline; после закрытия известных ошибок его можно сделать blocking.
|
||||||
|
|
||||||
|
Локально прогон идёт с `workers: 1` и заметно медленнее: это осознанный размен,
|
||||||
|
экономит память и оставляет компьютер отзывчивым. Playwright не умеет
|
||||||
|
per-project лимит воркеров, а browser matrix упирается в память, а не CPU.
|
||||||
|
Тяжёлые прогоны запускаются по команде пользователя — по одному, без фонового
|
||||||
|
запуска и циклов повторов; детали в `docs/agent-workflow.md`.
|
||||||
|
|
||||||
|
## Быстрые подмножества
|
||||||
|
|
||||||
|
| Изменение | Команда |
|
||||||
|
| ---------------------------- | ------------------------------------------------------- |
|
||||||
|
| Только Markdown | `pnpm check:docs` |
|
||||||
|
| TypeScript/Svelte | `pnpm --dir web check` и `pnpm --dir web test` |
|
||||||
|
| Lint-правило или lint-конфиг | `pnpm --dir web test:rules` и `pnpm --dir web lint:all` |
|
||||||
|
| Пользовательский поток | `pnpm --dir web test:e2e` |
|
||||||
|
| Fixtures/загрузка файла | `pnpm --dir web test:e2e` |
|
||||||
|
| Полный локальный gate | `pnpm verify` |
|
||||||
|
|
||||||
|
## Errors и warnings
|
||||||
|
|
||||||
|
- `errors` завершают соответствующий инструмент ненулевым кодом.
|
||||||
|
- После Q2 ESLint запускается с `--max-warnings=0`: новые ESLint warnings
|
||||||
|
блокируют gate.
|
||||||
|
- Token audit оставляет unused-token сообщение неблокирующим: после Q7a пыль
|
||||||
|
вычищена, решение о fail-режиме зафиксировано в Q7d — новый токен появляется
|
||||||
|
вместе с потребителем, но стиль не блокирует деплой.
|
||||||
|
- Резолв `var()` в `app.css` — ошибка: несуществующий токен молча роняет
|
||||||
|
объявление, поэтому этот check failing, в отличие от unused.
|
||||||
|
- `lint:all` сейчас чист: 0 errors, 0 i18n warnings, 0 unused tokens.
|
||||||
|
- `dict-consistency` использует base-locale fallback для registry metadata и
|
||||||
|
pattern-based policy для временно неиспользуемых `tools.*.params`; это не
|
||||||
|
молчаливый `allowPaths`-список.
|
||||||
|
|
||||||
|
## Решения, принятые без автоматизации
|
||||||
|
|
||||||
|
Здесь лежат решения «что сознательно НЕ вводим», чтобы не пересматривать их
|
||||||
|
заново через полгода. Для каждого — причина и то, что пересмотрело бы решение. В
|
||||||
|
`docs/backlog.md` такие пункты не дублируются: backlog живёт задачами, а не
|
||||||
|
историей решений.
|
||||||
|
|
||||||
|
### Детектор дублирования разметки — не вводим
|
||||||
|
|
||||||
|
Ни свой AST-правило, ни готовая зависимость (jscpd, `eslint-plugin-sonarjs`).
|
||||||
|
|
||||||
|
- Измерение по 65 `.svelte` в `components/**` и `routes/**`: при пороге 5
|
||||||
|
нормализованных строк — одна осмысленная пара, при пороге 3 строки — 17 пар и
|
||||||
|
все они шум (`</label>`, `{/each}`, `{#if children}`). Семейство
|
||||||
|
schema-контролов делит максимум 4 строки — это законное сходство by design.
|
||||||
|
- Причина: построчное сравнение Svelte-разметки принципиально ненадёжно —
|
||||||
|
одинаковый контейнер с разным содержимым даёт ложное срабатывание, а намеренно
|
||||||
|
общий класс — ложную тишину. Чтобы не ловить шум, порог пришлось бы поднять до
|
||||||
|
12+ строк, и тогда правило срабатывает ровно один раз.
|
||||||
|
- Контроль остаётся на ревью (расхождение видно в диффе) и на поведенческих
|
||||||
|
e2e-проверках; извлечение общей разметки закреплено конвенцией в `AGENTS.md`.
|
||||||
|
- Пересмотреть, если появится реально «уехавшая» копипаста в компонентах или
|
||||||
|
библиотека разрастётся на уровень композиций страниц.
|
||||||
|
|
||||||
|
### Линтер согласованности комментариев — не вводим
|
||||||
|
|
||||||
|
Описания инструментов различаются осознанно, и ловить это текстовым правилом
|
||||||
|
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
|
||||||
|
`TODO`/`FIXME` перенесены в backlog, незарегистрированных пометок в коде нет;
|
||||||
|
новые запрещены правилом в `AGENTS.md`.
|
||||||
|
|
||||||
|
### Visual snapshots — не вводим
|
||||||
|
|
||||||
|
`toHaveScreenshot` сравнивает страницу с PNG-baseline из прошлого коммита. При
|
||||||
|
активно меняющемся визуальном языке это превращает каждый дизайн-PR в
|
||||||
|
перегенерацию и ревью картинок, а baseline платформенные (снимок с Windows не
|
||||||
|
совпадёт на `ubuntu-latest`). Потребность закрывают e2e-поведение, `lint:tokens`
|
||||||
|
с token audit, i18n-гейт и ручной чеклист. Вернуться стоит, когда визуальный
|
||||||
|
язык стабилизируется; тогда первым идёт `/kit` в двух темах, а baseline
|
||||||
|
рождаются на Linux.
|
||||||
|
|
||||||
|
### Fail-режим для unused-token аудита — не вводим
|
||||||
|
|
||||||
|
Остаётся предупреждением: `verify` входит в deploy-workflow, поэтому стилевой
|
||||||
|
долг блокировал бы деплой сайта. Кроме того, аудит видит только ссылки
|
||||||
|
`var(--x)`, и чтение токена из JS дало бы ложное срабатывание. Правило «новый
|
||||||
|
токен — вместе с потребителем» живёт в `AGENTS.md`.
|
||||||
|
|
||||||
|
### Per-project `expect.timeout` — не задаём
|
||||||
|
|
||||||
|
Firefox и WebKit отдают результат так же быстро, как Chromium: расхождения
|
||||||
|
объясняются памятью, а не скоростью движка. Таймаут под конкретную машину —
|
||||||
|
магическое число, которое придётся подстраивать на каждом раннере, поэтому
|
||||||
|
`lint`/Playwright-конфиг остаётся с дефолтами, а проблема решается лимитом
|
||||||
|
воркеров.
|
||||||
|
|
||||||
|
### Coverage: браузерные модули вне scope
|
||||||
|
|
||||||
|
`src/lib/core/domText.ts` и `src/lib/core/io.ts` (canvas, `createImageBitmap`)
|
||||||
|
исключены из coverage-гейта: в node-Vitest они не исполнимы, и их включение
|
||||||
|
превращало бы метрику в шум. `registry/*` из scope НЕ исключены — низкое
|
||||||
|
покрытие там означает непокрытую логику, а не ограничение окружения. Список
|
||||||
|
исключений с причинами — в `docs/testing-strategy.md`.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.github/workflows/quality.yml`:
|
||||||
|
|
||||||
|
- `verify` блокирует PR при typecheck, unit, rule-test, lint, format, docs или
|
||||||
|
build ошибке;
|
||||||
|
- `e2e` запускается отдельным job после verify и пока не блокирует PR.
|
||||||
|
|
||||||
|
`.github/workflows/deploy.yml` перед production build запускает тот же
|
||||||
|
`pnpm verify`, поэтому deploy не должен проходить при ошибке быстрых проверок.
|
||||||
|
|
||||||
|
### Что и когда запускается
|
||||||
|
|
||||||
|
| Workflow | Триггер | Jobs |
|
||||||
|
| ------------------------ | --------------------------------------- | --------------------------- |
|
||||||
|
| `Deploy to GitHub Pages` | push в `main`, dispatch | `verify`, `build`, `deploy` |
|
||||||
|
| `Quality` | push в `main`, `pull_request`, dispatch | `verify`, `e2e` |
|
||||||
|
|
||||||
|
Пуш в `main` запускает оба workflow: deploy проверяет быстрый gate и собирает
|
||||||
|
сайт, `Quality` прогоняет тот же `verify` и browser matrix. Дублирование
|
||||||
|
`verify` (~1 мин) сознательное: matrix на каждый пуш стоит ~3 мин, и условные
|
||||||
|
job-и ради минуты не окупаются. Browser matrix проверяется на каждом пуше, а
|
||||||
|
ручной `gh workflow run quality.yml` нужен только для перепрогона старого
|
||||||
|
коммита.
|
||||||
|
|
||||||
|
### Проверка результатов CI после пуша
|
||||||
|
|
||||||
|
Первый реальный прогон browser matrix происходит на runner, а не на локальной
|
||||||
|
машине: локально Firefox и WebKit проходят на 1 воркере, но состав, скорость и
|
||||||
|
память Linux-раннера отличаются. Поэтому после пуша разработчика:
|
||||||
|
|
||||||
|
1. дождаться завершения workflow и посмотреть, какие job вообще запустились;
|
||||||
|
2. смотреть статусы **отдельных job**, а не общий conclusion прогона: у `e2e`
|
||||||
|
стоит `continue-on-error: true`, поэтому прогон с упавшим e2e целиком
|
||||||
|
помечается как `success` — проверено на run `36225579363` (`E2E: failure` при
|
||||||
|
`conclusion: success`);
|
||||||
|
3. если `e2e` запускался и зелёный — сообщить фактические числа passed, failed,
|
||||||
|
skipped;
|
||||||
|
4. если `e2e` не запускался, сказать это прямо: matrix на раннере не проверена,
|
||||||
|
и её можно прогнать через `gh workflow run quality.yml`;
|
||||||
|
5. красный `e2e` только из-за ресурсов раннера фиксируется как ограничение
|
||||||
|
окружения: сначала проверяется один проект и один spec, а не увеличиваются
|
||||||
|
таймауты под конкретную машину.
|
||||||
|
|
||||||
|
### Измеренная стоимость прогона
|
||||||
|
|
||||||
|
Замер на ручном запуске `quality.yml` (276 e2e-тестов, 4 проекта,
|
||||||
|
ubuntu-latest):
|
||||||
|
|
||||||
|
| Этап | Время |
|
||||||
|
| ----------------- | -------- |
|
||||||
|
| `verify` job | ~1 мин |
|
||||||
|
| `e2e` job целиком | ~3.1 мин |
|
||||||
|
| из них сами тесты | ~2.2 мин |
|
||||||
|
| весь run | ~4.2 мин |
|
||||||
|
|
||||||
|
Browser matrix стоит около трёх минут, поэтому её дешёво держать на каждом PR.
|
||||||
|
Полный прогон локально на 1 воркере — заметно дольше (~7 мин), и именно поэтому
|
||||||
|
локальные правила нагрузки описаны в `docs/agent-workflow.md`.
|
||||||
|
|
||||||
|
## Политика e2e в CI
|
||||||
|
|
||||||
|
Текущее состояние: `e2e` — non-blocking baseline, запускается на каждом пуше в
|
||||||
|
`main` и на каждом PR целиком (все четыре проекта matrix). Ночного разделения
|
||||||
|
нет: при стоимости job около трёх минут оно не окупается, поэтому новые
|
||||||
|
browser-critical спецификации просто добавляются в `browserContractTests`.
|
||||||
|
|
||||||
|
`e2e` станет blocking, когда выполнятся три условия:
|
||||||
|
|
||||||
|
1. Условия matrix пересмотрены: решено, какие спецификации обязаны идти в
|
||||||
|
`browserContractTests` и `mobileTests`, нужен ли `png-fixtures` вне Chromium
|
||||||
|
и что делать с engine-специфичными расхождениями декодеров.
|
||||||
|
2. Накопились стабильные прогоны на раннере без падений по таймингу.
|
||||||
|
3. Вместе с `continue-on-error` снимается и сам trick с общим conclusion: иначе
|
||||||
|
зелёный прогон при красном `e2e` останется возможен.
|
||||||
|
|
||||||
|
Пока условия не выполнены, `e2e` остаётся non-blocking, а его статус читается по
|
||||||
|
отдельному job.
|
||||||
|
|
||||||
|
## Локальная диагностика
|
||||||
|
|
||||||
|
При медленном полном прогоне сначала запускай минимальную команду из таблицы, а
|
||||||
|
перед ревью или merge — `pnpm verify`. После каждого шага плана результат команд
|
||||||
|
и текущий baseline фиксируются в отчёте агента.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# План разработки: easy-png-tools
|
||||||
|
|
||||||
|
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
||||||
|
> каталог переведён в типизированный `registry` (122 текущих инструмента), новый
|
||||||
|
> дизайн стал единственным production UI на корневых маршрутах; UI-v1 удалён в
|
||||||
|
> Q3. Tech debt не закрыт полностью; текущий baseline и состав quality-проверок
|
||||||
|
> — в `docs/README.md` и `docs/quality-gates.md`. Следующий этап: SEO S1-free
|
||||||
|
> (`plan-seo.md`) + баги и продуктовые задачи из `docs/backlog.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-фаз не требует, идёт после 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,179 @@
|
|||||||
|
# Стратегия e2e-тестирования
|
||||||
|
|
||||||
|
> Статус: **active**. Канонические правила для `web/e2e/`; продуктовые решения и
|
||||||
|
> известные баги остаются в `docs/backlog.md` и
|
||||||
|
> `docs/checklist-manual-testing.md`.
|
||||||
|
|
||||||
|
## 1. Что проверяем
|
||||||
|
|
||||||
|
E2E проверяет пользовательский контракт production UI: открытие маршрута,
|
||||||
|
доступность элемента, загрузку или ввод данных, появление результата, ошибку,
|
||||||
|
скачивание и переключение локали. Внутренняя реализация, порядок карточек,
|
||||||
|
CSS-классы и конкретный формат временного URL не являются контрактом.
|
||||||
|
|
||||||
|
Точные переводы проверяются в unit-тестах i18n. В e2e проверяется поведение и
|
||||||
|
отдельный RU/EN smoke, а не полный локализованный текст каждого verdict.
|
||||||
|
|
||||||
|
## 2. Приоритет locator-ов
|
||||||
|
|
||||||
|
1. `getByRole()` с доступным именем и `getByLabel()`.
|
||||||
|
2. Стабильные `href` или `id`, если семантического API нет.
|
||||||
|
3. Разрешённые `data-testid` только для визуальных границ, которые нельзя
|
||||||
|
надёжно назвать через accessibility API:
|
||||||
|
- `source-image`;
|
||||||
|
- `result-image`;
|
||||||
|
- `result-verdict`;
|
||||||
|
- `empty-state`.
|
||||||
|
4. `input[type="file"]` и download-button допускаются только внутри
|
||||||
|
`web/e2e/helpers/page.ts`.
|
||||||
|
|
||||||
|
Нельзя добавлять `data-testid` для счётчиков, групп, metadata, verdict текста,
|
||||||
|
кнопок темы или переключателя языка без отдельного обоснования.
|
||||||
|
|
||||||
|
## 3. Запрещённые зависимости
|
||||||
|
|
||||||
|
В e2e не фиксируются:
|
||||||
|
|
||||||
|
- абсолютные `TOTAL`, `GROUPS` и другие snapshot-числа каталога;
|
||||||
|
- точное число fuzzy-search результатов или порядок карточек;
|
||||||
|
- внутренние CSS-классы и HTML-структура, если есть role/label/href;
|
||||||
|
- `alt` как способ найти результат или источник;
|
||||||
|
- полные локализованные предложения и длинные тире в ожиданиях;
|
||||||
|
- локализованные имена контролов в общих сценариях без явной установки `en`.
|
||||||
|
|
||||||
|
Поведенческий тест может проверять, что известный инструмент видим или скрыт,
|
||||||
|
ошибка имеет текст и не является сырым i18n-ключом, результат доступен для
|
||||||
|
скачивания. Это не требует повторять словарь в e2e.
|
||||||
|
|
||||||
|
## 4. Локаль и fixtures
|
||||||
|
|
||||||
|
Обычные сценарии запускаются с `en` через `useEnglish()` или `openTool()`. RU/EN
|
||||||
|
проверяется отдельным `i18n.spec.ts`; после смены локали locator не должен
|
||||||
|
зависеть от старого accessible name.
|
||||||
|
|
||||||
|
Изображения создаются воспроизводимыми helpers в `web/e2e/helpers/fixtures.ts`.
|
||||||
|
Тесты не добавляют большие бинарные файлы и не зависят от локальных
|
||||||
|
пользовательских файлов. Runtime-fixtures для malformed/special PNG проверяют
|
||||||
|
browser-контракт: critical CRC и truncated отклоняются движком, ancillary CRC и
|
||||||
|
хвост после `IEND` допускаются, palette/16-bit декодируются. Отказ декодера —
|
||||||
|
свойство движка, поэтому ожидание задаётся per-engine в
|
||||||
|
`web/e2e/png-fixtures.spec.ts`, а не через skip. Общее требование для всех
|
||||||
|
движков: битый файл не роняет страницу, отказ показывается alert-ом, а успешный
|
||||||
|
декод даёт результат без ошибки. `toPass()` допускается только для ограниченного
|
||||||
|
ожидания hydration или асинхронного результата; он не должен превращать проверку
|
||||||
|
продукта в бесконечный retry.
|
||||||
|
|
||||||
|
Чистая логика схемы, layout и preview-модели (defaults, sanitization,
|
||||||
|
`resolveLayoutGroups`, `buildSchemaPreviewModel`) проверяется в
|
||||||
|
`src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в Playwright.
|
||||||
|
Component-тесты не дублируют e2e-контракт.
|
||||||
|
|
||||||
|
### Что попадает в coverage
|
||||||
|
|
||||||
|
Coverage-гейт считает только `src/lib` и только то, что node-Vitest способен
|
||||||
|
исполнить. Исключения в `web/vitest.config.ts` и их причины:
|
||||||
|
|
||||||
|
| Исключение | Причина |
|
||||||
|
| ------------------------------------------- | ------------------------------------------- |
|
||||||
|
| `**/*.test.ts`, `**/test-helpers.ts` | сами тесты |
|
||||||
|
| `**/*.svelte.ts` | runes, не работают в node |
|
||||||
|
| `executor/executor.worker.ts` | entry worker, исполняется только в браузере |
|
||||||
|
| `components/define.ts` | type-only, нет исполняемого кода |
|
||||||
|
| `core/domText.ts`, `core/io.ts` | canvas, `createImageBitmap`, нужен браузер |
|
||||||
|
| `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts` | данные, а не логика |
|
||||||
|
|
||||||
|
`registry/*` в scope остаётся: низкое покрытие там — это непокрытая логика, а не
|
||||||
|
следствие окружения. Исключение браузерных модулей означает, что переход
|
||||||
|
браузерного кода в scope — отдельное решение, связанное с DOM-тестами или visual
|
||||||
|
snapshots, а не молчаливое расширение списка исключений.
|
||||||
|
|
||||||
|
## 5. Helpers и `test.fixme`
|
||||||
|
|
||||||
|
`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`,
|
||||||
|
`renderText`, `resultImage`, `sourceImage`, `verdictStatus`, `textResult`,
|
||||||
|
`errorAlert`, `emptyState`, `rangeInput`, `downloadResult`, `downloadResultFile`
|
||||||
|
и `downloadResultBytes`. Специфика должна вызывать эти helpers, а не повторять
|
||||||
|
raw-селекторы. Harness выбирается по контракту: image — upload + `resultImage`,
|
||||||
|
text — `renderText` + результат, generator — auto-run без upload, verdict —
|
||||||
|
`verdictStatus`, files — отдельный ZIP-сценарий.
|
||||||
|
|
||||||
|
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
||||||
|
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
||||||
|
ссылка обновляются вместе; в текущем Q6e baseline активных fixme нет.
|
||||||
|
|
||||||
|
Если тест сравнивает два состояния одного элемента, он ждёт изменения значения
|
||||||
|
(`not.toHaveText`, `not.toHaveAttribute`), а не его видимости. Пересчёт идёт
|
||||||
|
через debounce, поэтому `toBeVisible()` после второго ввода проходит на старом
|
||||||
|
значении. Такой тест флакает по таймингу и обычно ловится на CI, а не локально.
|
||||||
|
|
||||||
|
## 6. Проверки
|
||||||
|
|
||||||
|
Для изменений e2e выполняются:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm --dir web check
|
||||||
|
pnpm --dir web test:rules
|
||||||
|
pnpm --dir web lint:all
|
||||||
|
pnpm --dir web exec prettier --check .
|
||||||
|
pnpm check:docs
|
||||||
|
pnpm --dir web test:e2e
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify` не включает Playwright: E2E остаётся отдельным тяжёлым gate. В CI
|
||||||
|
non-blocking E2E не считается заменой локальному smoke; перед ревью фиксируются
|
||||||
|
число passed, skipped и известные fixme.
|
||||||
|
|
||||||
|
## 7. Browser matrix
|
||||||
|
|
||||||
|
`web/playwright.config.ts` запускает четыре проекта:
|
||||||
|
|
||||||
|
| Проект | Движок | Спецификации |
|
||||||
|
| ----------------- | -------- | ---------------------------------------------------------------------- |
|
||||||
|
| `chromium` | Chromium | все e2e |
|
||||||
|
| `firefox` | Firefox | `pipeline`, `png-fixtures`, `text-and-verdicts` |
|
||||||
|
| `webkit` | WebKit | `pipeline`, `png-fixtures`, `text-and-verdicts` |
|
||||||
|
| `mobile-chromium` | Chromium | `navigation`, `catalog`, `generators`, `pipeline`, 390×844, `hasTouch` |
|
||||||
|
|
||||||
|
- Новые engine-специфичные фичи сначала добавляются в `chromium`, потом
|
||||||
|
решается, попадает ли spec в `browserContractTests`.
|
||||||
|
- Расхождения декодеров не ужесточаются и не скрываются skip-ом: они описаны в
|
||||||
|
тесте как ожидаемое поведение движка.
|
||||||
|
|
||||||
|
| Fixture | Отклоняют |
|
||||||
|
| ------------------------------------ | ------------------------- |
|
||||||
|
| `crc-bad-idat.png` | Chromium, Firefox |
|
||||||
|
| `truncated-header.png` | Chromium, Firefox, WebKit |
|
||||||
|
| `truncated-no-iend.png` | Chromium |
|
||||||
|
| `idat-cut.png` | Chromium |
|
||||||
|
| ancillary CRC, tail, palette, 16-bit | ни один движок |
|
||||||
|
|
||||||
|
- Firefox и WebKit работают с теми же таймаутами, что и Chromium: движок не
|
||||||
|
причина расхождений, поэтому per-project `expect.timeout` не задаётся.
|
||||||
|
- `workers` — 1 локально и 2 в CI. Playwright не умеет per-project лимит
|
||||||
|
воркеров, а browser matrix ограничена памятью, а не CPU: при высокой
|
||||||
|
параллельности браузеры на машине с 16 GB RAM не успевают поднять worker, и
|
||||||
|
тесты падают без ошибок. Локальный matrix поэтому медленный, но не нагружает
|
||||||
|
компьютер.
|
||||||
|
- Локальный baseline: 276 тестов, 276 passed, 0 skipped, 0 failed.
|
||||||
|
- Coverage ограничена `src/lib` и не включает браузерные модули — см. раздел 4.
|
||||||
|
|
||||||
|
## 8. Visual snapshots
|
||||||
|
|
||||||
|
Сознательно не используются. `toHaveScreenshot` сравнивает страницу с
|
||||||
|
PNG-baseline из прошлого коммита, а не с замыслом дизайна, поэтому:
|
||||||
|
|
||||||
|
- визуальный язык в активной работе означает перегенерацию и ревью картинок в
|
||||||
|
каждом дизайн-изменяющем PR;
|
||||||
|
- baseline привязан к платформе (ОС, шрифты, рендеринг): снимок с Windows не
|
||||||
|
совпадёт на `ubuntu-latest`, и baseline должны рождаться на Linux;
|
||||||
|
- четыре проекта matrix плюс тема и viewport дают нелинейный рост числа файлов.
|
||||||
|
|
||||||
|
Потребность закрывают e2e-поведение, `lint:tokens` с token audit, i18n-гейт и
|
||||||
|
ручной чеклист `docs/checklist-manual-testing.md`; «элемент визуально наехал»
|
||||||
|
ловится чеклистом и ревью.
|
||||||
|
|
||||||
|
Возвращаться к snapshots стоит, когда визуальный язык стабилизируется, изменения
|
||||||
|
станут редкими, а чеклист перестанет справляться. Тогда первым идёт `/kit` в
|
||||||
|
двух темах, а baseline генерируются на Linux (docker-образ Playwright или
|
||||||
|
отдельный CI-job).
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Аудит использования - 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,236 @@
|
|||||||
|
# Карта инструментов: реализовано · добавить · идеи
|
||||||
|
|
||||||
|
> Живой документ для сверки с каталогом. Формат записи:
|
||||||
|
> `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/height (размеры source по умолчанию; 0=авто для одной
|
||||||
|
стороны), keepAspect
|
||||||
|
- crop-png — x, y, width/height (полная область source по умолчанию)
|
||||||
|
- rotate-png — angle (90/180/270)
|
||||||
|
- flip-png — axis (h/v)
|
||||||
|
- skew-png — degX, degY
|
||||||
|
- rotate-free-png — angle
|
||||||
|
- zoom-png — scale
|
||||||
|
- shift-png — offsetX, offsetY, color фона
|
||||||
|
- add-padding-png — padding, transparent, color
|
||||||
|
- add-border-png — thickness, color
|
||||||
|
- fit-on-background-png — width, height, transparent, color
|
||||||
|
- tile-png — columns, rows
|
||||||
|
- split-into-parts-png — columns, rows (мультифайловый вывод, zip)
|
||||||
|
- trim-empty-space-png — threshold (альфа)
|
||||||
|
- change-canvas-size-png — width, height, anchor (3×3)
|
||||||
|
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad)
|
||||||
|
- swap-orientation-png — target (portrait/landscape)
|
||||||
|
- symmetric-copy-png — axis, keepSide
|
||||||
|
|
||||||
|
### Фильтры
|
||||||
|
|
||||||
|
- 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,30 @@
|
|||||||
|
{
|
||||||
|
"name": "easy-png-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "pnpm --dir web test",
|
||||||
|
"verify": "pnpm --dir web check && pnpm --dir web test:coverage && pnpm --dir web test:rules && pnpm --dir web lint:all && pnpm --dir web exec prettier --check . && pnpm check:docs && pnpm --dir web build",
|
||||||
|
"dev": "pnpm --dir web dev",
|
||||||
|
"build": "pnpm --dir web build",
|
||||||
|
"format": "pnpm format:docs && pnpm --dir web format",
|
||||||
|
"format:docs": "prettier --write 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,50 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoErrors,
|
||||||
|
toolLink,
|
||||||
|
trackErrors,
|
||||||
|
useEnglish,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useEnglish(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("catalog exposes a total and category headings", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await page.goto("/list-tools");
|
||||||
|
const total = page.getByRole("status", { name: /tools available/i });
|
||||||
|
await expect(total).toBeVisible();
|
||||||
|
await expect(total).toHaveAttribute("aria-label", /^\d+ /);
|
||||||
|
await expect(page.getByRole("heading", { level: 2 }).first()).toBeVisible();
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("catalog search narrows results", async ({ page }) => {
|
||||||
|
await page.goto("/list-tools");
|
||||||
|
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
||||||
|
await expect(toolLink(page, "resize-png")).toBeVisible();
|
||||||
|
await expect(toolLink(page, "flip-png")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("category filter narrows the catalog and resets", async ({ page }) => {
|
||||||
|
await page.goto("/list-tools");
|
||||||
|
const convert = page.getByRole("button", { name: /convert/i, exact: true });
|
||||||
|
const all = page.getByRole("button", { name: /^all$/i });
|
||||||
|
|
||||||
|
await convert.click();
|
||||||
|
await expect(convert).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(toolLink(page, "convert-png-to-jpg")).toBeVisible();
|
||||||
|
await expect(toolLink(page, "flip-png")).toBeHidden();
|
||||||
|
|
||||||
|
await all.click();
|
||||||
|
await expect(all).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(toolLink(page, "flip-png")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace search matches a tool by title", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
||||||
|
await expect(toolLink(page, "flip-png")).toBeVisible();
|
||||||
|
await expect(toolLink(page, "resize-png")).toBeHidden();
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoErrorAlert,
|
||||||
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
resultImage,
|
||||||
|
trackErrors,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
|
const GENERATOR_IDS = [
|
||||||
|
"create-empty-png",
|
||||||
|
"random-noise-png",
|
||||||
|
"linear-gradient-png",
|
||||||
|
"color-spectrum-png",
|
||||||
|
"random-colors-png",
|
||||||
|
"draw-grid-png",
|
||||||
|
"placeholder-png",
|
||||||
|
"blend-two-png",
|
||||||
|
"step-colors-png",
|
||||||
|
"emoji-to-png",
|
||||||
|
"color-wheel-png",
|
||||||
|
"complementary-png",
|
||||||
|
"triadic-png",
|
||||||
|
"tetradic-png",
|
||||||
|
"analogous-png",
|
||||||
|
"monochromatic-png",
|
||||||
|
"shades-png",
|
||||||
|
"mix-colors-png",
|
||||||
|
"sort-colors-png",
|
||||||
|
"text-to-png",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.describe("generators", () => {
|
||||||
|
test("single-color-png auto-runs and reacts to parameters", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "single-color-png");
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("spinbutton", { name: "Width" }).fill("32");
|
||||||
|
await page.getByRole("spinbutton", { name: "Height" }).fill("24");
|
||||||
|
await expect(resultImage(page)).toHaveJSProperty("naturalWidth", 32);
|
||||||
|
await expect(resultImage(page)).toHaveJSProperty("naturalHeight", 24);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of GENERATOR_IDS) {
|
||||||
|
test(`tool ${id} produces a result image`, async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, id);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { deflateSync } from "node:zlib";
|
||||||
|
|
||||||
|
export interface SourceFile {
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(data: Uint8Array): number {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < data.length; i++)
|
||||||
|
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
|
||||||
|
function makeIhdr(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
bitDepth: number,
|
||||||
|
colorType: number,
|
||||||
|
): Buffer {
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(width, 0);
|
||||||
|
ihdr.writeUInt32BE(height, 4);
|
||||||
|
ihdr[8] = bitDepth;
|
||||||
|
ihdr[9] = colorType;
|
||||||
|
return ihdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type: string, data: Buffer): Buffer {
|
||||||
|
const out = Buffer.alloc(8 + data.length + 4);
|
||||||
|
out.writeUInt32BE(data.length, 0);
|
||||||
|
out.write(type, 4, "ascii");
|
||||||
|
data.copy(out, 8);
|
||||||
|
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChunk(png: Buffer, type: string): number {
|
||||||
|
let offset = PNG_SIGNATURE.length;
|
||||||
|
while (offset + 12 <= png.length) {
|
||||||
|
const length = png.readUInt32BE(offset);
|
||||||
|
const chunkType = png.subarray(offset + 4, offset + 8).toString("ascii");
|
||||||
|
if (chunkType === type) return offset;
|
||||||
|
offset += 12 + length;
|
||||||
|
}
|
||||||
|
throw new Error(`PNG chunk ${type} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
|
||||||
|
export function makePng(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||||
|
): Buffer {
|
||||||
|
const stride = width * 4 + 1;
|
||||||
|
const raw = Buffer.alloc(height * stride);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const [r, g, b, a] = pixelAt(x, y);
|
||||||
|
const p = y * stride + 1 + x * 4;
|
||||||
|
raw[p] = r;
|
||||||
|
raw[p + 1] = g;
|
||||||
|
raw[p + 2] = b;
|
||||||
|
raw[p + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.concat([
|
||||||
|
PNG_SIGNATURE,
|
||||||
|
chunk("IHDR", makeIhdr(width, height, 8, 6)),
|
||||||
|
chunk("IDAT", deflateSync(raw)),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeIndexedPng(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
pixelAt: (x: number, y: number) => number,
|
||||||
|
): Buffer {
|
||||||
|
const plte = Buffer.alloc(palette.length * 3);
|
||||||
|
for (const [index, color] of palette.entries()) {
|
||||||
|
plte[index * 3] = color[0];
|
||||||
|
plte[index * 3 + 1] = color[1];
|
||||||
|
plte[index * 3 + 2] = color[2];
|
||||||
|
}
|
||||||
|
const stride = width + 1;
|
||||||
|
const raw = Buffer.alloc(height * stride);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
raw[y * stride + 1 + x] = pixelAt(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.concat([
|
||||||
|
PNG_SIGNATURE,
|
||||||
|
chunk("IHDR", makeIhdr(width, height, 8, 3)),
|
||||||
|
chunk("PLTE", plte),
|
||||||
|
chunk("IDAT", deflateSync(raw)),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function make16BitPng(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||||
|
): Buffer {
|
||||||
|
const stride = width * 8 + 1;
|
||||||
|
const raw = Buffer.alloc(height * stride);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const [r, g, b, a] = pixelAt(x, y);
|
||||||
|
const p = y * stride + 1 + x * 8;
|
||||||
|
raw.writeUInt16BE(r, p);
|
||||||
|
raw.writeUInt16BE(g, p + 2);
|
||||||
|
raw.writeUInt16BE(b, p + 4);
|
||||||
|
raw.writeUInt16BE(a, p + 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.concat([
|
||||||
|
PNG_SIGNATURE,
|
||||||
|
chunk("IHDR", makeIhdr(width, height, 16, 6)),
|
||||||
|
chunk("IDAT", deflateSync(raw)),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withPngChunk(png: Buffer, type: string, data: Buffer): Buffer {
|
||||||
|
const iendOffset = png.length - 12;
|
||||||
|
return Buffer.concat([
|
||||||
|
png.subarray(0, iendOffset),
|
||||||
|
chunk(type, data),
|
||||||
|
png.subarray(iendOffset),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withBadChunkCrc(png: Buffer, type: string): Buffer {
|
||||||
|
const copy = Buffer.from(png);
|
||||||
|
const offset = findChunk(copy, type);
|
||||||
|
const length = copy.readUInt32BE(offset);
|
||||||
|
copy[offset + 8 + length + 3] ^= 0xff;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncatePng(png: Buffer, length: number): Buffer {
|
||||||
|
return Buffer.from(png.subarray(0, length));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateChunkData(
|
||||||
|
png: Buffer,
|
||||||
|
type: string,
|
||||||
|
length: number,
|
||||||
|
): Buffer {
|
||||||
|
const offset = findChunk(png, type);
|
||||||
|
return truncatePng(png, offset + 8 + length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withTrailingBytes(png: Buffer, tail: Buffer): Buffer {
|
||||||
|
return Buffer.concat([png, Buffer.from(tail)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checker = (x: number, y: number): [number, number, number, number] =>
|
||||||
|
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
|
||||||
|
|
||||||
|
const alphaGrid = (x: number, y: number): [number, number, number, number] =>
|
||||||
|
(x + y) % 3 === 0 ? [0, 0, 0, 0] : [0, 128, 255, 255];
|
||||||
|
|
||||||
|
const solidRed = (): [number, number, number, number] => [255, 0, 0, 255];
|
||||||
|
|
||||||
|
export function asSourceFile(buffer: Buffer, name: string): SourceFile {
|
||||||
|
return { name, mimeType: "image/png", buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opaquePng: SourceFile = asSourceFile(
|
||||||
|
makePng(64, 48, checker),
|
||||||
|
"opaque.png",
|
||||||
|
);
|
||||||
|
export const transparentPng: SourceFile = asSourceFile(
|
||||||
|
makePng(64, 64, alphaGrid),
|
||||||
|
"transparent.png",
|
||||||
|
);
|
||||||
|
export const onePixelPng: SourceFile = asSourceFile(
|
||||||
|
makePng(1, 1, solidRed),
|
||||||
|
"1px.png",
|
||||||
|
);
|
||||||
|
export const landscapePng: SourceFile = asSourceFile(
|
||||||
|
makePng(64, 16, checker),
|
||||||
|
"landscape.png",
|
||||||
|
);
|
||||||
|
export const largePng: SourceFile = asSourceFile(
|
||||||
|
makePng(256, 256, checker),
|
||||||
|
"large.png",
|
||||||
|
);
|
||||||
|
export const corruptPng: SourceFile = {
|
||||||
|
name: "corrupt.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from("this is definitely not a png file", "utf8"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const specialPng = makePng(8, 8, checker);
|
||||||
|
const specialWithText = withPngChunk(
|
||||||
|
specialPng,
|
||||||
|
"tEXt",
|
||||||
|
Buffer.from("Comment\0q6d", "ascii"),
|
||||||
|
);
|
||||||
|
const palette4 = [
|
||||||
|
[255, 0, 0],
|
||||||
|
[0, 255, 0],
|
||||||
|
[0, 0, 255],
|
||||||
|
[255, 255, 255],
|
||||||
|
] as [number, number, number][];
|
||||||
|
const palette256 = Array.from({ length: 256 }, (_, index) => [
|
||||||
|
index,
|
||||||
|
(index * 3) % 256,
|
||||||
|
(index * 7) % 256,
|
||||||
|
]) as [number, number, number][];
|
||||||
|
|
||||||
|
export const crcBadIdatPng = asSourceFile(
|
||||||
|
withBadChunkCrc(specialPng, "IDAT"),
|
||||||
|
"crc-bad-idat.png",
|
||||||
|
);
|
||||||
|
export const crcBadAncillaryPng = asSourceFile(
|
||||||
|
withBadChunkCrc(specialWithText, "tEXt"),
|
||||||
|
"crc-bad-ancillary.png",
|
||||||
|
);
|
||||||
|
export const truncatedHeaderPng = asSourceFile(
|
||||||
|
truncatePng(specialPng, PNG_SIGNATURE.length + 12 + 13),
|
||||||
|
"truncated-header.png",
|
||||||
|
);
|
||||||
|
export const truncatedNoIendPng = asSourceFile(
|
||||||
|
truncatePng(specialPng, specialPng.length - 12),
|
||||||
|
"truncated-no-iend.png",
|
||||||
|
);
|
||||||
|
export const idatCutPng = asSourceFile(
|
||||||
|
truncateChunkData(specialPng, "IDAT", 4),
|
||||||
|
"idat-cut.png",
|
||||||
|
);
|
||||||
|
export const garbageTailPng = asSourceFile(
|
||||||
|
withTrailingBytes(specialPng, Buffer.from("trailing-garbage", "ascii")),
|
||||||
|
"garbage-tail.png",
|
||||||
|
);
|
||||||
|
export const palette4Png = asSourceFile(
|
||||||
|
makeIndexedPng(
|
||||||
|
8,
|
||||||
|
8,
|
||||||
|
palette4,
|
||||||
|
(x, y) => (Math.floor(x / 2) + Math.floor(y / 2)) % palette4.length,
|
||||||
|
),
|
||||||
|
"palette-4.png",
|
||||||
|
);
|
||||||
|
export const palette256Png = asSourceFile(
|
||||||
|
makeIndexedPng(16, 16, palette256, (x, y) => (x + y * 16) % 256),
|
||||||
|
"palette-256.png",
|
||||||
|
);
|
||||||
|
export const sixteenBitPng = asSourceFile(
|
||||||
|
make16BitPng(8, 8, (x, y) => [x * 8000, y * 8000, 32768, 65535]),
|
||||||
|
"16bit.png",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
|
||||||
|
|
||||||
|
export const svgMarkup =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6">' +
|
||||||
|
'<rect width="10" height="6" fill="#ff0000"/></svg>';
|
||||||
|
|
||||||
|
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
|
||||||
|
`${r} ${g} ${b} ${a}`;
|
||||||
|
|
||||||
|
/** Строка для bytes-to-png / rgb-values-to-png, ровно 32 пикселя в ширину по умолчанию. */
|
||||||
|
export function pixelRow(
|
||||||
|
count: number,
|
||||||
|
rgba: [number, number, number, number],
|
||||||
|
): string {
|
||||||
|
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import {
|
||||||
|
expect,
|
||||||
|
type Download,
|
||||||
|
type Locator,
|
||||||
|
type Page,
|
||||||
|
} from "playwright/test";
|
||||||
|
import type { SourceFile } from "./fixtures";
|
||||||
|
|
||||||
|
export const TEST_IDS = {
|
||||||
|
source: "source-image",
|
||||||
|
result: "result-image",
|
||||||
|
verdict: "result-verdict",
|
||||||
|
empty: "empty-state",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ErrorSink = { errors: string[] };
|
||||||
|
|
||||||
|
export function trackErrors(page: Page): ErrorSink {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
if (m.type() === "error") errors.push(`console: ${m.text()}`);
|
||||||
|
});
|
||||||
|
return { errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function useEnglish(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem("locale", "en");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openTool(page: Page, id: string): Promise<void> {
|
||||||
|
await useEnglish(page);
|
||||||
|
await page.goto(`/tools/${id}`);
|
||||||
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolLink(page: Page, id: string): Locator {
|
||||||
|
return page.locator(`a[href="/tools/${id}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resultImage(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceImage(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verdictStatus(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textResult(page: Page): Locator {
|
||||||
|
return page.getByLabel("Text result");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorAlert(page: Page): Locator {
|
||||||
|
return page.getByRole("alert");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyState(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeInput(page: Page, name: RegExp | string): Locator {
|
||||||
|
return page.getByRole("slider", { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderText(page: Page, value: string): Promise<void> {
|
||||||
|
const input = page.getByRole("textbox", { name: "Text data" });
|
||||||
|
await input.fill(value);
|
||||||
|
await page.getByRole("button", { name: "Render text" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
||||||
|
const input = page.locator('input[type="file"]');
|
||||||
|
await expect(input).toHaveCount(1);
|
||||||
|
await input.setInputFiles({
|
||||||
|
name: file.name,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
buffer: file.buffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadResultFile(page: Page): Promise<Download> {
|
||||||
|
const button = page.getByRole("button", { name: "Download result" });
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
button.click(),
|
||||||
|
]);
|
||||||
|
return download;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadResultBytes(
|
||||||
|
page: Page,
|
||||||
|
): Promise<{ name: string; bytes: Uint8Array }> {
|
||||||
|
const download = await downloadResultFile(page);
|
||||||
|
const path = await download.path();
|
||||||
|
if (!path) throw new Error("download path is unavailable");
|
||||||
|
return {
|
||||||
|
name: download.suggestedFilename(),
|
||||||
|
bytes: new Uint8Array(await readFile(path)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadResult(page: Page): Promise<string> {
|
||||||
|
return (await downloadResultFile(page)).suggestedFilename();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
||||||
|
await expect(errorAlert(page)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
||||||
|
expect(sink.errors, "нет console/page errors").toEqual([]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
import { pixelRow } from "./helpers/fixtures";
|
||||||
|
import { openTool, toolLink, useEnglish } from "./helpers/page";
|
||||||
|
|
||||||
|
test("поиск каталога находит по названию из другой локали", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await useEnglish(page);
|
||||||
|
await page.goto("/list-tools");
|
||||||
|
const input = page.getByRole("textbox");
|
||||||
|
const flipCard = toolLink(page, "flip-png");
|
||||||
|
await input.fill("отразить");
|
||||||
|
await expect(flipCard).toContainText("Flip PNG");
|
||||||
|
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||||
|
await expect(flipCard).toContainText("Отразить PNG");
|
||||||
|
await input.fill("flip");
|
||||||
|
await expect(flipCard).toContainText("Отразить PNG");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RU/EN: переключение переводит заголовок инструмента", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openTool(page, "resize-png");
|
||||||
|
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
|
||||||
|
const enBtn = page.getByRole("button", { name: "EN", exact: true });
|
||||||
|
const heading = page.getByRole("heading", { level: 1 });
|
||||||
|
await ruBtn.click();
|
||||||
|
await expect(heading).toHaveText("Изменить размер PNG");
|
||||||
|
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await enBtn.click();
|
||||||
|
await expect(heading).toHaveText("Resize PNG");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ошибка run'а локализуется и переводится при смене языка", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openTool(page, "bytes-to-png");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Text data" })
|
||||||
|
.fill(pixelRow(33, [255, 0, 0, 255]));
|
||||||
|
await page.getByRole("button", { name: "Render text" }).click();
|
||||||
|
const alert = page.getByRole("alert");
|
||||||
|
await expect(alert).toBeVisible();
|
||||||
|
await expect(alert).toHaveText(/\S/);
|
||||||
|
await expect(alert).not.toHaveText(/^errors\./);
|
||||||
|
const english = await alert.textContent();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||||
|
await expect(alert).not.toHaveText(english ?? "");
|
||||||
|
await expect(alert).not.toHaveText(/^errors\./);
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
import { expectNoErrors, trackErrors, useEnglish } from "./helpers/page";
|
||||||
|
|
||||||
|
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useEnglish(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const route of ROUTES) {
|
||||||
|
test(`page ${route} loads without errors`, async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
const resp = await page.goto(route);
|
||||||
|
expect(resp?.status()).toBe(200);
|
||||||
|
await expect(page.getByRole("main")).toBeVisible();
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("unknown tool route responds 404 on static build", async ({ page }) => {
|
||||||
|
const resp = await page.goto("/tools/definitely-not-a-tool");
|
||||||
|
expect(resp?.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("theme toggle persists the selected theme", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await page.goto("/");
|
||||||
|
const main = page.getByRole("main");
|
||||||
|
const before = await main.getAttribute("data-theme");
|
||||||
|
expect(["light", "dark"]).toContain(before);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Toggle theme" }).click();
|
||||||
|
await expect(main).not.toHaveAttribute("data-theme", before ?? "");
|
||||||
|
const after = await main.getAttribute("data-theme");
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem("theme"));
|
||||||
|
expect(["light", "dark"]).toContain(after);
|
||||||
|
expect(stored).toBe(after);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(main).toHaveAttribute("data-theme", after ?? "");
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("language toggle marks the active button", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const group = page.getByRole("group", { name: "Language" });
|
||||||
|
const en = group.getByRole("button", { name: "EN" });
|
||||||
|
await en.click();
|
||||||
|
await expect(en).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(group.getByRole("button", { name: "RU" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { unzipSync } from "fflate";
|
||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
import {
|
||||||
|
corruptPng,
|
||||||
|
landscapePng,
|
||||||
|
largePng,
|
||||||
|
onePixelPng,
|
||||||
|
opaquePng,
|
||||||
|
} from "./helpers/fixtures";
|
||||||
|
import {
|
||||||
|
downloadResultBytes,
|
||||||
|
errorAlert,
|
||||||
|
expectNoErrorAlert,
|
||||||
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
rangeInput,
|
||||||
|
resultImage,
|
||||||
|
sourceImage,
|
||||||
|
trackErrors,
|
||||||
|
uploadImage,
|
||||||
|
useEnglish,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
|
test("resize-png uses the source dimensions by default", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "resize-png");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||||
|
"64",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||||
|
"48",
|
||||||
|
);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("crop-png starts with the full source area", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "crop-png");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||||
|
"64",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||||
|
"48",
|
||||||
|
);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flip-png: full flow - upload, result, download", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, landscapePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
const { name, bytes } = await downloadResultBytes(page);
|
||||||
|
expect(name).toBe("flip-png.png");
|
||||||
|
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
||||||
|
137, 80, 78, 71, 13, 10, 26, 10,
|
||||||
|
]);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("convert-png-to-jpg: produces JPEG and quality affects size", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "convert-png-to-jpg");
|
||||||
|
await uploadImage(page, largePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
const quality = rangeInput(page, "Quality");
|
||||||
|
await quality.fill("10");
|
||||||
|
const low = await downloadResultBytes(page);
|
||||||
|
expect(low.name).toBe("convert-png-to-jpg.jpg");
|
||||||
|
expect(Array.from(low.bytes.subarray(0, 3))).toEqual([255, 216, 255]);
|
||||||
|
await quality.fill("90");
|
||||||
|
const high = await downloadResultBytes(page);
|
||||||
|
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("convert-png-to-webp: produces WebP and quality affects size", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "convert-png-to-webp");
|
||||||
|
await uploadImage(page, largePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
const quality = rangeInput(page, "Quality");
|
||||||
|
await quality.fill("10");
|
||||||
|
const low = await downloadResultBytes(page);
|
||||||
|
expect(low.name).toBe("convert-png-to-webp.webp");
|
||||||
|
expect(Array.from(low.bytes.subarray(0, 4))).toEqual([82, 73, 70, 70]);
|
||||||
|
expect(Array.from(low.bytes.subarray(8, 12))).toEqual([87, 69, 66, 80]);
|
||||||
|
await quality.fill("90");
|
||||||
|
const high = await downloadResultBytes(page);
|
||||||
|
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("png-to-bmp: produces a BMP download", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "png-to-bmp");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
const { name, bytes } = await downloadResultBytes(page);
|
||||||
|
expect(name).toBe("png-to-bmp.bmp");
|
||||||
|
expect(Array.from(bytes.subarray(0, 2))).toEqual([66, 77]);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reset restores defaults and re-runs with the source", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "blur-png");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
const radius = rangeInput(page, /radius/i);
|
||||||
|
await radius.fill("20");
|
||||||
|
await expect(radius).toHaveValue("20");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Reset" }).click();
|
||||||
|
await expect(radius).toHaveValue("4");
|
||||||
|
await expect(sourceImage(page)).toBeVisible();
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("split-into-parts-png downloads a zip with named PNG parts", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "split-into-parts-png");
|
||||||
|
await uploadImage(page, landscapePng);
|
||||||
|
await rangeInput(page, "Columns").fill("3");
|
||||||
|
await rangeInput(page, "Rows").fill("2");
|
||||||
|
await expect(page.getByText("part-1-3.png")).toBeVisible();
|
||||||
|
|
||||||
|
const { name, bytes } = await downloadResultBytes(page);
|
||||||
|
expect(name).toBe("split-into-parts-png.zip");
|
||||||
|
const entries = unzipSync(bytes);
|
||||||
|
|
||||||
|
expect(Object.keys(entries)).toEqual([
|
||||||
|
"part-1-1.png",
|
||||||
|
"part-1-2.png",
|
||||||
|
"part-1-3.png",
|
||||||
|
"part-2-1.png",
|
||||||
|
"part-2-2.png",
|
||||||
|
"part-2-3.png",
|
||||||
|
]);
|
||||||
|
for (const bytes of Object.values(entries)) {
|
||||||
|
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
||||||
|
137, 80, 78, 71, 13, 10, 26, 10,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blur: changing slider updates result image", async ({ page }) => {
|
||||||
|
await openTool(page, "blur-png");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
const firstSource = await resultImage(page).getAttribute("src");
|
||||||
|
await rangeInput(page, /radius/i).fill("20");
|
||||||
|
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
try {
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(window, "Worker", {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await useEnglish(page);
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, landscapePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, corruptPng);
|
||||||
|
await expect(errorAlert(page)).toBeVisible();
|
||||||
|
await expect(resultImage(page)).toHaveCount(0);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const fixture of [onePixelPng, largePng]) {
|
||||||
|
test(`handles ${fixture.name} correctly`, async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, fixture);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { expect, test, type Page } from "playwright/test";
|
||||||
|
import {
|
||||||
|
crcBadAncillaryPng,
|
||||||
|
crcBadIdatPng,
|
||||||
|
garbageTailPng,
|
||||||
|
idatCutPng,
|
||||||
|
palette4Png,
|
||||||
|
palette256Png,
|
||||||
|
sixteenBitPng,
|
||||||
|
truncatedHeaderPng,
|
||||||
|
truncatedNoIendPng,
|
||||||
|
type SourceFile,
|
||||||
|
} from "./helpers/fixtures";
|
||||||
|
import {
|
||||||
|
errorAlert,
|
||||||
|
expectNoErrorAlert,
|
||||||
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
resultImage,
|
||||||
|
trackErrors,
|
||||||
|
uploadImage,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
|
async function expectRejected(page: Page, file: SourceFile): Promise<void> {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, file);
|
||||||
|
await expect(errorAlert(page)).toBeVisible();
|
||||||
|
await expect(resultImage(page)).toHaveCount(0);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAccepted(page: Page, file: SourceFile): Promise<void> {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, file);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecodeContract = {
|
||||||
|
file: SourceFile;
|
||||||
|
rejectedIn: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeContracts: readonly DecodeContract[] = [
|
||||||
|
{ file: crcBadIdatPng, rejectedIn: ["chromium", "firefox"] },
|
||||||
|
{ file: truncatedHeaderPng, rejectedIn: ["chromium", "firefox", "webkit"] },
|
||||||
|
{ file: truncatedNoIendPng, rejectedIn: ["chromium"] },
|
||||||
|
{ file: idatCutPng, rejectedIn: ["chromium"] },
|
||||||
|
{ file: crcBadAncillaryPng, rejectedIn: [] },
|
||||||
|
{ file: garbageTailPng, rejectedIn: [] },
|
||||||
|
{ file: palette4Png, rejectedIn: [] },
|
||||||
|
{ file: palette256Png, rejectedIn: [] },
|
||||||
|
{ file: sixteenBitPng, rejectedIn: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe("PNG special fixtures", () => {
|
||||||
|
for (const { file, rejectedIn } of decodeContracts) {
|
||||||
|
test(`${file.name}: matches the engine decode contract`, async ({
|
||||||
|
browserName,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
if (rejectedIn.includes(browserName)) {
|
||||||
|
await expectRejected(page, file);
|
||||||
|
} else {
|
||||||
|
await expectAccepted(page, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
import {
|
||||||
|
opaquePng,
|
||||||
|
pixelRow,
|
||||||
|
svgMarkup,
|
||||||
|
tinyBase64,
|
||||||
|
transparentPng,
|
||||||
|
} from "./helpers/fixtures";
|
||||||
|
import {
|
||||||
|
expectNoErrorAlert,
|
||||||
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
renderText,
|
||||||
|
resultImage,
|
||||||
|
trackErrors,
|
||||||
|
uploadImage,
|
||||||
|
verdictStatus,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
|
async function expectVerdict(page: Parameters<typeof verdictStatus>[0]) {
|
||||||
|
const verdict = verdictStatus(page);
|
||||||
|
await expect(verdict).toBeVisible();
|
||||||
|
await expect(verdict).toHaveText(/\S/);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, input] of [
|
||||||
|
["base64-to-png", tinyBase64],
|
||||||
|
["hex-to-png", "ff0000ff"],
|
||||||
|
["bytes-to-png", pixelRow(32, [255, 0, 0, 255])],
|
||||||
|
[
|
||||||
|
"rgb-values-to-png",
|
||||||
|
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
|
||||||
|
],
|
||||||
|
["data-uri-to-png", `data:image/png;base64,${tinyBase64}`],
|
||||||
|
["svg-to-png", svgMarkup],
|
||||||
|
] as const) {
|
||||||
|
test(`text input → ${id} produces result image`, async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, id);
|
||||||
|
await expect(async () => {
|
||||||
|
await renderText(page, input);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
}).toPass({ timeout: 25_000 });
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("png-to-base64 shows decoded text result", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "png-to-base64");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
const output = page.getByLabel("Text result");
|
||||||
|
await expect(output).toBeVisible();
|
||||||
|
await expect(output).toHaveText(/\S/);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verify-is-png renders different verdicts for valid and invalid input", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openTool(page, "verify-is-png");
|
||||||
|
const verdict = verdictStatus(page);
|
||||||
|
|
||||||
|
await renderText(page, tinyBase64);
|
||||||
|
await expect(verdict).toBeVisible();
|
||||||
|
const validText = await verdict.textContent();
|
||||||
|
|
||||||
|
await renderText(page, "aGVsbG8=");
|
||||||
|
await expect(verdict).not.toHaveText(validText ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("png-is-transparent: opaque image renders a verdict", async ({ page }) => {
|
||||||
|
await openTool(page, "png-is-transparent");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expectVerdict(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("png-is-transparent: transparent image renders a verdict", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openTool(page, "png-is-transparent");
|
||||||
|
await uploadImage(page, transparentPng);
|
||||||
|
await expectVerdict(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("png-is-grayscale: colored image renders a verdict", async ({ page }) => {
|
||||||
|
await openTool(page, "png-is-grayscale");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expectVerdict(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("png-orientation: landscape image renders a verdict", async ({ page }) => {
|
||||||
|
await openTool(page, "png-orientation");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expectVerdict(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("png-file-size: renders a verdict", async ({ page }) => {
|
||||||
|
await openTool(page, "png-file-size");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expectVerdict(page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { expect, test } from "playwright/test";
|
||||||
|
import type { SourceFile } from "./helpers/fixtures";
|
||||||
|
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
||||||
|
import {
|
||||||
|
expectNoErrorAlert,
|
||||||
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
resultImage,
|
||||||
|
trackErrors,
|
||||||
|
textResult,
|
||||||
|
uploadImage,
|
||||||
|
verdictStatus,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
|
type Kind = "image" | "text-out" | "verdict";
|
||||||
|
|
||||||
|
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
|
||||||
|
// ── convert ────────────────────────────────────────────────
|
||||||
|
{ id: "convert-png-to-jpg", kind: "image" },
|
||||||
|
{ id: "convert-png-to-webp", kind: "image" },
|
||||||
|
{ id: "png-to-bmp", kind: "image" },
|
||||||
|
{ id: "png-to-base64", kind: "text-out" },
|
||||||
|
{ id: "png-to-data-uri", kind: "text-out" },
|
||||||
|
{ id: "png-to-hex", kind: "text-out" },
|
||||||
|
{ id: "png-to-bytes", kind: "text-out" },
|
||||||
|
{ id: "png-to-rgb-values", kind: "text-out" },
|
||||||
|
// ── alpha ──────────────────────────────────────────────────
|
||||||
|
{ id: "remove-background-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "remove-color-from-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "round-corners-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "add-stroke-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "circle-mask-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "wavy-mask-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "find-contour-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "feather-edges-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "clean-edges-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "make-thicker-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "make-thinner-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "despeckle-alpha-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "close-holes-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "harden-alpha-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "invert-alpha-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "set-alpha-channel-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "extract-alpha-mask-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "remove-alpha-channel-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "square-mask-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "star-mask-png", kind: "image", file: transparentPng },
|
||||||
|
// ── color ──────────────────────────────────────────────────
|
||||||
|
{ id: "grayscale-png", kind: "image" },
|
||||||
|
{ id: "invert-colors-png", kind: "image" },
|
||||||
|
{ id: "sepia-png", kind: "image" },
|
||||||
|
{ id: "quantize-png", kind: "image" },
|
||||||
|
{ id: "dithering-png", kind: "image" },
|
||||||
|
{ id: "decrease-color-count-png", kind: "image" },
|
||||||
|
{ id: "two-colors-png", kind: "image" },
|
||||||
|
{ id: "tint-png", kind: "image" },
|
||||||
|
{ id: "auto-contrast-png", kind: "image" },
|
||||||
|
{ id: "png-to-hsl", kind: "image" },
|
||||||
|
{ id: "png-to-cmyk", kind: "image" },
|
||||||
|
{ id: "gamma-png", kind: "image" },
|
||||||
|
{ id: "temperature-png", kind: "image" },
|
||||||
|
{ id: "custom-palette-png", kind: "image" },
|
||||||
|
{ id: "adjust-brightness-contrast-png", kind: "image" },
|
||||||
|
{ id: "change-png-opacity", kind: "image" },
|
||||||
|
{ id: "change-png-hue", kind: "image" },
|
||||||
|
{ id: "swap-channels-png", kind: "image" },
|
||||||
|
{ id: "black-and-white-png", kind: "image" },
|
||||||
|
{ id: "posterize-png", kind: "image" },
|
||||||
|
{ id: "png-to-hsv", kind: "image" },
|
||||||
|
{ id: "png-to-hsi", kind: "image" },
|
||||||
|
{ id: "png-to-ycbcr", kind: "image" },
|
||||||
|
{ id: "png-to-lab", kind: "image" },
|
||||||
|
// ── geometry ───────────────────────────────────────────────
|
||||||
|
{ id: "flip-png", kind: "image" },
|
||||||
|
{ id: "rotate-png", kind: "image" },
|
||||||
|
{ id: "rotate-free-png", kind: "image" },
|
||||||
|
{ id: "fit-on-background-png", kind: "image" },
|
||||||
|
{ id: "add-padding-png", kind: "image" },
|
||||||
|
{ id: "add-border-png", kind: "image" },
|
||||||
|
{ id: "tile-png", kind: "image" },
|
||||||
|
{ id: "change-canvas-size-png", kind: "image" },
|
||||||
|
{ id: "trim-empty-space-png", kind: "image" },
|
||||||
|
{ id: "swap-orientation-png", kind: "image" },
|
||||||
|
{ id: "skew-png", kind: "image" },
|
||||||
|
{ id: "zoom-png", kind: "image" },
|
||||||
|
{ id: "center-by-alpha-png", kind: "image" },
|
||||||
|
{ id: "change-aspect-ratio-png", kind: "image" },
|
||||||
|
{ id: "symmetric-copy-png", kind: "image" },
|
||||||
|
{ id: "shift-png", kind: "image" },
|
||||||
|
// ── filters ────────────────────────────────────────────────
|
||||||
|
{ id: "blur-png", kind: "image" },
|
||||||
|
{ id: "sharpen-png", kind: "image" },
|
||||||
|
{ id: "pixelate-png", kind: "image" },
|
||||||
|
{ id: "add-noise-png", kind: "image" },
|
||||||
|
{ id: "vignette-png", kind: "image" },
|
||||||
|
{ id: "silhouette-png", kind: "image" },
|
||||||
|
{ id: "randomize-pixels-png", kind: "image" },
|
||||||
|
{ id: "jpeg-artifacts-png", kind: "image" },
|
||||||
|
// ── text ───────────────────────────────────────────────────
|
||||||
|
{ id: "add-text-png", kind: "image" },
|
||||||
|
{ id: "date-stamp-png", kind: "image" },
|
||||||
|
{ id: "watermark-tile-png", kind: "image" },
|
||||||
|
// ── analyze (image output) ─────────────────────────────────
|
||||||
|
{ id: "extract-color-from-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "show-transparent-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "light-pixel-mask-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "extract-channel-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "unique-color-mask-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "show-grayscale-pixels-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "show-color-pixels-png", kind: "image", file: transparentPng },
|
||||||
|
{ id: "dark-pixel-mask-png", kind: "image", file: transparentPng },
|
||||||
|
// ── analyze (verdict output) ───────────────────────────────
|
||||||
|
{ id: "png-is-transparent", kind: "verdict" },
|
||||||
|
{ id: "png-is-grayscale", kind: "verdict" },
|
||||||
|
{ id: "png-orientation", kind: "verdict" },
|
||||||
|
{ id: "png-file-size", kind: "verdict" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe("smoke: tools produce output without errors", () => {
|
||||||
|
for (const { id, kind, file: fixture } of CASES) {
|
||||||
|
test(`tool ${id} (${kind})`, async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
|
||||||
|
const outputLocator = () => {
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
return resultImage(page);
|
||||||
|
case "text-out":
|
||||||
|
return textResult(page);
|
||||||
|
case "verdict":
|
||||||
|
return verdictStatus(page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// toPass: устойчивость к hydration-рейсу (ввод до гидрации SvelteKit
|
||||||
|
// может не обработаться с первого раза).
|
||||||
|
await expect(async () => {
|
||||||
|
await openTool(page, id);
|
||||||
|
await uploadImage(page, fixture ?? opaquePng);
|
||||||
|
await expect(outputLocator()).toBeVisible({ timeout: 8_000 });
|
||||||
|
}).toPass({ timeout: 25_000 });
|
||||||
|
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# ESLint-плагины и дизайн-токены: детали
|
||||||
|
|
||||||
|
Эта часть репозитория подробно описана здесь, а не в корневом `AGENTS.md`:
|
||||||
|
рутина-разработка её не требует, детали нужны только при правке самих
|
||||||
|
линт-правил или конфигов.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
||||||
|
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
||||||
|
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
||||||
|
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
||||||
|
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
||||||
|
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
||||||
|
токенов) и мини-словари `lib/i18n/`.
|
||||||
|
- `web/scripts/`:
|
||||||
|
- `lint-all.mjs` — оркестратор `lint:all`.
|
||||||
|
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
||||||
|
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
|
||||||
|
- `__tests__/` — тесты аудита и HCT-плагина; подхватываются обычным
|
||||||
|
`vitest run` через `scripts/**/*.test.mjs`, поэтому входят в `verify`.
|
||||||
|
- `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css.
|
||||||
|
|
||||||
|
## Конфигурация ESLint (`web/eslint.config.js`)
|
||||||
|
|
||||||
|
Устроена инкрементально:
|
||||||
|
|
||||||
|
- На **весь код** — парсинг TS/Svelte + правило
|
||||||
|
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
|
||||||
|
`prefer: 'type-imports'`).
|
||||||
|
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
|
||||||
|
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
|
||||||
|
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
|
||||||
|
устаревшие пути в `eslint.config.js`.
|
||||||
|
- Базовый `js.configs.recommended` + `globals.node` навешаны на инфраструктуру
|
||||||
|
линтинга: `eslint-plugins/**/*.js` и `scripts/**/*.mjs` (Q7c).
|
||||||
|
TS/svelte-рекомендации туда не подключаются.
|
||||||
|
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
||||||
|
`let`).
|
||||||
|
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
||||||
|
там запрещены tseslint) — при добавлении файлов в `__tests__/` /
|
||||||
|
`__fixtures__/` дописать их туда же.
|
||||||
|
- design-tokens правила применяются к тем же scoped-путям
|
||||||
|
(`src/lib/components/**`, `src/routes/**`).
|
||||||
|
- `i18n/no-hardcoded-user-text` применяется к `src/lib/components/**` (`/kit`
|
||||||
|
исключён как витрина с намеренно hardcoded-подписями) и настраивается опцией
|
||||||
|
`allowWords` —brand не хардкодится в правиле.
|
||||||
|
|
||||||
|
## Правила плагина design-tokens
|
||||||
|
|
||||||
|
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
|
||||||
|
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
|
||||||
|
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
|
||||||
|
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
|
||||||
|
color-токен (`--color-*`/`--brand-*`) и наоборот.
|
||||||
|
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
|
||||||
|
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
|
||||||
|
производные от токенов (`var()`, `calc()`, unitless-числа).
|
||||||
|
- `design-tokens/no-undefined-in-svelte` — `var(--x)` в `<style>` должен быть
|
||||||
|
определён в `src/app.css` (словарь токенов) или локально в компоненте. Файл
|
||||||
|
читается один раз и кэшируется (не `glob`-зависим).
|
||||||
|
|
||||||
|
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
||||||
|
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
||||||
|
|
||||||
|
## Плагин i18n (`i18n/`)
|
||||||
|
|
||||||
|
### `i18n/dict-consistency`
|
||||||
|
|
||||||
|
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
||||||
|
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
||||||
|
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
|
||||||
|
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
|
||||||
|
правила.
|
||||||
|
|
||||||
|
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
||||||
|
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
||||||
|
расхождения:
|
||||||
|
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
|
||||||
|
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
|
||||||
|
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
|
||||||
|
пропавшая целая секция репортится один раз (не по каждому потомку);
|
||||||
|
- **пустые значения**: `""` и строка из одних пробелов;
|
||||||
|
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
||||||
|
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
|
||||||
|
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
|
||||||
|
когда отклоняется одна-единственная локаль.
|
||||||
|
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
||||||
|
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
||||||
|
(синтакс, без типов).
|
||||||
|
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
||||||
|
(осознанные отклонения до достижения zero-warn).
|
||||||
|
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
|
||||||
|
отсутствовать только в базовой локали; в текущей конфигурации это registry
|
||||||
|
metadata `tools.*.title` и `tools.*.description`. Пустые значения и
|
||||||
|
placeholders для присутствующих ключей всё равно проверяются.
|
||||||
|
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
|
||||||
|
проверяются на missing; в текущей конфигурации это неиспользуемые
|
||||||
|
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
|
||||||
|
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||||
|
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||||
|
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||||
|
диске.
|
||||||
|
|
||||||
|
### `i18n/no-hardcoded-user-text`
|
||||||
|
|
||||||
|
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
|
||||||
|
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
|
||||||
|
report-only (`warn`), baseline — 0 срабатываний.
|
||||||
|
|
||||||
|
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
|
||||||
|
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
|
||||||
|
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
|
||||||
|
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
|
||||||
|
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
|
||||||
|
скобках (`{expr}`) тоже — они считаются в другом месте.
|
||||||
|
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
|
||||||
|
имена файлов и версии (`result.png`, `v0.1.0`), размеры (`512×512`),
|
||||||
|
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
|
||||||
|
`/kit` — витрина компонентов с намеренно латинскими подписями.
|
||||||
|
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
|
||||||
|
продукта передаётся конфигом, а не зашито в правило.
|
||||||
|
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
|
||||||
|
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
|
||||||
|
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
|
||||||
|
|
||||||
|
## Токены-префиксы (целевой словарь дизайна)
|
||||||
|
|
||||||
|
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
||||||
|
переменные, которым разрешено быть hex/rgb, остальные цвета — только `hct()` и
|
||||||
|
только в app.css.
|
||||||
|
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
||||||
|
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
|
||||||
|
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в app.css,
|
||||||
|
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
|
||||||
|
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
|
||||||
|
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
|
||||||
|
подтягиваются через `@csstools/postcss-global-data`).
|
||||||
|
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
|
||||||
|
|
||||||
|
Нюансы stylelint:
|
||||||
|
|
||||||
|
- Правило `custom-property-pattern` тестирует паттерн **без** `--` (`--x` →
|
||||||
|
`x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
|
||||||
|
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
|
||||||
|
`ignore`, не в `except`): пустые строки между подряд идущими токенами
|
||||||
|
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
|
||||||
|
токены в app.css отдельными блоками.
|
||||||
|
|
||||||
|
## Токен-аудит (`check-tokens.mjs`)
|
||||||
|
|
||||||
|
Лёгкий оркестратор поверх `web/scripts/token-audit/*`. Проверяет:
|
||||||
|
|
||||||
|
- **Parity**: каждый цветовой токен из `:root` обязан иметь пару в
|
||||||
|
`[data-theme="dark"]` и наоборот. Производные токены (значение содержит
|
||||||
|
`var()`, напр. `hct(from var(--...))`) из пары исключены — они наследуют тему
|
||||||
|
автоматически.
|
||||||
|
- **hct-only для цветов**: ЛЮБОЕ цветовое значение в app.css обязано быть
|
||||||
|
`hct(...)` — и литерал, и производное `hct(from var(...) h c t)`. Исключения:
|
||||||
|
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
|
||||||
|
`color-mix(...)` (единственный легальный способ смешать два токена).
|
||||||
|
`oklch()/rgb()/#hex` в `--color-*` запрещены.
|
||||||
|
|
||||||
|
- **Резолв `var()`**: каждая ссылка `var(--x)` в app.css должна указывать на
|
||||||
|
токен, определённый в этом же файле. `var(--x, fallback)` не считается ошибкой
|
||||||
|
— fallback легален без определения. Провал (exit 1), потому что несуществующий
|
||||||
|
токен молча роняет объявление.
|
||||||
|
- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не
|
||||||
|
используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`,
|
||||||
|
поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение
|
||||||
|
оставить проверку неблокирующей зафиксировано в `docs/quality-gates.md`.
|
||||||
|
Провалами (exit 1) считаются parity, hct-авторство и резолв `var()`.
|
||||||
|
|
||||||
|
## Тесты кастомных линт-правил
|
||||||
|
|
||||||
|
Линт-правила покрыты юнит-тестами (Vitest, `test:rules`):
|
||||||
|
|
||||||
|
- `__tests__/design-tokens.test.ts` — три чистых правила
|
||||||
|
(`no-hardcoded-in-svelte`, `no-category-mismatch`,
|
||||||
|
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
|
||||||
|
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
|
||||||
|
`__fixtures__/src/app.css` (не из реального `src/app.css`).
|
||||||
|
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
|
||||||
|
`process.cwd()` не трогается, реальные `src/` не читаются.
|
||||||
|
|
||||||
|
Запуск: `pnpm --dir web test:rules` (`vitest run eslint-plugins/__tests__`).
|
||||||
|
Полный `pnpm --dir web test` тоже их гоняет.
|
||||||
|
|
||||||
|
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
|
||||||
|
добавление/правка стабов — тоже работа с валидным кодом.
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||