Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb94d06c2 | ||
|
|
c22c6e5574 | ||
|
|
a51e224d02 | ||
|
|
4de8ec8190 | ||
|
|
8dcc263461 | ||
|
|
7361a82c18 | ||
|
|
0e8c2478a1 | ||
|
|
76039f7bf3 | ||
|
|
fce1b20e85 | ||
|
|
31ace359d7 | ||
|
|
fbe6139585 | ||
|
|
2f11cb86ad | ||
|
|
676be383d9 | ||
|
|
32cb369ca9 | ||
|
|
557c74b26e | ||
|
|
b7be973b54 | ||
|
|
47f42f5de3 | ||
|
|
1ba03b731d | ||
|
|
5c58451f2f | ||
|
|
e7d3925eb2 | ||
|
|
83e75b5589 | ||
|
|
40338544de | ||
|
|
7f26498317 | ||
|
|
92cc590959 | ||
|
|
ba533e67fd | ||
|
|
a0bc72ef5d | ||
|
|
0753c95583 | ||
|
|
684423de67 | ||
|
|
7763fb7faa | ||
|
|
f00dae8b01 | ||
|
|
2a282b48f8 | ||
|
|
a9327eaa27 | ||
|
|
225a496ac7 | ||
|
|
6b946bfb08 | ||
|
|
e87bc1d715 | ||
|
|
864f9cdc9f | ||
|
|
91150a14bf | ||
|
|
4d9cd2cfcb | ||
|
|
e73986fefd | ||
|
|
5743b9fb3f | ||
|
|
08608b878a | ||
|
|
409c98ca29 | ||
|
|
62bdd5a7c5 | ||
|
|
f40a7c89ae | ||
|
|
260a738ce4 | ||
|
|
e1fc11cb54 | ||
|
|
1db83d12ff | ||
|
|
ddab996b50 | ||
|
|
5990310e99 | ||
|
|
c4eab38866 | ||
|
|
f89811c116 | ||
|
|
1d337f065b | ||
|
|
8cb3f1a8f0 | ||
|
|
7b7e12fafc | ||
|
|
d09c954694 | ||
|
|
9776e1d325 | ||
|
|
7a3498c173 | ||
|
|
931ee27b05 | ||
|
|
2fc7801162 | ||
|
|
e703451474 | ||
|
|
82207c82f2 | ||
|
|
9c6f223b13 | ||
|
|
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,23 @@
|
||||
name: "Setup Cocogitto"
|
||||
description: "Installs a specific version of Cocogitto and cleans up the workspace"
|
||||
inputs:
|
||||
version:
|
||||
description: "Cocogitto version to install"
|
||||
required: true
|
||||
default: "7.0.0"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Cocogitto
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
URL="https://github.com/cocogitto/cocogitto/releases/download/${VERSION}/cocogitto-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
|
||||
curl -LOf "$URL"
|
||||
tar -xzf "cocogitto-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
|
||||
sudo mv $(find . -name cog -type f -executable) /usr/local/bin/
|
||||
git clean -fd
|
||||
cog --version
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Bump version
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: bump
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
uses: ./.github/workflows/verify.yml
|
||||
|
||||
bump:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
bumped: ${{ steps.check-bump.outputs.bumped }}
|
||||
new_version: ${{ steps.get-tag.outputs.version }}
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ vars.XBOCT_BOT_ID }}
|
||||
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Cocogitto
|
||||
uses: ./.github/actions/setup-cocogitto
|
||||
|
||||
- name: Check if bump is needed
|
||||
id: check-bump
|
||||
run: |
|
||||
BUMP_OUTPUT=$(cog bump --auto --dry-run 2>&1)
|
||||
|
||||
if [[ "$BUMP_OUTPUT" == *"No conventional commits"* ]]; then
|
||||
echo "No bump needed."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Changes detected, proceeding..."
|
||||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Bump and push
|
||||
if: steps.check-bump.outputs.bumped == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
cog bump --auto
|
||||
git push origin main --tags
|
||||
|
||||
- name: Get new version
|
||||
id: get-tag
|
||||
if: steps.check-bump.outputs.bumped == 'true'
|
||||
run: |
|
||||
VERSION=$(git describe --tags --abbrev=0)
|
||||
echo "Detected version: $VERSION"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release:
|
||||
needs: bump
|
||||
if: needs.bump.outputs.bumped == 'true'
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
version: ${{ needs.bump.outputs.new_version }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
uses: ./.github/workflows/verify.yml
|
||||
|
||||
build:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ^11.20.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
run: pnpm build
|
||||
env:
|
||||
BASE_PATH: /easy-png-tools
|
||||
PUBLIC_GA_ENABLED: "true"
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: web/build
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: quality-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
uses: ./.github/workflows/verify.yml
|
||||
|
||||
e2e:
|
||||
name: E2E (non-blocking baseline)
|
||||
needs: verify
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ^11.20.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Install web dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: web
|
||||
run: pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
|
||||
- name: Run E2E
|
||||
working-directory: web
|
||||
run: pnpm test:e2e
|
||||
@@ -0,0 +1,58 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Версия для релиза (например, v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.XBOCT_BOT_ID }}
|
||||
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Cocogitto
|
||||
uses: ./.github/actions/setup-cocogitto
|
||||
|
||||
- name: Sync and reset
|
||||
run: |
|
||||
git fetch origin main --tags --force
|
||||
git reset --hard origin/main
|
||||
|
||||
- name: Get changelog
|
||||
run: |
|
||||
cog changelog "${{ inputs.version }}" > GITHUB_CHANGELOG.md
|
||||
if [ ! -s GITHUB_CHANGELOG.md ]; then
|
||||
echo "Release ${{ inputs.version }}" > GITHUB_CHANGELOG.md
|
||||
echo "::warning title=Changelog::Changelog was empty, used default text."
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ inputs.version }}
|
||||
name: Release ${{ inputs.version }}
|
||||
body_path: GITHUB_CHANGELOG.md
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Fast quality gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ^11.20.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
web/pnpm-lock.yaml
|
||||
|
||||
- name: Install root dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install web dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run quality gate
|
||||
run: pnpm verify
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
|
||||
# Отчёты разовых аудитов и артефакты прогонов
|
||||
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,112 @@
|
||||
# AGENTS.md
|
||||
|
||||
Краткий обязательный контракт. Правило: **у каждой темы ровно один источник
|
||||
истины**; здесь только то, что агент обязан знать до чтения остального, и то,
|
||||
что линтер не проверяет. Подробности — по ссылкам ниже.
|
||||
|
||||
## Стек и команды
|
||||
|
||||
- **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes).
|
||||
- **pnpm** обязателен, npm не использовать. Команды — из корня (`pnpm build`,
|
||||
`pnpm dev`, `pnpm verify`) или из `web/` с префиксом `pnpm --dir web`.
|
||||
|
||||
| Что | Команда |
|
||||
| ----------------------- | ------------------------------------------------- |
|
||||
| Полный быстрый gate | `pnpm verify` |
|
||||
| Типы | `pnpm --dir web check` |
|
||||
| Юнит-тесты | `pnpm --dir web test` |
|
||||
| Тесты lint-правил | `pnpm --dir web test:rules` |
|
||||
| Линтинг | `pnpm --dir web lint:all` |
|
||||
| Только токены | `pnpm --dir web lint:tokens` |
|
||||
| E2E | `pnpm --dir web test:e2e` |
|
||||
| Форматирование | `pnpm format` (код + docs) |
|
||||
| Проверка форматирования | `pnpm check:docs` + `pnpm --dir web format:check` |
|
||||
|
||||
## Что читать под задачу
|
||||
|
||||
Читать только нужную строку, а не все документы подряд.
|
||||
|
||||
| Задача | Документ |
|
||||
| ---------------------------------------------- | ---------------------------------- |
|
||||
| Слои, поток данных, новый инструмент/поле | `docs/architecture.md` |
|
||||
| Какие проверки запускать, пороги, CI | `docs/quality-gates.md` |
|
||||
| Правка самих гейтов: «почему так, а не иначе» | `docs/decisions.md` |
|
||||
| Порядок работы, нагрузка на машину | `docs/agent-workflow.md` |
|
||||
| Locator-ы, fixtures, browser matrix | `docs/testing-strategy.md` |
|
||||
| Детали lint-правил и токенов | `web/eslint-plugins/README.md` |
|
||||
| Задачи и идеи | `docs/backlog.md` |
|
||||
| Актуальный список инструментов | `docs/tools-map.md` |
|
||||
| Правка полей и раскладки по волнам | `docs/plan-fields-audit-fixes.md` |
|
||||
| Ручная приёмка UI | `docs/checklist-manual-testing.md` |
|
||||
| Направления развития, фазы 1–11 | `docs/roadmap.md` |
|
||||
| SEO/GEO-план (адреса, `noindex`, sitemap) | `docs/plan-seo.md` |
|
||||
| API/PWA/шрифты | `docs/plan-platform.md` |
|
||||
| Переименование бренда и покупка домена | `docs/plan-domain.local.md` † |
|
||||
| Сырые UX-находки по инструментам (вход аудита) | `docs/tools-audit.md` |
|
||||
| Архивирование и удаление старых документов | `docs/archive/README.md` |
|
||||
| Индекс остальных документов | `docs/README.md` |
|
||||
|
||||
† `docs/plan-domain.local.md` вне git, в клоне отсутствует — в этом случае
|
||||
задачу ищут по `docs/plan-seo.md` §6.
|
||||
|
||||
## Инварианты, которые линтер не проверяет
|
||||
|
||||
- **Сначала искать.** Новая константа, тип или утилита не заводится, пока не
|
||||
найдено существующее определение — по ключу в соседних модулях и по всему
|
||||
`web/src/`.
|
||||
- **Комментарий объясняет «почему»**, а не пересказывает код. `TODO`/`FIXME` —
|
||||
только для зарегистрированной задачи: ссылка на `docs/backlog.md` и критерий
|
||||
закрытия.
|
||||
- **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n,
|
||||
UI и рефакторинг.
|
||||
- **Не коммитить.** Коммит и пуш делает разработчик после ревью.
|
||||
- **Не запускать тяжёлое без спроса.** Playwright — только по явной команде
|
||||
пользователя, один прогон, без циклов повторов и фонового запуска.
|
||||
- **Не писать изменяемые числа** (количество инструментов, тестов, локалей,
|
||||
проценты покрытия): вместо числа — ссылка на источник или команда. Число
|
||||
допустимо, только если его проверяет конфиг или тест. Обоснование и границы
|
||||
автоматизации — `docs/decisions.md`, раздел 3.
|
||||
- **Результат CI смотреть по отдельным job**: у `e2e` стоит `continue-on-error`,
|
||||
поэтому общий conclusion может быть зелёным при красном e2e
|
||||
(`docs/decisions.md`, раздел 10).
|
||||
|
||||
## Границы изменений
|
||||
|
||||
- Production-UI — `web/src/routes/**` и `web/src/lib/components/**`.
|
||||
- `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts` — общие модули: их
|
||||
правка проверяется unit-тестами и smoke-сценариями production-потребителей.
|
||||
- Направление зависимостей, точки расширения и рецепт правки (инструмент, вид
|
||||
поля, маршрут, токен) — `docs/architecture.md`, разделы 1–2, 7, 8.
|
||||
|
||||
## Что проверяется линтером
|
||||
|
||||
`pnpm --dir web lint:all` — ESLint с `--max-warnings=0` (warn-only правила тоже
|
||||
блокируют gate), Stylelint и token audit. Синтаксические ограничения здесь не
|
||||
дублируются: полный список правил и префиксы токенов —
|
||||
`web/eslint-plugins/README.md`; новое правило сначала появляется в warn-only
|
||||
режиме с fixtures и тестами (`test:rules`).
|
||||
|
||||
- **E2E линтером не покрыт:** порядок locator-ов, допустимые `data-testid`,
|
||||
`test.fixme` — `docs/testing-strategy.md`.
|
||||
|
||||
## Дизайн
|
||||
|
||||
Источник правды по визуальному языку — код: `web/src/app.css` (дизайн-токены и
|
||||
глобальные стили), `web/src/lib/components/**` и витрина `/kit`. Новый дизайн
|
||||
живёт в `app.css` и применяется ко всем production-маршрутам; повторяющийся
|
||||
визуальный элемент — отдельный компонент, даже «просто div с двумя стилями»;
|
||||
дизайн-токены — только из CSS-переменных, новый токен появляется вместе с
|
||||
потребителем (`pnpm --dir web lint:tokens` сообщает о неиспользуемых). Приёмка —
|
||||
`docs/checklist-manual-testing.md`.
|
||||
|
||||
## Документация
|
||||
|
||||
- Новый документ создаётся, только если темы нет в существующем; иначе
|
||||
обновляется источник истины. У документа есть статус (`draft`, `active`,
|
||||
`completed`, `archived`).
|
||||
- Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и
|
||||
ссылка на канонический документ.
|
||||
- **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для
|
||||
проекта факты переносятся в тематический документ. Завершённые планы и
|
||||
исследования — в `docs/archive/`, удаляются через 30 дней; процедура в
|
||||
`docs/archive/README.md`.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
|
||||
|
||||
- - -
|
||||
## [v0.5.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/76039f7bf3247ef9711113608f5ec2d01a7fa56f..v0.5.0) - 2026-10-09
|
||||
#### Features
|
||||
- add show mask chip for tools - ([c22c6e5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c22c6e55741f99a6c8a79a82b7b3deadd33fa3dc)) - Ku6epXBOCTuK
|
||||
- add chains page, update pipeline usage - ([a51e224](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a51e224d02ccd45ca0adc69135112b8a0a3839c7)) - Ku6epXBOCTuK
|
||||
- add limit size to download flow - ([8dcc263](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8dcc263461111937d1b116cbfec41329c314b7b9)) - Ku6epXBOCTuK
|
||||
#### Bug Fixes
|
||||
- image decode error i18n - ([76039f7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/76039f7bf3247ef9711113608f5ec2d01a7fa56f)) - Ku6epXBOCTuK
|
||||
|
||||
- - -
|
||||
|
||||
## [v0.4.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/83e75b5589ee200b6e216bc0187404b8be086149..v0.4.0) - 2026-10-07
|
||||
#### Features
|
||||
- add og description\image\etc - ([31ace35](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/31ace359d7ff187cbe9ba968facc498e75e66b3d)) - Ku6epXBOCTuK
|
||||
- add socials icons - ([fbe6139](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fbe6139585aeaca2b70b55224115c9e2b7653ca6)) - Ku6epXBOCTuK
|
||||
- add favorites tools - ([2f11cb8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f11cb86ade08b91f48c823f3354f62260bae262)) - Ku6epXBOCTuK
|
||||
- add split to parts by tile size - ([32cb369](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/32cb369ca96a96fc0194a63d8086db610f3f190a)) - Ku6epXBOCTuK
|
||||
- aligned mode for pipeline - ([b7be973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b7be973b54091b071e0c751533a73ad1d0ea11fb)) - Ku6epXBOCTuK
|
||||
- change tool inplace - ([1ba03b7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ba03b731d80212eedcf9d8985478dc78e2e32f4)) - Ku6epXBOCTuK
|
||||
- collapse\expand steps in pipeline - ([5c58451](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c58451f2f5a0dde167d7b2bebcc8f3190f20212)) - Ku6epXBOCTuK
|
||||
#### Bug Fixes
|
||||
- select mode via segmented button in split by parts - ([676be38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/676be383d9fb71b7dc4e83d8c243dc39c60f6c36)) - Ku6epXBOCTuK
|
||||
- add text to no param tools - ([e7d3925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e7d3925eb2063b0265a396bf693b4ed95b49782b)) - Ku6epXBOCTuK
|
||||
|
||||
- - -
|
||||
|
||||
## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06
|
||||
#### Features
|
||||
- add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK
|
||||
- add transparency range slider to opacity fields - ([ba533e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba533e67fdba5e6b40f21c6b3ca375cf16426fef)) - Ku6epXBOCTuK
|
||||
- upload image on click, tooltip - ([a0bc72e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a0bc72ef5d06b65507d6fe2ee3150872dbc146ae)) - Ku6epXBOCTuK
|
||||
- split to parts show sizes of parts - ([684423d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/684423de679fc1b65195bd4fb1ce43290577722e)) - Ku6epXBOCTuK
|
||||
#### Bug Fixes
|
||||
- max height for source preview - ([0753c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0753c95583e83adb9fa25779bb5263a5cfe93ba2)) - Ku6epXBOCTuK
|
||||
- scroll in result for split parts - ([7763fb7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7763fb7faa790c2e41f85362d375f576f28a4de1)) - Ku6epXBOCTuK
|
||||
- range slider drag after regression - ([f00dae8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f00dae8b0158b9ff779511e1dd46cda8f5b6cb14)) - Ku6epXBOCTuK
|
||||
- update split into parts max params - ([2a282b4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2a282b48f8d6be2bd3a4079f3a1a77031072b970)) - Ku6epXBOCTuK
|
||||
|
||||
- - -
|
||||
|
||||
## [v0.2.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f40a7c89ae5615f2c16f1154116d35fdc0e53125..v0.2.0) - 2026-10-04
|
||||
#### Features
|
||||
- add pipeline flow - ([6b946bf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b946bfb08729ead2515e87406cb3efd22ca9859)) - Ku6epXBOCTuK
|
||||
- add select format to download button - ([e87bc1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e87bc1d7159e91acbf297351710c367eae543011)) - Ku6epXBOCTuK
|
||||
- upload source by ctrl+v and dropzone - ([864f9cd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/864f9cdc9f355292d688a46f1d0383a893d252d3)) - Ku6epXBOCTuK
|
||||
- add size presets for tools - ([91150a1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91150a14bf8203627132f6d3a409109845ed749f)) - Ku6epXBOCTuK
|
||||
- touched fields dont reset on change source image - ([5743b9f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5743b9fb3f1eb7f3b4f9b01e35c9a0f897ee7411)) - Ku6epXBOCTuK
|
||||
#### Bug Fixes
|
||||
- keep aspect ratio flow - ([4d9cd2c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4d9cd2cfcbe6491dfe2ee6074e8f8c1d8abeee74)) - Ku6epXBOCTuK
|
||||
- update range slider field component - ([e73986f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e73986fefd7306391711a39f719e48f60f5d3292)) - Ku6epXBOCTuK
|
||||
- add title from i18n - ([62bdd5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/62bdd5a7c582c74118e964058f84108eaf0cf4cc)) - Ku6epXBOCTuK
|
||||
#### Refactoring
|
||||
- reuse badge component - ([409c98c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/409c98ca295be7b4541148c9568ed9b89524e37a)) - Ku6epXBOCTuK
|
||||
|
||||
- - -
|
||||
|
||||
## [v0.1.1](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/1db83d12ff717bc2e282fe21d35d282f261147f5..v0.1.1) - 2026-10-04
|
||||
#### Bug Fixes
|
||||
- google analytics only on production - ([e1fc11c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1fc11cb5491a6f5be5c823140830672ea669a62)) - Ku6epXBOCTuK
|
||||
- shapes offset calculate - ([1db83d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1db83d12ff717bc2e282fe21d35d282f261147f5)) - Ku6epXBOCTuK
|
||||
|
||||
- - -
|
||||
|
||||
## [v0.1.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/fdf7e2a3cd68d6693f1c5581796fafaf8939dad4..v0.1.0) - 2026-10-04
|
||||
#### Features
|
||||
- change id - remove png intents - ([7b7e12f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b7e12fafcbd7444be61426cd0b90104de9de40f)) - Ku6epXBOCTuK
|
||||
- move routes to slugs - ([d09c954](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d09c95469494ec5f243f1981f6bc08b6e9fa6eb7)) - Ku6epXBOCTuK
|
||||
- separate i18n dict pages - ([9776e1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9776e1d3251761347440ed43d57fef851b2af150)) - Ku6epXBOCTuK
|
||||
- separate slug pages from tools - ([7a3498c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7a3498c173daa63f0a9ec88f09e10798525be9bd)) - Ku6epXBOCTuK
|
||||
- add source aware resolver, update reset, update generator - ([5f77c09](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5f77c097e2d58d1826a10f05217af973c4de7e81)) - Ku6epXBOCTuK
|
||||
- remove v1 routes and libs - ([16d4288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/16d42889120566e45008fa94285fd518b37f967f)) - Ku6epXBOCTuK
|
||||
- add new dics\translations - ([9a27388](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9a27388119bb1b14bd759a830a9444be6c498e1b)) - Ku6epXBOCTuK
|
||||
- add icon for split tool, update docs - ([b91c1ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b91c1ab7ee5bad72a90d2c66543a2e04e8f8c70e)) - Ku6epXBOCTuK
|
||||
- add ui for new split tool - ([2be8871](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2be887193084b0fc87f16c110264bad58dc7279c)) - Ku6epXBOCTuK
|
||||
- add executor support for multifiles result - ([adb5785](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adb578540f95ccafd3d0e8c419760da1cb031dcb)) - Ku6epXBOCTuK
|
||||
- add split to parts to geometry registry - ([a7a42d0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a7a42d0091af3cc8e61c16962c4000d5c0bd8bdd)) - Ku6epXBOCTuK
|
||||
- add error texts - ([e1c3d0a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1c3d0a95ffd1b1a1245a88bf8db247c28fc0b64)) - Ku6epXBOCTuK
|
||||
- add download zip with result files - ([ceee9ad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ceee9adead94c437785ede7053e4563a7cbf5054)) - Ku6epXBOCTuK
|
||||
- add split to parts core tool - ([d94c5d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d94c5d1297bfbc35b08feab28b565eab152217f4)) - Ku6epXBOCTuK
|
||||
- add files tool result - ([ee5f660](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ee5f660572676edf829faca3a238796468ab2c12)) - Ku6epXBOCTuK
|
||||
- rename old -> v1 folder - ([33e3f3c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/33e3f3c4f035c0daeae8de687cd464b1572242e5)) - Ku6epXBOCTuK
|
||||
- flatten lib/preview -> lib folder - ([1e5e096](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e5e096d5d61b5ca69aaf196928868ad67f2f4f5)) - Ku6epXBOCTuK
|
||||
- flatten components/kit -> components folder - ([f8a1da6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f8a1da6ff79be1a22e0046db7c3701110479011a)) - Ku6epXBOCTuK
|
||||
- move preview pages to root - ([45dc54b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/45dc54b6aa136f753de16856a2378fec5ecbd10f)) - Ku6epXBOCTuK
|
||||
- move old root routes to /v1 routes - ([b6318f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6318f03ab60cea602606d88dd94ed3d0601dd0f)) - Ku6epXBOCTuK
|
||||
- update filter tools to new types - ([d6706ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d6706abea22cd9fa587f377338200032cce0f38a)) - Ku6epXBOCTuK
|
||||
- update alpha and colors tools to new types - ([bea010d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bea010dd907e1c5ffe0b3b2f43c5db6efac9d348)) - Ku6epXBOCTuK
|
||||
- update geometry tools to new types - ([2937974](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2937974fe732690ca58e4d693e5c1a69ef4bfc90)) - Ku6epXBOCTuK
|
||||
- update text tools to new types - ([1e85fe3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e85fe36a636a7ab4e4daf48c2877ba9e91eeccb)) - Ku6epXBOCTuK
|
||||
- update convert and analyze tools to new types - ([60c7d61](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/60c7d61daba60f656522e3ab45ffb87149910e69)) - Ku6epXBOCTuK
|
||||
- update generate tool to new types - ([6b0b064](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b0b0646d51d3b3f74e4d132df2bfd44be411530)) - Ku6epXBOCTuK
|
||||
- update schema components - ([b6db1c8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6db1c8f19ceb15c6a5e81f4108c9a25dc180525)) - Ku6epXBOCTuK
|
||||
- update executor for tools - ([ba8a518](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba8a51886129d3c128d21ee625a8c3a0d16ae5b0)) - Ku6epXBOCTuK
|
||||
- update tools types - ([292e53c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/292e53c9c76d4e02efa4990b5d62868a197d39c7)) - Ku6epXBOCTuK
|
||||
- add text tools - ([e5b76ca](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e5b76ca3e11eb0313988ea1d49578adb6f24bf1e)) - Ku6epXBOCTuK
|
||||
- add text core tools and views - ([a497857](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a49785764b39af1707be5990b5adcb43eb7356c9)) - Ku6epXBOCTuK
|
||||
- add output format for download - ([c0cebdc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0cebdc81f229fc2fab57b40c48b2fb6dd84f598)) - Ku6epXBOCTuK
|
||||
- add generate tools - ([b3d460a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b3d460a951f69b505b153ae2e1af5596f8321049)) - Ku6epXBOCTuK
|
||||
- add multiple colors field - ([6be9d10](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6be9d10c42580a984b78e95fb0e317b2d4d6993a)) - Ku6epXBOCTuK
|
||||
- add analyze, generate, text tools and update plan - ([cc70750](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cc70750727aaf5d5c758aa64fa577262a5197b9b)) - Ku6epXBOCTuK
|
||||
- add alpha tools - ([e2735c5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e2735c52abf1e3968b24e334bb3d52c4e7f4dcf3)) - Ku6epXBOCTuK
|
||||
- add geometry tools - ([d383cf2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d383cf29e30ad3722b754eb07cad10251af784d3)) - Ku6epXBOCTuK
|
||||
- add filters tools - ([bfad0c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bfad0c7d020fe8e19a5919ed42011710b955a1df)) - Ku6epXBOCTuK
|
||||
- add colors tools - ([6750d5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6750d5a373c9f10b44fa75c7c407f12e2d8d6680)) - Ku6epXBOCTuK
|
||||
- add layout groupt to tools - ([e662d1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e662d1df248469f4e554b39a4e42d25a3296192d)) - Ku6epXBOCTuK
|
||||
- add tool schema layout - ([d48262e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d48262e48269785c7edd414868fd952ff6d85f5f)) - Ku6epXBOCTuK
|
||||
- add gradient control component - ([cad9bf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cad9bf6cfd48aef22d27610d735a3e76bd4baaab)) - Ku6epXBOCTuK
|
||||
- add plate (under text) field component - ([b99ccf5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b99ccf5d7808bdd3bca7349b10e1b3858c7e56c3)) - Ku6epXBOCTuK
|
||||
- add font style field component - ([0a7c4a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0a7c4a84c9009ab907579dc1750d8bd203d34f67)) - Ku6epXBOCTuK
|
||||
- add position9 field component - ([f24ccf9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f24ccf924031c7384b22c83087401fbdfbfc4125)) - Ku6epXBOCTuK
|
||||
- add offset field component - ([0c28925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c28925673facc40500c3c5e8e63d94db617f94b)) - Ku6epXBOCTuK
|
||||
- add two colors field control - ([19f2e13](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/19f2e13b8874b60bb0d34d2d0ce8a575bfd9fe5c)) - Ku6epXBOCTuK
|
||||
- update use dimension tools - ([f286044](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f286044ab92773af36b712d847b4eccc1f31d81b)) - Ku6epXBOCTuK
|
||||
- add dimension tools for new flow - ([2601aa5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2601aa5b5c9e6d5c20a4df6d1eab2a0a68126f60)) - Ku6epXBOCTuK
|
||||
- add registry tools for new preview flow - ([1e9b354](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e9b35406d8e3fd4071ba72d1dc6a043a0bf318f)) - Ku6epXBOCTuK
|
||||
- double executor to use with preview - ([520a1e1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/520a1e1d37f4b63692ca906c06bdf694e297f0ca)) - Ku6epXBOCTuK
|
||||
- add new registry for preview design - ([a19d3db](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a19d3db929f5d840900f1190c9efd10bb0595548)) - Ku6epXBOCTuK
|
||||
- add single tool schema rendering - ([1c74629](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1c746299c9780a237cff2197c8b6b46310bc49d1)) - Ku6epXBOCTuK
|
||||
- add new registry schema for new ui - ([1ae4b36](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ae4b36c9c2b0268a39486c935220cad9290cfb7)) - Ku6epXBOCTuK
|
||||
- css audit fixes - ([dbeffac](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dbeffac082f56990fa1d08089b00e79da28b276c)) - Ku6epXBOCTuK
|
||||
- WIP: add css fixes - ([2ef4709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2ef47090387fc47bd6c93365c7471532402e514d)) - Ku6epXBOCTuK
|
||||
- add refs css audit - ([1831c3d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1831c3d10051510842fbf1a591c95d39cabf3ce4)) - Ku6epXBOCTuK
|
||||
- fix tools pages, dom audit fix - ([9ef6ae0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ef6ae03c69a8b343bc8f05dac832876b9ea184e)) - Ku6epXBOCTuK
|
||||
- add preview catalog and catalog group and header - ([b695f5d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b695f5d58c2e78f7d076d175ae819811b874cfe1)) - Ku6epXBOCTuK
|
||||
- fix list tools page to correspond refs - ([7578a18](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7578a185b64e48c970e27c18c580b4055ba5c23b)) - Ku6epXBOCTuK
|
||||
- fix demo page to correspond refs - ([d29ec73](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d29ec73322d6caf3d7ebff3305b2cf20cb0f0fbb)) - Ku6epXBOCTuK
|
||||
- add new simple components - ([c0f48ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0f48ecd9762f0c507933409525f5e22bf759a76)) - Ku6epXBOCTuK
|
||||
- some refactor, fix some design - ([65f12c9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/65f12c931baec26732a6fc3f39284bd423a3af0b)) - Ku6epXBOCTuK
|
||||
- add link to preview design - ([40eee98](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/40eee984daeb2d82af375ff322c66a6c0e7ca869)) - Ku6epXBOCTuK
|
||||
- update tool page - add step card steps - ([d373cc0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d373cc0daf8672c1febb3c3a844cb62714ff1dc7)) - Ku6epXBOCTuK
|
||||
- add dropzone, update tool page - ([a16a7b9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a16a7b92c737273b27fd2611a77c65b746d63231)) - Ku6epXBOCTuK
|
||||
- add single tool pages, wiring with toolcard - ([f990ad6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f990ad67671662637acc6b890145a2d2b0109bf0)) - Ku6epXBOCTuK
|
||||
- separate topbar, fix important styles - ([f4216e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f4216e6868f975e511395c729359109a9a001a5c)) - Ku6epXBOCTuK
|
||||
- add demo page - preselected pipeline - ([adcc255](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adcc2551073d0a57517b655239100d807e643fe7)) - Ku6epXBOCTuK
|
||||
- add list tools page - ([73118ea](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/73118ea393c60d69108be3a08d72608c937465b5)) - Ku6epXBOCTuK
|
||||
- add main page - ([e9578fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9578fc15e179b9ae5df3c0f9e1690f95b113a26)) - Ku6epXBOCTuK
|
||||
- add ui kit page - ([06d185a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/06d185add66864662b0a173183f69c22078dc098)) - Ku6epXBOCTuK
|
||||
- add ui components - ([8437c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8437c95a28343d6b24971d41776b8fd65e831810)) - Ku6epXBOCTuK
|
||||
- add ui components - ([ac75665](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ac75665deb78d9b9459e4c34f4a9f9719a027c63)) - Ku6epXBOCTuK
|
||||
- add new design primitives - ([0acfa3a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0acfa3aeb923fe7cbe95846055d2e199a62257e4)) - Ku6epXBOCTuK
|
||||
- add new design2.css, add fonts - ([418a091](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/418a091505d80e4b2798a80740906d2d215c2488)) - Ku6epXBOCTuK
|
||||
- add compression tools - ([83d3ce1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/83d3ce11986c908ea64c54c23bbfba0395cc838d)) - Ku6epXBOCTuK
|
||||
- add quantize tools - ([501d2dd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/501d2dda4e186b2f7ece3f1be41b552393ca18a3)) - Ku6epXBOCTuK
|
||||
- add text and generation tools - ([2b67414](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2b67414e79ef646b78bf6372d35e2262099d65a0)) - Ku6epXBOCTuK
|
||||
- add pixel effects core tools - ([56ebe5b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/56ebe5b53be2bd797911faf285b5fe7b414358a1)) - Ku6epXBOCTuK
|
||||
- add geometry masks - ([84cc56c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/84cc56cc6cf6627382d4f76f464c787a90181308)) - Ku6epXBOCTuK
|
||||
- add shape masks - ([d65adcd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d65adcd16cb9bbd888b356784b46489e611f6ace)) - Ku6epXBOCTuK
|
||||
- add masks core tools - ([553601a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/553601acc1ec4bb5a5fd74b50e60afb09070dd5d)) - Ku6epXBOCTuK
|
||||
- add channel tools - ([5c37eb2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c37eb2e7b6bc41a219550e8be237697266ce5a1)) - Ku6epXBOCTuK
|
||||
- add pallette tools - ([db02cbd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/db02cbd16558a367d328184b96f570ec692e5d67)) - Ku6epXBOCTuK
|
||||
- add watermarks and text tools - ([116d7c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/116d7c72aac7ba2463f249c962251ee9d4a56744)) - Ku6epXBOCTuK
|
||||
- add text and date draw tools - ([54af762](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/54af7626d6615c379e841e38e528fc9572d11196)) - Ku6epXBOCTuK
|
||||
- add text draw core - ([38e7cd2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/38e7cd20d28e7fc26d3cbfb3ec960a2b136d1202)) - Ku6epXBOCTuK
|
||||
- add demo page - ([6e0647d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6e0647dfbf9eb68d980ae8d76d6affdc9f1965d6)) - Ku6epXBOCTuK
|
||||
- inline tool page - parameters beetween source and result images - ([148e4b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/148e4b195739774c5e892dbfd16f5a83617b93dc)) - Ku6epXBOCTuK
|
||||
- add theme switcher, dark theme - ([f1f5bd4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f1f5bd4409797cb6c28fdee55e3dbd73ac6ce5f0)) - Ku6epXBOCTuK
|
||||
- add base path for deploy - ([6d770dc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6d770dc642cd6110bb4ab3d7af9c9364bb29d0bb)) - Ku6epXBOCTuK
|
||||
- search use both languages and remove diacrites - ([c04a1fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c04a1fc1dc4d266f47164e735668a53e14c2d61b)) - Ku6epXBOCTuK
|
||||
- use i18n in components - ([3960368](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/396036802c1ee1e8fe52877826b22b9a3a427bee)) - Ku6epXBOCTuK
|
||||
- base english language, also fallback to english - ([74a18d9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/74a18d91a77f3a049683f73122279eb9392a61df)) - Ku6epXBOCTuK
|
||||
- move all common messages to i18n (ru\en) - ([2301b0e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2301b0e1654874744d2ddf72b878997aa461620b)) - Ku6epXBOCTuK
|
||||
- add i18n - dict, en\ru, state - ([8255d33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8255d330988f9f6f98c51407e6e98613f7306150)) - Ku6epXBOCTuK
|
||||
- add tool registry, icons - ([8745812](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8745812a5f077f715b003ca4022d20a72eb783ba)) - Ku6epXBOCTuK
|
||||
- add io extensions - svg, jpeg - ([0909e14](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0909e14cde8ba6ab7249e54bc84bdc4a095cbf8d)) - Ku6epXBOCTuK
|
||||
- add core tools - transfor, light, effects - ([7ef4b90](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7ef4b908e2f314e776ffd10f904085ac2eb4f3ea)) - Ku6epXBOCTuK
|
||||
- add tools into registry, add icons - ([a2dd288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a2dd288851572b9634664f647e5c76a7f8184869)) - Ku6epXBOCTuK
|
||||
- add alpha and morphology core tools - ([cb5ccf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cb5ccf6da8d3f21590e481a617507a9f899090e8)) - Ku6epXBOCTuK
|
||||
- add tool search cards - ([5e44a45](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e44a454571e99a33d019f5eacfaed840a00c8c5)) - Ku6epXBOCTuK
|
||||
- add tool popularity and icons - ([76f1770](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/76f1770286258097000ee18a9d11adc6b55ae08c)) - Ku6epXBOCTuK
|
||||
- combine tool in single card - ([702b962](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/702b96261d0266fd904fa740f5a4bdab12ffa24e)) - Ku6epXBOCTuK
|
||||
- move tool executor to web worker - ([9fbb90b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9fbb90b6418641154c40017ca03d7fd3bcec1452)) - Ku6epXBOCTuK
|
||||
- add registry - background, blur, sharpen - ([c143011](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c14301130f23e9a4787f13d26b0bbf1a14bd084e)) - Ku6epXBOCTuK
|
||||
- add background and convolution core tools - ([fd5236c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fd5236c19b5a6daba78d8780d3c217e92d33f74f)) - Ku6epXBOCTuK
|
||||
- restore old toolchain button - ([91b3492](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91b3492231974d3b67b694a313c1f91dcf5b545d)) - Ku6epXBOCTuK
|
||||
- add chain tools workflow - ([306c26a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/306c26a3602d64a6a24ed938f20c04882f4637c4)) - Ku6epXBOCTuK
|
||||
- add tool search component, add chainable flag - ([c93a8e4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c93a8e493ef8464d1b1ee96869b6f99ddc0a583a)) - Ku6epXBOCTuK
|
||||
- add step picker\list\editor - ([8532eb9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8532eb98c3f985ed4ee9ba0c7a51eabe0f86dc3c)) - Ku6epXBOCTuK
|
||||
- add pipeline for multi instrument flow - ([23c985b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/23c985bde90cdbc24af5f1c137c0153e98658408)) - Ku6epXBOCTuK
|
||||
- add registry - ([6fae98b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6fae98b25c9db23f27effdd6c73914cad5a9a647)) - Ku6epXBOCTuK
|
||||
- add core operations - ([f9a4973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f9a4973b471b19f2aeaa8b71bf56bff2fd4de18d)) - Ku6epXBOCTuK
|
||||
- add core operations - ([7fa2c52](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7fa2c52bb83538898398460de5ed520a26372c02)) - Ku6epXBOCTuK
|
||||
- add text\hex conver tools - ([17b0c68](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/17b0c68eb52cf8e4e495bd01754ba8e7d3130202)) - Ku6epXBOCTuK
|
||||
- add color picker glass - ([96c37aa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96c37aabc568896f7a279069d0c65e6ff4f794bb)) - Ku6epXBOCTuK
|
||||
- add color picker - ([7b79996](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b799969c4bc0229de490921f29a15b84387ee21)) - Ku6epXBOCTuK
|
||||
- add slider field - ([3701fc8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3701fc80451f94989d412a954715b1ffe9333836)) - Ku6epXBOCTuK
|
||||
- add show mask workflow - ([aa5372b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/aa5372b771ef0834c2221f045c27cdd4570a1c41)) - Ku6epXBOCTuK
|
||||
- reactive tool flow, with debounce - ([d0ee4ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d0ee4ec77f7999655e55c79df50b98a68d3fab9a)) - Ku6epXBOCTuK
|
||||
- update drop file flow - ([caf71ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/caf71ec8f0418ebe43d9e04b007e9c3379864afd)) - Ku6epXBOCTuK
|
||||
- add ctrl+v flow - ([f576701](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f576701baabbd0172550efe18c422650e0d15ee1)) - Ku6epXBOCTuK
|
||||
- update - use design system components - ([c7b56bb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c7b56bbda79b67d4e13c5062c55266c7b6de2c5c)) - Ku6epXBOCTuK
|
||||
- add design system components - ([15e2e01](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/15e2e010ec3227dae0786ebf81afe71ed0c8b011)) - Ku6epXBOCTuK
|
||||
- add design system tokens - ([3620c5f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3620c5fc309e840eea5ac6927534b50c2957928f)) - Ku6epXBOCTuK
|
||||
- add route pages - ([3ec1199](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3ec11999d82c118b72416d11fd0aa6b2698359f6)) - Ku6epXBOCTuK
|
||||
- add svelte components - ([c022555](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0225552e4d5970b81a3dba51e357821da38977b)) - Ku6epXBOCTuK
|
||||
- add instruments registry - ([719bda2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/719bda2e225331d04da35b7a35fb980b2af63f00)) - Ku6epXBOCTuK
|
||||
- core instruments - ([9ccc9f9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ccc9f94acb5cc9d6f5ab1c13a7b3641160e3edd)) - Ku6epXBOCTuK
|
||||
- core types and io - ([4ec2b41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4ec2b418fd001bf0c0d95534cd38988b6a44cd1d)) - Ku6epXBOCTuK
|
||||
- initial sveltekit - ([85f595f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85f595f14b9cd059723d4ae84265b6a6e1633a67)) - Ku6epXBOCTuK
|
||||
#### Bug Fixes
|
||||
- show version in footer from manifest file - ([f89811c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f89811c116fbff42d0323ed811104bdce06dc68d)) - Ku6epXBOCTuK
|
||||
- update crop tool max sizes - 2000x1200 - ([becf03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/becf03f4165e744e05b4e39d906b72a4804f6094)) - Ku6epXBOCTuK
|
||||
- use base path for favicon - ([44cfca9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/44cfca92ce2eb2cef5119fb3701719f78d97f579)) - Ku6epXBOCTuK
|
||||
- worker use only i18n keys instead of full i18n - ([c2f3838](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c2f3838485a78b12246a480cda564feb5a19255e)) - Ku6epXBOCTuK
|
||||
- update i18n - update registry for tools - ([371387c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/371387ce81569921f5e143daec81275b63a1bab4)) - Ku6epXBOCTuK
|
||||
- update i18n - update schema fields descriptions - ([08c9fbc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/08c9fbce3b21a7530bba12d8cdaa77f4286e69ae)) - Ku6epXBOCTuK
|
||||
- update i18n - tool group descriptions - ([4de42b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4de42b1e402a17f43992b132d8d3d97b869fc0d9)) - Ku6epXBOCTuK
|
||||
- update i18n - text verdicts from analyze tools - ([372babc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/372babc3b741e0fd5e10709b4b6e3e70589b6074)) - Ku6epXBOCTuK
|
||||
- update i18n - fields components - ([4b44bc4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b44bc439ce2a36baf1c3f68fde8776d31ccb3f1)) - Ku6epXBOCTuK
|
||||
- update i18n - schema components - ([6c88a23](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c88a23e4882bebd20e9bd7cc45c70f15818d5cb)) - Ku6epXBOCTuK
|
||||
- update i18n - catalog, nav, common components - ([a4c85c0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a4c85c08140bd802bfb0dc2952ea475d4dca48f5)) - Ku6epXBOCTuK
|
||||
- update i18n text in pages and layout - ([e069709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e06970997e5a402139d2f2b9c78cab8310cad293)) - Ku6epXBOCTuK
|
||||
- update lang toggle component - ([1097186](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/10971864872fb305bbcad4e6470b7afac8fa1312)) - Ku6epXBOCTuK
|
||||
- restart tool after change source - ([5cd2a58](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5cd2a586ce70116730cc9ce11653943194992454)) - Ku6epXBOCTuK
|
||||
- update design - colors, remove texts - ([f2829de](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f2829de216f48f0d2bc5b35c0a1bf34a51ad10e8)) - Ku6epXBOCTuK
|
||||
- disable download button while running jobs, auto run generator jobs - ([0c2de3f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c2de3f191cb9cfd10c4186cd16db7db2f8545bf)) - Ku6epXBOCTuK
|
||||
- separate preview tile component - ([35df076](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/35df07673211f2734c4f52ab4e232580006efc9c)) - Ku6epXBOCTuK
|
||||
- source image checker background - ([0b107c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b107c7d93a8dd27b8ad40c2c73a70518a45056f)) - Ku6epXBOCTuK
|
||||
- update topbar links - ([f7623ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f7623ab3bbc3d3998938e918e963219039f97f88)) - Ku6epXBOCTuK
|
||||
- v1 layout - ([85effce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85effce7d9ae940858fda6ed3f4b8336799ac93c)) - Ku6epXBOCTuK
|
||||
- css fixes - ([420d03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/420d03f58c3ed26f0dd5658cdc9391f881847ba1)) - Ku6epXBOCTuK
|
||||
- lint errors - ([6f1771c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6f1771cb07da8b43b3ef79c8ebb4033f429b491c)) - Ku6epXBOCTuK
|
||||
- update tool card component - route resolve issue - ([987f453](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/987f4535c8609e25ae27db192ba598683604803f)) - Ku6epXBOCTuK
|
||||
- resolve always text input issue, separate to simple components - ([a1438e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a1438e6f7f227f7489465070c02abdbb495a9dd1)) - Ku6epXBOCTuK
|
||||
- add compress\decrease colors presets - ([37a4a05](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/37a4a0576c505ee3a87c0886e92faa9521092097)) - Ku6epXBOCTuK
|
||||
- update tools list - show all tools - ([d1a386f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d1a386f92f2e9e7fa859cef950ca68d5f3729194)) - Ku6epXBOCTuK
|
||||
- update segmented component styles - ([a24a182](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a24a182d1813f9bc882d3dff2cbf58a0396c799c)) - Ku6epXBOCTuK
|
||||
- increase font size - ([1e63eaa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e63eaa03bc7d4c6ee21eb88cd0dff4cb54b4922)) - Ku6epXBOCTuK
|
||||
- update field component - ([3d0f8eb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3d0f8ebb100294b970e9b45a854126ca031df9d7)) - Ku6epXBOCTuK
|
||||
- update download button style - ([ef74cab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ef74cabf1e8bea77e88e0e29c802e02ee57eaf3d)) - Ku6epXBOCTuK
|
||||
- update button component styles - ([c590bdf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c590bdf91a896fab0fd8a3e347a67db639be26c7)) - Ku6epXBOCTuK
|
||||
- update css variables, update badge component - ([4794523](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/47945230846bacc4063bf8fc21620fefe57f3099)) - Ku6epXBOCTuK
|
||||
- update some styles - ([191ca71](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/191ca71553187b45efb6bc26fb278a1e10b93aeb)) - Ku6epXBOCTuK
|
||||
- change style, demo page visual looks as done - ([749b97c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/749b97cb4f55b145067bc169da8d4d08848ee4e3)) - Ku6epXBOCTuK
|
||||
- resolve new audit styles - ([80def1f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/80def1f38043b3fdd081fa8c8b3558c6c6f01ce5)) - Ku6epXBOCTuK
|
||||
- update font styles - ([0b13d4c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b13d4c9e829e5a0c1d2e8be210ae9ba659bbe39)) - Ku6epXBOCTuK
|
||||
- update style, resolve old css leaks issue - ([586c66a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/586c66a159a9a4e30ae414070ffa83f06d9209ed)) - Ku6epXBOCTuK
|
||||
- resolve style issues - ([bbd5532](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bbd5532529257262faa133d4cd5e7dee0f943927)) - Ku6epXBOCTuK
|
||||
- step fields styles - ([8eff9a9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8eff9a94836fa2a6219888a5599a3fa011699d9b)) - Ku6epXBOCTuK
|
||||
- update styles - ([2c70c44](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2c70c445d5ec601945b842663cfbc81222fdc91c)) - Ku6epXBOCTuK
|
||||
- update preview styles - ([6c8f7b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c8f7b2d8b1eaca7f0cee708f0f1e81d0d59e25e)) - Ku6epXBOCTuK
|
||||
- update top bar styles - ([86b1c81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86b1c813e72bc07f7f07e534b54faa4f6d69e142)) - Ku6epXBOCTuK
|
||||
- resolve design issues from refs audit - ([0d95acb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0d95acba8933280b32e54a3f13d014aa464a2e71)) - Ku6epXBOCTuK
|
||||
- remove tile label doubling - ([6959abb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6959abb15926edab7817006a42cfabfa436c04e8)) - Ku6epXBOCTuK
|
||||
- update design with references - ([5093b50](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5093b50f5cfea72f6298e9dd5a2ae934e741b75a)) - Ku6epXBOCTuK
|
||||
- update search flow - first tool search - ([3c1c5b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c1c5b2b3ae6fe8bdf576a70acb6b449edaf0197)) - Ku6epXBOCTuK
|
||||
- some small css fixes - ([5e512f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e512f0bdd34f6329b54f111f81950acbebb2569)) - Ku6epXBOCTuK
|
||||
- get names from categories - ([96b8c00](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96b8c0012988e4586718a11a77419b7752edeb80)) - Ku6epXBOCTuK
|
||||
- base en - ([6787201](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6787201425b87256369003b97df299704ed75975)) - Ku6epXBOCTuK
|
||||
- resolve transform core tools, add tests - ([ebc3c33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ebc3c33c12b58be7413378ff6e44677cf76f1a64)) - Ku6epXBOCTuK
|
||||
- use toolcard as links at list page - ([200e971](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/200e971df503e6ba6a460bdd24f47cd617dbbc7c)) - Ku6epXBOCTuK
|
||||
- update layout - ([4c5b52a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4c5b52a9ad13aadd425e9c5571120f356bf631be)) - Ku6epXBOCTuK
|
||||
- update user input flow (keep aspect ratio) - ([da55328](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/da5532849198d3040a56f71a69fe6af6ae5b988f)) - Ku6epXBOCTuK
|
||||
- resolve loading image error - ([4b70866](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b7086693cfd06ab88d47f22314913aa9326a470)) - Ku6epXBOCTuK
|
||||
#### Refactoring
|
||||
- separate components - ([c4eab38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c4eab388662b927aa5a6527be75350686e903865)) - Ku6epXBOCTuK
|
||||
- add slug to tools - ([931ee27](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/931ee27b056a641b0adec58d0016347cd6afbc18)) - Ku6epXBOCTuK
|
||||
- separate schema preview logic - ([6ab5989](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ab59897d10dd391c549a3c1e6d9df6fc7623288)) - Ku6epXBOCTuK
|
||||
- separate schema fields logic - ([cdb23fe](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cdb23fe1c113f5bcc41228b9508e68a65a2746b6)) - Ku6epXBOCTuK
|
||||
- executor protocol, fallback, debounce - ([0933f1c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0933f1cdeab3998de628eb1a5443399304e088ae)) - Ku6epXBOCTuK
|
||||
- change v1 to import own copy of i18n - ([939038f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/939038ffc02fc618c5e53a8589a803417149f327)) - Ku6epXBOCTuK
|
||||
- update path registry-new to registry - ([75f6891](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/75f6891e7c4285b4a63515b426f88f3fe87bb6ab)) - Ku6epXBOCTuK
|
||||
- set root layout, rename css, fix links in lint rules \ etc - ([e970b47](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e970b477205ea02dc96c67dac19bbc36660aab85)) - Ku6epXBOCTuK
|
||||
- move old libs and components to old folder - ([3834dad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3834dadd03f2946fa679e4cfb737d7cb486e588b)) - Ku6epXBOCTuK
|
||||
- update css vars lint rules and fix them - ([e3d50f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e3d50f2128e5f27c834455f5c11de0dd6364d9fe)) - Ku6epXBOCTuK
|
||||
- fix lint css vars rules - ([2d24f51](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2d24f51fd5bf94408f1ec515575fc700bfd74ae3)) - Ku6epXBOCTuK
|
||||
- isolate old and preview versions, add lint rule - ([e57fadd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e57faddfdef9e75312b5a81df56e6450fb82bff4)) - Ku6epXBOCTuK
|
||||
- update field schema types - ([743ae81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/743ae817ee47b8bf2826ee9876e046babd7d811f)) - Ku6epXBOCTuK
|
||||
- separate schema fields components - ([4a13bfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4a13bfbd1684d095d10f9403bde88359174456c7)) - Ku6epXBOCTuK
|
||||
- separate registry file - ([58e75f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/58e75f23345b956d4068ff16ef90805031eae1c8)) - Ku6epXBOCTuK
|
||||
- move form fields to separate folder - ([6ec3e75](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ec3e75df4dc22728a234dab77c4bf69316ce9c1)) - Ku6epXBOCTuK
|
||||
- move components - ([e9de7a4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9de7a4bf8244a278bb44e8eb84c35db7ee38965)) - Ku6epXBOCTuK
|
||||
- update panel component - ([7e94205](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7e94205866f8c9d25ebb548f3c3fa87f42557e25)) - Ku6epXBOCTuK
|
||||
- update panel component - ([2f758a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f758a88af234fc2ceefca024d6abfdd9ee14608)) - Ku6epXBOCTuK
|
||||
- separate top bar component - ([991fe69](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/991fe69381e7018bb977991fb6ce2ba99c87c53d)) - Ku6epXBOCTuK
|
||||
- update footer component - ([11c4cce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/11c4ccece436aae2081c426d3e31fb272a464904)) - Ku6epXBOCTuK
|
||||
- icon and download button use button component - ([8a6f879](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8a6f87938d9179728e55642119ab575ff9891b93)) - Ku6epXBOCTuK
|
||||
- update pages and refs audit - ([86e7b72](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86e7b7250d5a35eabdd9c9a463ec7999fb7f641a)) - Ku6epXBOCTuK
|
||||
- move old design to separate layout tree - ([f08af41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f08af41568f9694543703fdcc936118d8f20e342)) - Ku6epXBOCTuK
|
||||
- separate tool page for A\B testing - ([d70cbad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d70cbad0d2d804386b2d5d53673553bf65ca323c)) - Ku6epXBOCTuK
|
||||
- add error messages i18n - ([486b99d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/486b99dbb8ec9383de36d2e6cd88c9fec08c7434)) - Ku6epXBOCTuK
|
||||
- use standalone tool run executor - ([1b3bad4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1b3bad46d1be4a04311f46400c078542026315ff)) - Ku6epXBOCTuK
|
||||
- change show mask flow - checkbox in params - ([3a5acfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3a5acfbc452a2a2fc350dc4e803b0846473779c2)) - Ku6epXBOCTuK
|
||||
- separate tool page - ([5560435](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5560435b55cb232b978f4c20a813f3d964e66b79)) - Ku6epXBOCTuK
|
||||
- dropzone update - ([7afd07e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7afd07e8c148a317456ee864685258e40cab484d)) - Ku6epXBOCTuK
|
||||
- remove unused styles - ([a9234d2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a9234d27de7d19f3a0489ebe1da1d310371a4287)) - Ku6epXBOCTuK
|
||||
#### Style
|
||||
- fix formatting - ([3c10147](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c10147ad85650ed9e9f63850fc9581400e2d427)) - Ku6epXBOCTuK
|
||||
- fix prettier ignore\config - ([9dccdd9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9dccdd99a0f7b6ef187627cf8ec4811b648bdd22)) - Ku6epXBOCTuK
|
||||
- format docs markdown - ([64e35f4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/64e35f4fdfedf331e79309c49ddab936b2a263da)) - Ku6epXBOCTuK
|
||||
- fix formatting via prettier - ([ada96b3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ada96b3534a20a2c5c671cef3ca0146ada743afa)) - Ku6epXBOCTuK
|
||||
|
||||
- - -
|
||||
|
||||
Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto).
|
||||
@@ -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,100 @@
|
||||
# Project renamed
|
||||
# easy-png-tools
|
||||
|
||||
"Easy png tools" now named "Easy image tools" and moved to a new domain.
|
||||
Browser-based image utility toolkit — no server, no data upload, everything runs
|
||||
locally in your browser.
|
||||
|
||||
[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
|
||||
|
||||
- **A full set of image tools** across categories: conversion,
|
||||
transparency/alpha, color, channel decomposition, geometry, filters,
|
||||
pixel-property masks, generation, text/watermarks, analysis & verdicts — the
|
||||
current list lives in [`docs/tools-map.md`](docs/tools-map.md)
|
||||
- **Web Worker execution** — heavy operations run off the main thread with
|
||||
automatic fallback to direct calls
|
||||
- **i18n** — full Russian and English localization
|
||||
- **Dark/Light themes** — persisted to localStorage
|
||||
- **Pure TypeScript core** — `lib/core/` operates on
|
||||
`ImageData`/`Uint8ClampedArray` with no DOM dependency
|
||||
- **Fully static** — no backend; the image never leaves your browser. The only
|
||||
third-party request is a Google Analytics page-view counter — it never sees
|
||||
the file or its contents; the boundary and its limits are in
|
||||
`docs/plan-seo.md` §0
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **SvelteKit** (Svelte 5, runes mode) with static adapter — pure static export,
|
||||
no server
|
||||
- **Vite** + **TypeScript** (strict)
|
||||
- **Vitest** for unit tests, **Playwright** for e2e
|
||||
- **ESLint** (incl. local plugins) + Stylelint + Prettier
|
||||
- **pnpm** (required, ^11.20.0)
|
||||
- **Lucide** icons, **IBM Plex** fonts (self-hosted)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [pnpm](https://pnpm.io/) v11.20+
|
||||
|
||||
### Install & Run
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Static output is written to `web/build/`.
|
||||
|
||||
### Commands
|
||||
|
||||
Run from the repository root; `pnpm --dir web …` targets the app package.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # dev server
|
||||
pnpm verify # full fast quality gate — the pre-review gate
|
||||
pnpm build # static production export → web/build/
|
||||
```
|
||||
|
||||
For the full command list and the "which checks to run for which change" matrix,
|
||||
see [`AGENTS.md`](AGENTS.md) and
|
||||
[`docs/quality-gates.md`](docs/quality-gates.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything is built around the **two-layer registry** (`web/src/lib/registry/`):
|
||||
a tool is a self-contained implementation `{id, schema, input, result, run}`
|
||||
(`registry/tools/`), and a page is `{slug, title, description, category, steps}`
|
||||
(`registry/pages/`) that names the address and the tools to run — one tool can
|
||||
back several pages. Pages and forms are generated from it, and all execution
|
||||
goes through a single `executor.execute` entry point.
|
||||
|
||||
Core image processing lives in `web/src/lib/core/` and operates directly on
|
||||
`ImageData`, with no browser API dependencies.
|
||||
|
||||
Layering, data flow, and the recipe for adding a tool or a field kind:
|
||||
[`docs/architecture.md`](docs/architecture.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [`docs/roadmap.md`](docs/roadmap.md) for phases and
|
||||
[`docs/plan-platform.md`](docs/plan-platform.md) for the API/PWA direction. The
|
||||
current focus is SEO/GEO ([`docs/plan-seo.md`](docs/plan-seo.md)) plus the
|
||||
product tasks in [`docs/backlog.md`](docs/backlog.md).
|
||||
|
||||
## For AI agents
|
||||
|
||||
Read [`AGENTS.md`](AGENTS.md) first — it is the mandatory short contract.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
branch_whitelist = ["main"]
|
||||
ignore_merge_commits = true
|
||||
tag_prefix = "v"
|
||||
|
||||
pre_bump_hooks = ["node scripts/sync-version.mjs {{version}}"]
|
||||
|
||||
[changelog]
|
||||
path = "CHANGELOG.md"
|
||||
template = "remote"
|
||||
remote = "github.com"
|
||||
repository = "easy-png-tools"
|
||||
owner = "Ku6epXBOCTuK"
|
||||
|
||||
[commit_types]
|
||||
build = { omit_from_changelog = true }
|
||||
chore = { omit_from_changelog = true }
|
||||
ci = { omit_from_changelog = true }
|
||||
docs = { omit_from_changelog = true }
|
||||
test = { omit_from_changelog = true }
|
||||
@@ -0,0 +1,71 @@
|
||||
# Документация проекта
|
||||
|
||||
> Статус: **active**. Индекс документов. Обязательный краткий контракт — в
|
||||
> `AGENTS.md`, подробные правила — в самих документах.
|
||||
|
||||
## Порядок чтения
|
||||
|
||||
1. `AGENTS.md` — команды, инварианты и карта «что читать под задачу».
|
||||
2. Этот файл — выбрать актуальный документ по теме.
|
||||
3. Соответствующий план или checklist темы.
|
||||
4. Исходники, конфигурация и тесты рядом с изменяемым кодом.
|
||||
|
||||
## Активные документы
|
||||
|
||||
| Документ | Когда читать | Статус |
|
||||
| ---------------------------------- | -------------------------------------------------- | ------ |
|
||||
| `AGENTS.md` | Всегда: контракт агента | active |
|
||||
| `docs/README.md` | Индекс документов, правила ведения документации | active |
|
||||
| `docs/architecture.md` | Слои, поток данных, новый инструмент/поле/маршрут | active |
|
||||
| `docs/quality-gates.md` | Какие проверки запускать, пороги, coverage, CI | active |
|
||||
| `docs/decisions.md` | Правка самих гейтов; чтение CI после пуша | active |
|
||||
| `docs/agent-workflow.md` | Порядок работы, нагрузка на машину, протокол ревью | active |
|
||||
| `docs/testing-strategy.md` | Правка e2e: locator-ы, fixtures, browser matrix | active |
|
||||
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
|
||||
| `docs/roadmap.md` | Направления развития, фазы 1–11 | active |
|
||||
| `docs/tools-map.md` | Актуальный список инструментов против реестра | active |
|
||||
| `docs/tools-audit.md` | Сырые UX/UI-находки по инструментам (вход аудита) | active |
|
||||
| `docs/plan-fields-audit-fixes.md` | Разбивка аудита на волны правок | draft |
|
||||
| `docs/plan-seo.md` | SEO/GEO-план | active |
|
||||
| `docs/plan-platform.md` | API/PWA/шрифты-план | active |
|
||||
| `docs/plan-domain.local.md` | Чек-лист переименования бренда и покупки домена | active |
|
||||
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
|
||||
| `web/eslint-plugins/README.md` | Правка lint-правил, токенов и их конфигов | active |
|
||||
| `docs/archive/README.md` | Архивирование и удаление старых документов | active |
|
||||
|
||||
`docs/plan-domain.local.md` **исключён из git** через `.gitignore`: выбранное
|
||||
имя домена не должно попасть в историю до покупки. В клоне файла нет — это
|
||||
ожидаемо, и ссылки на него в `plan-seo.md` и `backlog.md` не разрешаются. Строка
|
||||
в таблице оставлена, чтобы задачу R1/R2 можно было найти по индексу; сам
|
||||
чек-лист лежит только на машине разработчика.
|
||||
|
||||
## Правила
|
||||
|
||||
- У каждой темы ровно один владелец: остальные документы ссылаются на него, а не
|
||||
пересказывают. Новый документ создаётся, только если темы нет в существующем.
|
||||
- У документа есть статус (`draft`, `active`, `completed`, `archived`) и
|
||||
понятная область применения. Ссылки на конкретный файл или команду проверяются
|
||||
при изменении источника истины.
|
||||
- Подробные материалы живут в `docs/`, а не в корне. Не создавать отдельные
|
||||
`docs/commands.md` или `docs/known-issues.md`: команды принадлежат
|
||||
`quality-gates`, известные проблемы — backlog и checklist. `README.md` в корне
|
||||
остаётся кратким обзором продукта, а не вторым руководством агента.
|
||||
- `docs/backlog.md` содержит только открытые задачи: выполненные удаляются, а
|
||||
критичные для проекта факты из них переносятся в тематический документ.
|
||||
- `docs/archive/` — временная историческая справка, не источник критериев
|
||||
приёмки; срок хранения 30 дней, файлы восстановимы из git history.
|
||||
Единственный документ в архиве со статусом `active` —
|
||||
`docs/archive/README.md`: он описывает процедуру архивирования, а не архивный
|
||||
материал, поэтому не подлежит 30-дневному удалению.
|
||||
- Ссылаясь на инструмент, сверяться с реестром: внутреннее имя (`id`) — в
|
||||
`web/src/lib/registry/tools/`, адрес и имя страницы (`slug`) — в
|
||||
`web/src/lib/registry/pages/`; исторические названия в старых планах не
|
||||
действуют (`docs/architecture.md`, раздел 3).
|
||||
- Поиск по `docs/` задевает `docs/archive/`: там вынесены завершённые планы и
|
||||
исследования, поэтому grep по `docs/` даёт больше шума, чем пользы. Для правок
|
||||
production-кода архив не нужен: grep по `docs/*.md` без `archive/`.
|
||||
- Проверка ссылок в документации не автоматизирована; ожидаемо неразрешимыми
|
||||
считаются ссылки на `docs/plan-domain.local.md` (файл вне git). Всё остальное
|
||||
неразрешимое — ошибка.
|
||||
- Протокол «остановиться и показать изменения» — `docs/agent-workflow.md`,
|
||||
раздел 6.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Workflow для агентов
|
||||
|
||||
> Статус: **active**. Порядок исследования, изменения и проверки. Обязательные
|
||||
> инварианты — в `AGENTS.md`, этот документ не заменяет source of truth
|
||||
> подсистемы.
|
||||
|
||||
## 1. Подготовка к задаче
|
||||
|
||||
1. Прочитать `AGENTS.md` и выбрать документ по карте «что читать под задачу».
|
||||
2. Найти релевантный активный план, backlog или checklist.
|
||||
3. Проверить `git status`: не перезаписывать чужие незакоммиченные изменения.
|
||||
4. Определить границу изменения: production UI, shared-модуль или инфраструктура
|
||||
— `AGENTS.md`, раздел «Границы изменений». Изменение shared-кода требует
|
||||
проверки production-потребителей.
|
||||
5. Не добавлять исключения линтера ради отдельных модулей: осознанные
|
||||
отступления живут в конфиге правила (`allowPaths`, `allowWords`,
|
||||
`ignoreMissingPatterns`, `allowDefaultProject` —
|
||||
`web/eslint-plugins/README.md`), а не в самих проверяемых файлах.
|
||||
|
||||
## 2. Порядок изменения
|
||||
|
||||
- Сначала исправляются тесты или контракт, который фиксирует неверное поведение,
|
||||
затем production-код — если это не отдельная продуктовая задача.
|
||||
- Не начинать следующий этап плана в том же шаге: изменение атомарное, ревью
|
||||
идёт между шагами (раздел 6).
|
||||
|
||||
## 3. Проверки
|
||||
|
||||
Матрица «изменение → минимальный набор проверок», состав `pnpm verify`, политика
|
||||
warnings и чтение CI — в `docs/quality-gates.md`. Здесь только то, что относится
|
||||
к работе агента.
|
||||
|
||||
`lint:all` роняет gate на любых ESLint warnings (`--max-warnings=0`).
|
||||
Единственное, что он пропускает, — unused-token сообщения (неблокирующие).
|
||||
Поэтому «команда прошла» не равно «нечего чинить»: нужно отдельно показать число
|
||||
errors, warnings и token warnings.
|
||||
|
||||
E2E запускается отдельно от быстрых проверок, потому что поднимает build и
|
||||
статический сервер. Для UI-изменений сначала выбирается минимальный
|
||||
пользовательский поток, затем при необходимости расширяется smoke.
|
||||
|
||||
### Нагрузка на машину
|
||||
|
||||
Агент работает на компьютере пользователя, поэтому тяжёлые прогоны не
|
||||
запускаются «по умолчанию»:
|
||||
|
||||
- Playwright-прогоны запускаются только по явной команде пользователя: один
|
||||
прогон за раз, без циклов повторов и без фонового запуска.
|
||||
- Локально `workers: 1` (`web/playwright.config.ts`). Browser matrix проходит
|
||||
медленно, зато не забирает память и не мешает работе пользователя. Скорость
|
||||
проверки уступает отзывчивости компьютера.
|
||||
- Нельзя запускать тяжёлый прогон параллельно с другим прогоном или в момент,
|
||||
когда пользователь работает с приложением.
|
||||
- Диагностика флаков идёт от меньшего к большему: один spec, один проект,
|
||||
`--workers=1`, и только затем полный прогон.
|
||||
- Таймауты не подбираются под конкретную машину. Если проверка падает из-за
|
||||
нехватки ресурсов, это фиксируется как ограничение окружения, а не лечится
|
||||
увеличением `expect.timeout` (обоснование — `docs/decisions.md`, раздел 7).
|
||||
- При необходимости повторного прогона сначала спрашивается пользователь.
|
||||
|
||||
## 4. Работа с тестами
|
||||
|
||||
Политика locator-ов, запрещённые зависимости, fixtures, browser matrix и
|
||||
`test.fixme` — в `docs/testing-strategy.md`. Здесь только рабочая рутина:
|
||||
|
||||
- Поведенческий тест проверяет пользовательский контракт, а не текущую
|
||||
реализацию.
|
||||
- Спецификация вызывает общие операции из `web/e2e/helpers/page.ts`, а не
|
||||
повторяет raw-селекторы.
|
||||
- Не фиксировать абсолютное число инструментов, если проверка не связана с этим
|
||||
инвариантом.
|
||||
- Новые fixtures должны быть воспроизводимыми и не требовать ручного создания
|
||||
больших бинарных файлов.
|
||||
|
||||
## 5. Документация
|
||||
|
||||
Порядок работы с документами и карта источников истины — `docs/README.md`.
|
||||
Архивирование — `docs/archive/README.md`.
|
||||
|
||||
## 6. Протокол ревью между шагами
|
||||
|
||||
После каждого шага плана агент:
|
||||
|
||||
1. останавливается и не начинает следующий этап;
|
||||
2. перечисляет изменённые файлы и принятые решения;
|
||||
3. указывает выполненные проверки и их фактический результат;
|
||||
4. отдельно перечисляет warnings, fixme, долги и вопросы;
|
||||
5. ждёт подтверждения на продолжение.
|
||||
|
||||
Коммит и пуш делает разработчик. Следующий шаг начинается только после ревью
|
||||
текущего изменения; после пуша результат CI читается по процедуре из
|
||||
`docs/decisions.md`, раздел 10.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Архитектура
|
||||
|
||||
> Статус: **active**. Описание текущего кода: границы модулей, поток данных от
|
||||
> реестра до скачивания, точки расширения. Канонический источник истины — сам
|
||||
> код; этот документ объясняет, **куда смотреть и что с чем связано**, и не
|
||||
> дублирует сигнатуры.
|
||||
|
||||
## 1. Слои
|
||||
|
||||
```txt
|
||||
web/src/lib/
|
||||
registry/ # источник истины: инструменты и страницы
|
||||
registry/tools/ # Tool: реализации (id), без адресов и текстов
|
||||
registry/pages/ # Page: адрес, тексты, категория, список шагов
|
||||
registry-schema.ts # декларативная схема параметров + санитизация дефолтов
|
||||
catalog.ts # группировка страниц и счётчики для /list-tools
|
||||
categories.ts # id/названия категорий
|
||||
tool-icons.ts # slug страницы → иконка
|
||||
core/ # операции над ImageData; без DOM, кроме io.ts/domText.ts
|
||||
executor/ # единственная точка исполнения инструментов
|
||||
i18n/ # словари ru/en, t(), состояние локали
|
||||
components/ # UI по слоям: ui/ примитивы, layout/ каркас,
|
||||
# catalog/ каталог, schema/ view инструмента,
|
||||
# fields/ поля (+ fields/schema/ контролы схемы),
|
||||
# display/ типографика и карточки, feedback/ состояния
|
||||
zip.ts # сборка мультифайлового результата (1 → many)
|
||||
theme.svelte.ts # light/dark + localStorage
|
||||
web/src/routes/
|
||||
+page.svelte # главная
|
||||
list-tools/ # каталог
|
||||
tools/[slug]/ # страница инструмента (адрес = slug страницы)
|
||||
kit/ # витрина компонентов (production, но исключена из i18n-гейта)
|
||||
```
|
||||
|
||||
Правило зависимостей: `components/**` и `routes/**` знают про `registry/**`,
|
||||
`executor/**` и `core/**`; `core/**` не знает ни о ком; `registry/**` описывает
|
||||
данные и знает только `core/**` и `registry-schema.ts`. Нарушение этого порядка
|
||||
— повод не заводить импорт, а инвертировать зависимость.
|
||||
|
||||
Исключение из «core без DOM» — `core/io.ts` (декодирование файла, кодирование,
|
||||
скачивание) и `core/domText.ts` (canvas-рендер текста и эмодзи). Они трогают
|
||||
`document`, `createImageBitmap` и `HTMLCanvasElement`, поэтому не работают в
|
||||
node-Vitest и исключены из coverage-гейта (`docs/quality-gates.md`, раздел
|
||||
Coverage). Всё остальное в `core/**` — чистые функции над `ImageData`.
|
||||
|
||||
### Чего в коде нет
|
||||
|
||||
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
|
||||
скачала результат. Отсутствуют:
|
||||
|
||||
- **Пайплайн / цепочки шагов** — реализованы: модуль шагов
|
||||
`web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps),
|
||||
именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage
|
||||
`chains:named`). Страница инструмента всегда открывается чистой; при первом
|
||||
структурном изменении (добавлен шаг, заменён первый инструмент) цепочка
|
||||
автоматически становится именованной (имя — дата/время) и маршрут бесшовно
|
||||
меняется на `/pipeline?id=<id>` (передача исходника через handoff-стор,
|
||||
replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/
|
||||
создать). Легаси-автосейв `pipeline:<slug>` снят без миграции (ключи просто
|
||||
перестают читаться). Экспортный JSON-формат не устоялся и может меняться до
|
||||
фазы 11 `docs/roadmap.md`.
|
||||
- **Разделения страниц, которые делят один инструмент** — сводит все
|
||||
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
|
||||
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
|
||||
на практике не ссылается ни одна.
|
||||
|
||||
Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по
|
||||
пайплайну — `docs/roadmap.md`.
|
||||
|
||||
## 2. Поток данных
|
||||
|
||||
```txt
|
||||
registry/{tools,pages}/*.ts
|
||||
│
|
||||
├─► catalog.ts ──► routes/list-tools (каталог страниц, поиск, фильтры)
|
||||
│
|
||||
└─► routes/tools/[slug]/+page.svelte
|
||||
│ getPageBySlug(slug) → Page, getTool(steps[0].id) → Tool
|
||||
▼
|
||||
SchemaToolView.svelte оркестратор страницы
|
||||
│ schema = tool.schema, тексты и имя файла — от page
|
||||
├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол)
|
||||
├─► SchemaSourceTile/SchemaTextSource.svelte вход
|
||||
└─► SchemaPreview + schema-preview-model.ts вид превью
|
||||
│ buildSchemaPreviewModel(input) → { sourceValue, resultValue, formatValue, hasResult }
|
||||
▼
|
||||
executor.execute(ctx) единственная точка исполнения
|
||||
│ сериализация параметров → worker → worker-handler → tool.run(ctx)
|
||||
▼
|
||||
core/* пиксели
|
||||
│
|
||||
▼
|
||||
SchemaResultTile / SchemaTextResult + SchemaActions (download/copy)
|
||||
│ кодирование по Tool.output (mime/ext/quality)
|
||||
└─► zip.ts, если result === "files"
|
||||
```
|
||||
|
||||
Ключевые решения:
|
||||
|
||||
- **Реестр — единственный источник истины.** Страница и форма строятся из
|
||||
записей; новый инструмент = новая запись `Tool` + функция `run` и новая
|
||||
страница `Page` на него.
|
||||
- **Исполнение идёт только через `executor.execute`.** Компоненты не зовут
|
||||
`tool.run` напрямую — иначе нельзя перенести тяжёлое в worker/wasm без правки
|
||||
вызывающего кода.
|
||||
- **Форма параметров не знает про инструмент.** Она рендерится из
|
||||
`registry-schema.ts`, поэтому добавление вида поля не трогает страницы.
|
||||
- **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая
|
||||
функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод.
|
||||
|
||||
## 3. Инструмент и страница
|
||||
|
||||
Реестр двухслойный: реализация и то, что видит пользователь, лежат в разных
|
||||
файлах. `Tool` (`web/src/lib/registry/types.ts`, записи в
|
||||
`registry/tools/<group>.ts`):
|
||||
|
||||
| Поле | Смысл |
|
||||
| ---------- | ------------------------------------------------------------------------------ |
|
||||
| `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker |
|
||||
| `schema` | `toolSchema({ fields, layout?, meta? })` |
|
||||
| `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе |
|
||||
| `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
|
||||
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
|
||||
| `output`? | mime/ext/quality для скачивания (не у всех) |
|
||||
| `runMask`? | ч/б превью «что изменится»; тогл на тайле результата, в цепочку не идёт |
|
||||
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
|
||||
|
||||
`Page` (записи в `registry/pages/<group>.ts`):
|
||||
|
||||
| Поле | Смысл |
|
||||
| ------------- | ---------------------------------------------------------------------- |
|
||||
| `slug` | уникальный адрес `/tools/<slug>`, имя скачиваемого файла и ключ иконки |
|
||||
| `title` | EN-строка страницы, переводится через `pages.<slug>.title` |
|
||||
| `description` | EN-строка страницы, переводится через `pages.<slug>.description` |
|
||||
| `category` | id из `categories.ts`; по нему каталог группирует страницы |
|
||||
| `steps` | упорядоченный список шагов `{ id, params? }` — ссылки на инструменты |
|
||||
|
||||
Кто по чему адресуется: `slug` — маршрут, имя файла, `TOOL_ICONS`, поиск по
|
||||
каталогу; `id` — ключ `tools.<id>.*` (подписи полей, `options`, `results`),
|
||||
`toolId` worker-протокола и запись пайплайна. Модель допускает, что один
|
||||
инструмент обслуживает несколько страниц, а страница состоит из нескольких
|
||||
инструментов; на практике пока каждая страница ссылается на свой инструмент, а
|
||||
исполняется первый (и единственный) шаг.
|
||||
|
||||
Инструмент работает с любым поддержанным форматом, поэтому png-интент живёт
|
||||
только в `slug`: `id: "crop"` → страница `slug: "crop-png"` → `crop-png.png`.
|
||||
Правило имени `id`: снять суффикс `-png` и в середине имени (`png-to-base64` →
|
||||
`to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`). Правило
|
||||
относится только к `id`: у `slug` своя история и SEO-значение, поэтому
|
||||
переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя
|
||||
файла, ключ `pages.*`, иконку и запись в `docs/tools-map.md`. Слаги, сложившиеся
|
||||
до разделения реестра, правилу не соответствуют (`convert-png-to-jpg`,
|
||||
`change-png-opacity`, `png-is-grayscale`, `verify-is-png` и другие) — это не
|
||||
ошибка, менять их без SEO-решения нельзя. Форму slug проверяет
|
||||
`registry.test.ts` (только форма, не смысл).
|
||||
|
||||
Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых
|
||||
случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS`
|
||||
и `PAGES` собираются в `registry/index.ts` из восьми групп; `getTool(id)` и
|
||||
`getPageBySlug(slug)` — способы достать запись, `registry.test.ts` проверяет
|
||||
уникальность обоих имён, существование инструмента под каждым шагом и то, что
|
||||
шаг ровно один.
|
||||
|
||||
## 4. Схема параметров
|
||||
|
||||
`registry-schema.ts` — единственное место, где описаны виды полей.
|
||||
|
||||
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
|
||||
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
|
||||
`font-style`, `plate`, `gradient`.
|
||||
- **Фабрика `field.*`** создаёт `Field<T>`; UI-контрол подбирается в
|
||||
`SchemaFields.svelte` по `spec.kind` через карту `FIELDS`.
|
||||
- **`schema.layout`** группирует поля (`resolveLayoutGroups`); раскладка
|
||||
разделов — единственное место, где живёт порядок и группировка.
|
||||
- **Значения по умолчанию** даёт `defaultSchemaParams`; `applySourceDefaults`
|
||||
подставляет размеры текущего source; `sanitizeSchemaParams` приводит мусор к
|
||||
допустимому виду. Правка поведения полей идёт здесь, а не в компоненте.
|
||||
|
||||
Новый вид поля требует: спека + фабрика `field.*` → запись в `fieldSpecs` →
|
||||
контрол (`components/fields/schema/`) → ключ в `FIELDS` → дефолт и санитизация →
|
||||
i18n-подписи → тест в `registry-schema.test.ts`.
|
||||
|
||||
## 5. Исполнение
|
||||
|
||||
`executor/executor.ts` — фабрика `createExecutor({ workerFactory? })` плюс
|
||||
готовый экспорт `execute`. Контракт асинхронный (`Promise`), параметры
|
||||
сериализуются по `executor/protocol.ts`, исполнение уходит в
|
||||
`executor.worker.ts`; `worker-handler.ts` разбирает сообщение и зовёт `run`.
|
||||
Если worker недоступен — прямой вызов. UI импортирует `execute` из
|
||||
`executor/index.ts`.
|
||||
|
||||
Отсюда следует: подключение wasm позже означает замену реализации **внутри**
|
||||
исполнителя, а не правку компонентов.
|
||||
|
||||
## 6. i18n
|
||||
|
||||
Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с
|
||||
`LOCALES`/`BASE_LOCALE`, `t.ts` с `t()`, `locale.svelte.ts` с состоянием
|
||||
локали). Пользовательский текст живёт только в словарях и читается через `t()`.
|
||||
Списки ключей и допустимых атрибутов для i18n-правил линтера лежат не здесь, а в
|
||||
`web/eslint-plugins/i18n/lists.js`. Секции разделены по слоям:
|
||||
`pages.<slug>.title` и `pages.<slug>.description` — тексты страницы (в базовой
|
||||
локали секция пустая, EN берётся из реестра),
|
||||
`tools.<id>.options/params/results` — подписи полей и тексты вердиктов
|
||||
инструмента, `fields.*`/`groups.*` — общие подписи схемы. Инварианты паритета
|
||||
ключей, плейсхолдеров и запрет хардкода проверяются линтером, детали —
|
||||
`web/eslint-plugins/README.md`.
|
||||
|
||||
## 7. Точки расширения
|
||||
|
||||
| Задача | Где править |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) |
|
||||
| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен |
|
||||
| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются |
|
||||
| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `tool-icons.ts` + `docs/tools-map.md` + хардкод slug в тестах и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи |
|
||||
| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
|
||||
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
|
||||
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
|
||||
| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу |
|
||||
| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов |
|
||||
| Мультифайловый результат | `result: "files"` + `zip.ts` |
|
||||
|
||||
## 8. Рецепт: добавить инструмент
|
||||
|
||||
1. **Найти соседей.** Поиск по ключу в `registry/tools/<group>.ts` и по всему
|
||||
`web/src/` — не заводить дублирующую константу, тип или утилиту.
|
||||
2. **Выбрать группу** (`geometry`/`alpha`/`color`/`convert`/`filters`/
|
||||
`generate`/`text`/`analyze`) и положить инструмент в её экспорт; при новой
|
||||
категории — сначала `categories.ts`.
|
||||
3. **Задать `id`** по правилу раздела 3: без png-интента (`crop`, `to-jpg`,
|
||||
`from-base64`, `is-grayscale`).
|
||||
4. **Описать схему** через `field.*`, задать `layout`, если поля нужно
|
||||
сгруппировать. Качество кодирования (`quality`) — не поле страницы, а
|
||||
настройка скачивания (`docs/plan-seo.md` §4, S1f).
|
||||
5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в
|
||||
`core/`, без DOM.
|
||||
6. **Добавить страницу** в `registry/pages/<group>.ts`: `slug` с адресным
|
||||
png-интентом (`crop-png`), тексты EN, `category`, `steps: [{ id }]`.
|
||||
7. **Проверить обязательные контракты**: инструмент попадает в `TOOLS`, страница
|
||||
в `PAGES`, `/tools/<slug>` prerender-ится, `input`/`result` согласованы с
|
||||
тем, что делает `run`.
|
||||
8. **Добавить i18n-ключи во все локали** сразу: `pages.<slug>.title` и
|
||||
`pages.<slug>.description`, при наличии опций и вердиктов — `tools.<id>`.
|
||||
9. **Тесты:** unit — в `registry/registry.test.ts` или рядом с `core`-функцией;
|
||||
e2e-smoke — существующий spec по типу `input`/`result` (правила —
|
||||
`docs/testing-strategy.md`).
|
||||
10. **Обновить `docs/tools-map.md`:** новая страница обязательна в разделе
|
||||
«Реализовано», формат строки — в шапке раздела. Карта перечисляет **`slug`
|
||||
страниц** из `registry/pages/`, то есть адреса. Проверяет
|
||||
`web/src/lib/registry/tools-map-doc.test.ts`: slug без страницы и страница
|
||||
без записи в карте роняют тест; разделы «Можно добавить» и «Идеи»
|
||||
проверяются в обратную сторону — реализованный slug в плане тоже роняет
|
||||
тест, поэтому при переезде инструмента из плана в реестр его вынимают
|
||||
оттуда. Обоснование и границы автоматизации — `docs/decisions.md`, раздел 4.
|
||||
|
||||
Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для
|
||||
инструмента, меняющего пользовательский поток, — e2e (см. раздел 3
|
||||
`docs/quality-gates.md`).
|
||||
@@ -0,0 +1,59 @@
|
||||
# Архив документов
|
||||
|
||||
> Статус: **active**. Здесь хранятся завершённые, отменённые и явно устаревшие
|
||||
> документы как временная историческая справка. Этот README — единственный
|
||||
> документ архива со статусом `active`: он описывает процедуру, а не архивный
|
||||
> материал, поэтому не подлежит 30-дневному удалению.
|
||||
|
||||
## Назначение
|
||||
|
||||
Архив помогает быстро посмотреть, какое решение принималось раньше и почему. Он
|
||||
не является источником текущих требований, критериев приёмки или команды. Для
|
||||
актуальных правил используются `AGENTS.md`, `docs/README.md` и активные планы.
|
||||
|
||||
Если решение снова понадобится, его можно восстановить из git history или
|
||||
создать новый активный документ: **архивные файлы удаляются, а не хранятся
|
||||
вечно.** Удалённые планы восстановимы через
|
||||
`git log --diff-filter=D -- name-only -- docs/archive/` и
|
||||
`git show <commit>^:<path>`.
|
||||
|
||||
## Обязательные метаданные
|
||||
|
||||
При переносе документа в архив добавить в начало:
|
||||
|
||||
```text
|
||||
> Статус: archived
|
||||
> Архивирован: YYYY-MM-DD
|
||||
> Удалить после: YYYY-MM-DD
|
||||
> Причина: краткое объяснение, почему документ больше не активен
|
||||
> Использование: только историческая справка
|
||||
```
|
||||
|
||||
Дата `Удалить после` — дата архивирования плюс 30 дней.
|
||||
|
||||
## Порядок архивирования
|
||||
|
||||
1. Убедиться, что документ действительно завершён, отменён или заменён более
|
||||
актуальным источником.
|
||||
2. Убрать из него ссылки на несуществующие пути и артефакты прошлых волн.
|
||||
3. Добавить обязательные метаданные.
|
||||
4. Переместить файл в `docs/archive/`.
|
||||
5. Найти оставшиеся входящие ссылки:
|
||||
`git grep -n "<filename>" -- 'docs/*.md' AGENTS.md README.md web/eslint-plugins/README.md`.
|
||||
Шаг 4 ломает их, и агент без этого шага напишет в новом документе ссылку на
|
||||
удалённый файл. Ожидаемо неразрешимыми остаются ссылки на
|
||||
`docs/plan-domain.local.md` — он вне git, это не ошибка (`docs/README.md`,
|
||||
раздел «Правила»).
|
||||
6. Не менять архивный документ под новые требования.
|
||||
|
||||
## Удаление
|
||||
|
||||
Через 30 дней после архивирования:
|
||||
|
||||
- проверить, что документ не нужен для текущей работы;
|
||||
- найти и удалить оставшиеся ссылки на него из активных документов;
|
||||
- удалить файл из `docs/archive/`.
|
||||
|
||||
До истечения срока архивный файл не превращается обратно в live-документ: если
|
||||
он снова актуален, создаётся новый документ или возвращается версия из git
|
||||
истории с новым статусом и датами.
|
||||
@@ -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,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,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,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,268 @@
|
||||
# Backlog
|
||||
|
||||
> Статус: **active**. Открытые задачи. Подробные планы, разбор решений и сырые
|
||||
> находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
|
||||
> `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
|
||||
>
|
||||
> Все пункты — чекбоксы; нумерация «Идея N» в тексте сквозная по документу и
|
||||
> сохраняется ради перекрёстных ссылок («идея 4 зависит от идеи 3»). Оценки
|
||||
> (S/M/L) — оценка труда, не приоритет.
|
||||
|
||||
## Pipeline
|
||||
|
||||
- [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
|
||||
но он работает с любыми картинками, наверное надо убрать; в заголовке
|
||||
страницы — возможно, надо указать все форматы (связано с SEO)
|
||||
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
|
||||
поиском, фильтром возможных инструментов и т.д.
|
||||
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
|
||||
режима aligned\normal
|
||||
- [ ] переделать визуал toggle компонента (которым режим переключается)
|
||||
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
|
||||
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
|
||||
сохранённого chain по каждому файлу, сборка результата обратно в zip для
|
||||
скачивания. Зависит от идеи 3 (выбор цепочки) и от экспортного формата
|
||||
pipeline JSON; прогресс и ошибки — пофайлово.
|
||||
- [ ] **Идея 15. Набор файлов в пайплайне: 1 → many не должен быть
|
||||
терминальным** — оценка L. **Сейчас** `execute` принимает один
|
||||
`source: PixelImage | undefined` и возвращает один `ToolResult`, а
|
||||
`SchemaToolView` раскладывает вывод в `result` / `fileResult` /
|
||||
`textResult` по `resultKind` — поэтому шаг с `result: "files"` упирается в
|
||||
конец цепочки. **Направление:** следующий инструмент применяется **ко всем
|
||||
картинкам** предыдущего результата (fan-out), а many → 1 (вход = архив или
|
||||
несколько файлов) — обратная сторона того же контракта. Терминальными
|
||||
остаются только результаты, которые не картинка (text, verdict). Что
|
||||
обдумать: - где проходит граница: один вызов `run(ctx)` на файл (имя части
|
||||
склеивается в `FileResult.files`) или батч целиком на стороне исполнителя;
|
||||
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
|
||||
прогресс и отмена; - частичные ошибки: один битый файл не должен ронять
|
||||
весь шаг — нужен per-file отчёт, а не один `ToolError` на шаг; - имена
|
||||
частей на каждом шаге и Download для набора (сейчас zip по `page.slug`,
|
||||
`SchemaToolView.download()`); - обратная сторона: распаковка zip в
|
||||
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
|
||||
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
|
||||
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
|
||||
|
||||
## Инструменты
|
||||
|
||||
- [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с
|
||||
такими делать вообще? оставить как seo? можно ли под капотом использовать
|
||||
другой инструмент с хардкодед настройками?
|
||||
- [ ] qr code to text, text to qr code
|
||||
- [ ] bar code to text, text to bar code
|
||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||
выделенной области
|
||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||
какой размер - 112\56\28px или 72\36\18)
|
||||
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
|
||||
файлов, посмотреть реализацию в
|
||||
[twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
|
||||
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
||||
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
||||
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
||||
паспортом, семейства с кириллицей)
|
||||
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
|
||||
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
|
||||
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
|
||||
инструмента), а сама модель: `output` с mime/ext — свойство `Tool`, а
|
||||
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
|
||||
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
|
||||
вывода
|
||||
- [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке
|
||||
Download** — оценка M/L. Решение и продуктовое обоснование:
|
||||
`docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет
|
||||
существующие convert-страницы — `docs/plan-seo.md` §5. Селектор формата и
|
||||
качества в Download уже есть; «сжатие до целевого КБ» реализовано
|
||||
(`fitWithinBytes` в `web/src/lib/core/io.ts`: бинарный поиск по quality
|
||||
для lossy, по k квантования для PNG; для BMP — предупреждение, что лимит
|
||||
не применим). Что осталось: нужна ли отдельная страница под интент «сжать
|
||||
до N КБ» — открытый вопрос с вордстатом, имя не выбрано
|
||||
(`docs/plan-seo.md` §5). Проработать вместе с достройкой download-флоу.
|
||||
- [ ] **S1g под вопросом** — исходная формулировка (`docs/plan-seo.md` §4): на
|
||||
convert-страницах блокировать в меню Download формат исходника, чтобы
|
||||
`convert-png-to-jpg` не сохранял в png. Против: пользователь может
|
||||
захотеть сохранить обработанный PNG или JPEG с другим качеством — блок
|
||||
мешает легальному сценарию. Решение не принято; перед реализацией
|
||||
переобдумать.
|
||||
- [ ] **Лимит КБ: сигнал о недостижимости** — если даже минимальное качество /
|
||||
k=2 не влезает в лимит, файл скачивается молча с превышением. Нужно:
|
||||
`fitWithinBytes` (`web/src/lib/core/io.ts`) возвращает также достигнутый
|
||||
размер, UI показывает warning «не удалось ужаться до N КБ, получилось M».
|
||||
- [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не
|
||||
виден, пока меню Download закрыто: пользователь может забыть про него и
|
||||
случайно сжать следующие файлы. Нужен постоянный индикатор (значок/
|
||||
подпись на кнопке или рядом). Связанный вопрос персистентности: лимит —
|
||||
`$state` в `SchemaToolView.svelte`, при переходе на другой инструмент
|
||||
сбрасывается в `init()` и в localStorage не пишется (в отличие от шагов
|
||||
цепочки). Это безопасное поведение по умолчанию, но стоит осознанно
|
||||
решить: оставить сброс при смене страницы или сохранять лимит (per-tool?
|
||||
глобально?) вместе с цепочкой.
|
||||
- [ ] **Лимит КБ/качество: превью итогового результата** — обдумать UI/UX: при
|
||||
сжатии по размеру или низкому качеству дать возможность посмотреть
|
||||
итоговый файл до скачивания — при сильном сжатии (низкий quality, грубое
|
||||
квантование в PNG) искажения могут быть настолько заметны, что
|
||||
пользователь откажется от варианта и выберет другой формат/лимит. Сейчас
|
||||
превью показывает результат инструмента до re-encode, а сжатая версия
|
||||
существует только внутри `fitWithinBytes`/`encode` в момент скачивания.
|
||||
Открытые вопросы: превью на лету (дорого — encode на каждый ввод) или по
|
||||
кнопке «предпросмотр»; где показывать (оверлей/замена тайла результата);
|
||||
связь с идеей 14 (отображение маски) и пунктом про зум превью выше.
|
||||
- [ ] **Идея 7. Страница `watermark-image-png` (водяной знак картинкой)** —
|
||||
оценка M. Нужна overlay-механика: второй источник изображения
|
||||
(getOverlay/store), параметры scale/opacity/position/margin/aspect.
|
||||
Решается вместе с вопросом, как второй источник картинки вписывается в
|
||||
schema-driven preview (`SchemaToolView`).
|
||||
- [ ] **Идея 8. `png-info` (детальная информация о PNG)** — оценка M/L.
|
||||
Отдельный райз: exif-теги, редактирование, структурированный вывод; нужен
|
||||
свой отдельный случай в UI, не «ещё один text/verdict». Возможно нужна
|
||||
вообще отдельная страница, несовместимая с другими инструментами: 1) она
|
||||
работает не с изображением, а с метаданными из файла, 2) она не может быть
|
||||
частью цепочки, т.к. результат не является изображением, 3) если надо
|
||||
изменить теги, никакого форматирования и изменения в изображение вносить
|
||||
не надо, только в метаинформацию.
|
||||
- [ ] **Идея 9. Region-инструменты** (censor/erase/pixelate-area/blur-area/
|
||||
sharpen-area/reverse-area) — ждут UI выделения области на превью. Вне
|
||||
очереди.
|
||||
- [ ] **Идея 10. Мультифайловые инструменты-потребители** (gif-to-frames,
|
||||
separate-colors) — механизм «результат = набор файлов» (1 → many) уже есть
|
||||
(`docs/architecture.md`, раздел 7), первый потребитель —
|
||||
`split-into-parts-png`. Остальные подключаются по мере нужды.
|
||||
- [ ] **Идея 11. Анимационные** (slow-reveal/fade/scrolling) — выход не PNG;
|
||||
отдельное решение о формате.
|
||||
- [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract,
|
||||
handwritten-digital).
|
||||
|
||||
## UI и качество
|
||||
|
||||
- [ ] split to parts - посмотреть вариант с toggle component - может будет
|
||||
удобнее чем sectioned buttons
|
||||
- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать
|
||||
заменить на Toggle-компонент, когда тот будет доделан (см. пункт про
|
||||
визуал toggle в «Pipeline»): возможно, будет удобнее и понятнее
|
||||
- [ ] маска подгружается с задержкой при нажатии на чип «Маска» — сейчас
|
||||
`runMask` считается только для включённых шагов (эффект в
|
||||
`SchemaToolView.svelte`). Альтернатива: считать маску всегда вместе с
|
||||
результатом, по нажатию просто показывать другую картинку. Оценить
|
||||
достоинства/недостатки: отклик мгновенный, но лишний проход по пикселям на
|
||||
каждый ран каждого шага с `runMask` (на больших изображениях — заметная
|
||||
цена за функцию, которую могут не открыть)
|
||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
|
||||
мгновенно пересортировываются и под мышкой оказывается другой элемент.
|
||||
Нужна или анимация, или механизм отложенного обновления позиций
|
||||
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
|
||||
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
|
||||
черная\белая\цветные\серая для проверки прозрачных картинок
|
||||
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
||||
единую структуру, позволит упростить некоторые вещи - например
|
||||
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
|
||||
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
||||
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
||||
`ondownloadtxt`, `ondownload`,
|
||||
- [ ] resize keep aspect - работает, но немного не очевидное поведение при
|
||||
прямоугольной картинке и выборе размера через быстрые кнопки (например
|
||||
256х256). Воспроизведение поведения: keep aspect включено, загружаем
|
||||
прямоугольную картинку 832 × 1232, картинка рескейлится до 173 × 256,
|
||||
выключаем keep aspect, картинка рескейлится до 256 × 256 (с искажением
|
||||
пропорций), включаем keep aspect - картинка рескейлится до размера 256 ×
|
||||
379
|
||||
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
||||
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
||||
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
||||
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
||||
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
||||
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
||||
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
||||
- [ ] обновить страницу kit или сделать storybook
|
||||
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
|
||||
поэтому она не следует за пропорциями результата — широкий результат
|
||||
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
|
||||
чтобы соотношение сторон выбиралось по результату в диапазоне 16:9…9:16
|
||||
(`web/src/lib/components/schema/PreviewTile.svelte`).
|
||||
- [ ] Радиусы бордеров: `--radius-m` ненулевой и применён ко всем бордерам
|
||||
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
|
||||
в дизайне, а где должен быть 0
|
||||
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
||||
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
||||
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
||||
`--color-main`/`--color-accent` подняты под текущий порог
|
||||
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
|
||||
для обычного текста, а `--color-danger` в light-теме на границе: после
|
||||
завершения редизайна вернуть порог 4.5 и перепроверить оба токена.
|
||||
- [ ] **Идея 16. Главная должна отличаться от каталога** — оценка S. Сейчас
|
||||
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
|
||||
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
|
||||
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
|
||||
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
|
||||
Замысел: главная — крупнее и с более простым поиском, без категорий;
|
||||
каталог остаётся группировкой с фильтрами. Детектор дублирования разметки
|
||||
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
|
||||
ловится на ревью.
|
||||
|
||||
## Тесты и lint
|
||||
|
||||
- [ ] `tseslint.config(...)` deprecated: сигнатура
|
||||
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
|
||||
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
|
||||
core, а не `typescript-eslint`) — `web/eslint.config.js`.
|
||||
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
|
||||
до этого действует pattern-based policy-исключение
|
||||
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
|
||||
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
||||
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
||||
(`docs/testing-strategy.md`, раздел 5)
|
||||
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
||||
привязанные к разметке или svelte-классам (сейчас остались:
|
||||
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
||||
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
||||
правило такое (запретить toContain без i18n)
|
||||
|
||||
## SEO/GEO
|
||||
|
||||
- [ ] переименование бренда + домен: чек-лист в локальном файле
|
||||
`plan-domain.local.md` (в git не входит, в клоне отсутствует; подробности
|
||||
и порядок — `docs/plan-seo.md` §6, R1/R2)
|
||||
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
|
||||
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
|
||||
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
|
||||
вордстату с уникальным контентом; редиректы только для опечаток и
|
||||
переименований уже опубликованных страниц; клонов с canonical не делать
|
||||
(`docs/plan-seo.md` §5)
|
||||
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
|
||||
- [ ] Seo.svelte: per-tool OG-картинки (S1e) — у каждой страницы инструмента
|
||||
своя превью-карточка 1200×630 для соцсетей/мессенджеров: имя инструмента,
|
||||
бренд, иконка. Сейчас все страницы ссылаются на общую
|
||||
`static/og/default.png` (генерирует `pnpm --dir web gen:og` —
|
||||
Playwright-скриншот HTML-карточки в `web/scripts/gen-og.mjs`). Что делать:
|
||||
расширить gen-og.mjs — проход по реестру страниц, карточка с названием
|
||||
инструмента в `static/og/[slug].png`, в `Seo.svelte` подставлять og-URL по
|
||||
slug страницы. Критерий приёмки: у `/tools/flip-png` в og:image своя
|
||||
картинка с названием инструмента, файл лежит в `static/og/`
|
||||
- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
|
||||
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
|
||||
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
|
||||
- [ ] GSC: подключить домен, отправить sitemap, мониторинг индексации (S4a)
|
||||
- [ ] GA4: cookieless-конфигурация, отключить рекламные audiences и signals,
|
||||
`anonymize_ip` — сейчас в `web/src/routes/+layout.svelte` голый
|
||||
`gtag('config')` (S4b)
|
||||
|
||||
## Платформа (API/PWA)
|
||||
|
||||
- [ ] Vercel: переезд хостинга с GitHub Pages + глобальная статистика
|
||||
популярности инструментов через Vercel KV (serverless-функция: INCR при
|
||||
открытии инструмента, чтение топа для скоринга поиска).
|
||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
|
||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||
(после wasm-фаз 5–7)
|
||||
- [ ] Шрифты: лицензирование (F1–F4) и DOM-free рендер (F5–F6) — до фазы 8
|
||||
(`docs/plan-platform.md` §4)
|
||||
|
||||
## Переезд
|
||||
|
||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
|
||||
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
|
||||
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
|
||||
plurals есть все формы, для которых нужен перевод, `other` никогда не
|
||||
пустой, число подставляется в текст без склейки
|
||||
@@ -0,0 +1,107 @@
|
||||
# Чек-лист ручного тестирования production UI
|
||||
|
||||
> Статус: **active**. Вспомогательный документ к e2e-тестам (`web/e2e/`,
|
||||
> `pnpm --dir web test:e2e`) и стратегии `docs/testing-strategy.md`.
|
||||
> Автоматизированная часть закрывает «нет падений/ошибок»; здесь — то, что
|
||||
> руками, и что Playwright не покрывает.
|
||||
>
|
||||
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
|
||||
> → `node scripts/serve-static.mjs`).
|
||||
>
|
||||
> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания»,
|
||||
> сообщения об ошибках человекочитаемые**.
|
||||
|
||||
## A. Краевые PNG-файлы
|
||||
|
||||
> Фикстуры для этого раздела генерируются скриптом: `pnpm generate:fixtures` →
|
||||
> `tests/fixtures/manual-edge-cases/`.
|
||||
|
||||
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
|
||||
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
|
||||
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
|
||||
remove-background, feather-edges, clean-edges дают корректные края.
|
||||
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется
|
||||
(авто-покрытие: `web/e2e/png-fixtures.spec.ts`).
|
||||
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
||||
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
||||
`tool-flows.spec.ts` и `png-fixtures.spec.ts`).
|
||||
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
|
||||
прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`.
|
||||
|
||||
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
||||
|
||||
| Файл | Описание |
|
||||
| ------------------------ | ------------------------------------------------------------ |
|
||||
| `1x1.png` | Минимальное изображение 1×1, сплошной красный |
|
||||
| `1x1-transparent.png` | 1×1, полностью прозрачный (alpha=0) |
|
||||
| `2x2-extreme.png` | 2×2: чёрный, белый, полупрозрачный красный, прозрачный синий |
|
||||
| `1024x1024-checker.png` | 1024×1024, шахматка ≈1 мегапиксель |
|
||||
| `1920x1080-gradient.png` | 1920×1080, градиент ≈2 мегапикселя |
|
||||
| `alpha-checkerboard.png` | 64×64, чёрно-белая шахматка с чередующейся альфой |
|
||||
| `alpha-gradient.png` | 128×64, альфа-градиент 0→255 по ширине |
|
||||
| `sparse-alpha.png` | 100×100, 10% пикселей с alpha=0 |
|
||||
| `all-black-opaque.png` | 64×64, сплошной чёрный |
|
||||
| `all-white-opaque.png` | 64×64, сплошной белый |
|
||||
| `all-transparent.png` | 64×64, полностью прозрачное |
|
||||
| `palette-4colors.png` | 32×32, палитровый 4 цвета (RGBW) |
|
||||
| `palette-256.png` | 64×64, палитровый полная палитра 256 цветов |
|
||||
| `16bit-subtle.png` | 32×32, 16-bit RGBA, тонкий градиент |
|
||||
| `noise-pattern.png` | 100×100, шумовая текстура |
|
||||
| `1x4000-strip.png` | 1×4000, длинная вертикальная полоса |
|
||||
| `4000x1-strip.png` | 4000×1, широкая горизонтальная полоса |
|
||||
| `corrupt-bad-crc.png` | 4×4, повреждённый CRC в IDAT |
|
||||
| `truncated.png` | Обрезанный файл (только PNG-заголовок + часть IHDR) |
|
||||
| `fake-png.txt` | Текстовый файл «.png» — не-PNG |
|
||||
|
||||
## B. Края параметров
|
||||
|
||||
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate — не
|
||||
«залипает» running.
|
||||
- [ ] Пустые/NaN поля (напр. width/height) — санитизация (silent repair), нет
|
||||
красного alert-текста в виде `errors.*`.
|
||||
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
|
||||
именные цвета) — silent repair или понятная ошибка.
|
||||
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет.
|
||||
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
||||
|
||||
## C. Буфер обмена и drag-n-drop
|
||||
|
||||
- [ ] В реальном браузере кнопка copy на текстовом результате (png-to-base64 и
|
||||
т.п.) кладёт текст в буфер (в Playwright не тестируем из-за permissions).
|
||||
- [ ] Drag-n-drop файла на dropzone страницы инструмента (не только файл-диалог)
|
||||
— UX-ощущения и корректность.
|
||||
|
||||
## D. Производительность и отзывчивость
|
||||
|
||||
- [ ] Долгие авто-рераны на больших картинках (`jpeg-artifacts-png`,
|
||||
`dithering-png`, `quantize-png` на 4К) — UI не фризит, канселяция/дебаунс
|
||||
срабатывает.
|
||||
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
|
||||
залипаний, панели не прыгают.
|
||||
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
|
||||
(утечек blob-URL/bitmap быть не должно).
|
||||
|
||||
## E. Визуальный слой (доделываем по ходу)
|
||||
|
||||
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
|
||||
desktop) на всех маршрутах: `/`, `/list-tools`, `/kit`, любая `/tools/*`.
|
||||
- [ ] Тема light/dark: контраст вердиктов (Yes/No), подпись панели превью
|
||||
(«Предпросмотр»), мета-инфо бликов не даёт.
|
||||
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
|
||||
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
|
||||
|
||||
## F. Субъективная корректность
|
||||
|
||||
- [ ] dithering / two-colors / quantize — результат «по ощущениям» соответствует
|
||||
описанию.
|
||||
- [ ] watermark-tile / add-text / date-stamp на кириллице — рендер текста
|
||||
корректный (не «кракозябры»).
|
||||
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
||||
|
||||
## Как долго
|
||||
|
||||
- Полный прогон: все разделы A–F, включая мегапиксели и 4К, в двух браузерах.
|
||||
- Быстрый смоук: A (кроме мегапикселей), B (выборочно), C, F выборочно.
|
||||
|
||||
> Сценарии, закрытые e2e-тестами, в этот чек-лист не попадают: их покрытие видно
|
||||
> по спецификациям в `web/e2e/`, политика e2e — `docs/testing-strategy.md`.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Решения по гейтам и тестам
|
||||
|
||||
> Статус: **active**. Журнал решений «что сознательно НЕ вводим» и «как читаем
|
||||
> результат CI». Только про гейты, линтеры, coverage, e2e и CI — **для обычных
|
||||
> правок production-кода не нужен**. Читать, когда проектируется или меняется
|
||||
> сам гейт, либо когда спрашивают «почему так, а не иначе». Оперативные команды
|
||||
> и пороги — в `docs/quality-gates.md`, правила нагрузки на машину — в
|
||||
> `docs/agent-workflow.md`, раздел 3.
|
||||
|
||||
Здесь лежат решения, чтобы не пересматривать их заново. Для каждого: причина и
|
||||
то, что решение пересмотрело бы. В `docs/backlog.md` такие пункты не
|
||||
дублируются: backlog живёт задачами, а не историей решений.
|
||||
|
||||
Числа ниже — разовые замеры, а не текущие значения: помечены датой. Актуальные
|
||||
цифры даёт прогон (`docs/quality-gates.md`), по правилу `AGENTS.md` в документах
|
||||
нет величин, которые меняются вместе с кодом.
|
||||
|
||||
## 1. Детектор дублирования разметки — не вводим
|
||||
|
||||
Ни свой AST-правило, ни готовая зависимость (jscpd, `eslint-plugin-sonarjs`).
|
||||
|
||||
- Замер по корпусу `.svelte` в `components/**` и `routes/**` (сентябрь 2026):
|
||||
при пороге 5 нормализованных строк — одна осмысленная пара, при пороге 3 — 17
|
||||
пар, и все они шум (`</label>`, `{/each}`, `{#if children}`). Семейство
|
||||
schema-контролов делит максимум 4 строки — законное сходство by design.
|
||||
- Причина: построчное сравнение Svelte-разметки принципиально ненадёжно —
|
||||
одинаковый контейнер с разным содержимым даёт ложное срабатывание, а намеренно
|
||||
общий класс — ложную тишину. Чтобы не ловить шум, порог пришлось бы поднять до
|
||||
12+ строк, и тогда правило срабатывает ровно один раз.
|
||||
- Контроль остаётся на ревью (расхождение видно в диффе) и на поведенческих
|
||||
e2e-проверках; извлечение общей разметки закреплено конвенцией в `AGENTS.md`.
|
||||
- Пересмотреть, если появится реально «уехавшая» копипаста в компонентах или
|
||||
библиотека разрастётся на уровень композиций страниц.
|
||||
|
||||
## 2. Линтер согласованности комментариев — не вводим
|
||||
|
||||
Описания инструментов различаются осознанно, и ловить это текстовым правилом
|
||||
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
|
||||
`TODO`/`FIXME` перенесены в backlog, незарегистрированных пометок в коде нет;
|
||||
новые запрещены правилом в `AGENTS.md`.
|
||||
|
||||
## 3. Линтер «изменяемых чисел» в документации — не вводим
|
||||
|
||||
Правило «не фиксировать числа, которые меняются вместе с кодом» живёт в
|
||||
`AGENTS.md` и держится на ревью. Автоматизация отклонена по замеру (сентябрь
|
||||
2026): regex «число + существительное» на корпусе Markdown дал 15 совпадений, из
|
||||
которых 14 — ложные («30 дней» политики архива, «9 опций» описания поля, «4
|
||||
проекта» факт из `playwright.config.ts», цитата самого правила).
|
||||
|
||||
- Правило всё равно неполно: ловит «M инструментов», но не «инструментов — M»,
|
||||
не «более 100», не «≈120»; на каждое обходное движение нужна правка правила, а
|
||||
allowlist легитимных чисел становится вторым источником долга. Худший исход —
|
||||
не «шумно», а «вроде гейт, а толку нет»: срабатывания на пустом месте
|
||||
забейзелят, и через месяц правило перестанет ловить настоящие нарушения.
|
||||
Видимое блокирующее правило, которое на деле ничего не проверяет, хуже
|
||||
отсутствия правила.
|
||||
- Соблюдение правила дешевле обхода: «список — в `docs/tools-map.md`» стоит
|
||||
столько же символов, сколько цифра, но не требует обновления.
|
||||
- Пересмотреть, если появится источник истины, из которого число можно
|
||||
**вычислить** и сравнить автоматически, без разбора прозы. Такой случай есть и
|
||||
решён отдельно — следующий пункт.
|
||||
|
||||
## 4. Сверка `docs/tools-map.md` с реестром — автоматизирована
|
||||
|
||||
Правило про числа не закрывает более опасный класс расхождений: документ не
|
||||
устарел числом, а **перечисляет то, чего нет в коде**. В разделе «Реализовано»
|
||||
карты лежали несуществующие страницы (`png-info`, `color-wheel-generator`,
|
||||
`jpg-to-png`, `watermark-image-png`), которые агент принял бы за готовые
|
||||
инструменты. Счёт при этом был верный — число такой класс не ловит.
|
||||
|
||||
Сверка множеств отвечает на вопрос однозначно и без allowlist, поэтому
|
||||
автоматизирована: `web/src/lib/registry/tools-map-doc.test.ts` парсит раздел
|
||||
«Реализовано» и сравнивает slug с `PAGES` в обе стороны (slug без страницы и
|
||||
страница без записи), плюс ловит повтор slug между буллетами. Формат строки
|
||||
карты — всё до первого тире, slug через `/` или `,` — закреплён в самом
|
||||
документе и в тесте. Сверяются именно страницы, а не инструменты: карта
|
||||
описывает то, что видит пользователь, а один инструмент может обслуживать
|
||||
несколько страниц (см. `docs/architecture.md`, раздел 3).
|
||||
|
||||
Чтобы сломанный парсер не выглядел как успех, у теста есть отдельная проверка
|
||||
«разбор не пустой»: если формат карты снова поменяется, тест упадёт с сообщением
|
||||
про парсер, а не молча перестанет что-либо проверять. Входит в обычный
|
||||
`pnpm --dir web test`; новых гейтов и конфигов не добавляет.
|
||||
|
||||
Граница автоматизации: парсер читает разделы «Реализовано», «Можно добавить» и
|
||||
«Идеи», но проверяет их в разных направлениях. Раздел 1 сверяется с реестром
|
||||
обеими сторонами (slug без страницы и страница без записи). Разделы 2 и 3
|
||||
сверяются только в одну сторону: там slug'и намеренно несуществующие, поэтому
|
||||
проверяется, что **ни один из них не является реальной страницей** —
|
||||
реализованный инструмент, оставшийся в плане, роняет тест. Проверять «у плана
|
||||
есть запись в реестре» нельзя по построению: список плановый, а не фактический.
|
||||
|
||||
Плановые разделы устроены прозой, поэтому их разбор другой: берутся все токены
|
||||
нужной формы из всего буллита, а не только из «головы» до тире. Следствие для
|
||||
авторов карты: реализованные страницы в планах называются прозой между
|
||||
буллитами, а не внутри них.
|
||||
|
||||
## 5. Visual snapshots — не вводим
|
||||
|
||||
`toHaveScreenshot` сравнивает страницу с PNG-baseline из прошлого коммита, а не
|
||||
с замыслом дизайна. При активно меняющемся визуальном языке это превращает
|
||||
каждый дизайн-PR в регенерацию и ревью картинок, а baseline платформенные
|
||||
(снимок с Windows не совпадёт на `ubuntu-latest`). Потребность закрывают
|
||||
e2e-поведение, `lint:tokens` с token audit, i18n-гейт и ручной чеклист
|
||||
`docs/checklist-manual-testing.md`.
|
||||
|
||||
Вернуться стоит, когда визуальный язык стабилизируется; тогда первым идёт `/kit`
|
||||
в двух темах, а baseline рождаются на Linux.
|
||||
|
||||
## 6. Fail-режим для unused-token аудита — не вводим
|
||||
|
||||
Остаётся предупреждением: `verify` входит в deploy-workflow, поэтому стилевой
|
||||
долг блокировал бы деплой сайта. Кроме того, аудит видит только ссылки
|
||||
`var(--x)`, и чтение токена из JS дало бы ложное срабатывание. Правило «новый
|
||||
токен — вместе с потребителем» живёт в `AGENTS.md`.
|
||||
|
||||
## 7. Per-project `expect.timeout` — не задаём
|
||||
|
||||
Firefox и WebKit отдают результат так же быстро, как Chromium: расхождения
|
||||
объясняются памятью, а не скоростью движка. Таймаут под конкретную машину —
|
||||
магическое число, которое придётся подстраивать на каждом раннере, поэтому
|
||||
Playwright-конфиг остаётся с дефолтами, а проблема решается лимитом воркеров.
|
||||
Практические правила — `docs/agent-workflow.md`, раздел 3.
|
||||
|
||||
## 8. Дублирование `verify` между workflow — сознательно
|
||||
|
||||
`Deploy to GitHub Pages` и `Quality` оба зовут `verify`: deploy проверяет
|
||||
быстрый gate и собирает сайт, `Quality` прогоняет тот же `verify` и browser
|
||||
matrix. По замеру (сентябрь 2026) `verify` — единицы минут, matrix — около трёх,
|
||||
поэтому дублирование дешевле условных job-и, а matrix дёшево держать на каждом
|
||||
PR. Актуальные длительности — в логе прогона. Ручной
|
||||
`gh workflow run quality.yml` нужен только для перепрогона старого коммита.
|
||||
|
||||
## 9. Политика e2e в CI
|
||||
|
||||
Текущее состояние: `e2e` — non-blocking baseline, запускается на каждом пуше в
|
||||
`main` и на каждом PR целиком (все проекты matrix). Ночного разделения нет: при
|
||||
стоимости job около трёх минут оно не окупается, поэтому новые browser-critical
|
||||
спецификации просто добавляются в `browserContractTests`.
|
||||
|
||||
`e2e` станет blocking, когда выполнятся три условия:
|
||||
|
||||
1. Условия matrix пересмотрены: решено, какие спецификации обязаны идти в
|
||||
`browserContractTests` и `mobileTests`, нужен ли `png-fixtures` вне Chromium
|
||||
и что делать с engine-специфичными расхождениями декодеров.
|
||||
2. Накопились стабильные прогоны на раннере без падений по таймингу.
|
||||
3. Вместе с `continue-on-error` снимается и сам trick с общим conclusion: иначе
|
||||
зелёный прогон при красном `e2e` останется возможен.
|
||||
|
||||
Пока условия не выполнены, `e2e` остаётся non-blocking, а его статус читается по
|
||||
отдельному job — см. следующий раздел.
|
||||
|
||||
## 10. Чтение результатов CI после пуша
|
||||
|
||||
Первый реальный прогон browser matrix происходит на runner, а не на локальной
|
||||
машине: локально Firefox и WebKit проходят на 1 воркере, но состав, скорость и
|
||||
память Linux-раннера отличаются. Поэтому после пуша разработчика:
|
||||
|
||||
1. дождаться завершения workflow и посмотреть, какие job вообще запустились;
|
||||
2. смотреть статусы **отдельных job**, а не общий conclusion прогона: у `e2e`
|
||||
стоит `continue-on-error: true`, поэтому прогон с упавшим e2e целиком
|
||||
помечается как `success` — проверено на run `36225579363` (`E2E: failure` при
|
||||
`conclusion: success`);
|
||||
3. если `e2e` запускался и зелёный — сообщить фактические числа passed, failed,
|
||||
skipped;
|
||||
4. если `e2e` не запускался, сказать это прямо: matrix на раннере не проверена,
|
||||
и её можно прогнать через `gh workflow run quality.yml`;
|
||||
5. красный `e2e` только из-за ресурсов раннера фиксируется как ограничение
|
||||
окружения: сначала проверяются один проект и один spec
|
||||
(`docs/agent-workflow.md`, раздел 3), а не увеличиваются таймауты.
|
||||
|
||||
## 11. Тексты страниц: в базовой локали секция `pages` пустая
|
||||
|
||||
Заголовки и описания страниц переводятся (`pages.<slug>.*`), а EN-строка лежит в
|
||||
реестре страниц — дублировать её в `en.ts` нельзя, иначе при правке реестра
|
||||
английский текст разъедется со страницей, а `dict-consistency` этого не увидит.
|
||||
Поэтому `en.ts` объявляет `pages: {}` — пустую секцию, а линтер правила
|
||||
`i18n/dict-consistency` не считает это нарушением: в базовой локали отсутствие
|
||||
`pages.*.title/description` разрешено опцией `baseLocaleFallback`, а полнота
|
||||
перевода для остальных локалей проверяется строго и юнит-тестом
|
||||
(`tools-i18n.test.ts`).
|
||||
|
||||
Альтернатива — `allowPaths: ["pages"]` — была бы слабее: она разрешила бы пустую
|
||||
секцию `pages` в любой локали, то есть пропущенный перевод страницы прошёл бы
|
||||
линтер молча. Пустая секция в EN и есть то, что делает правило точным.
|
||||
|
||||
## 12. Бамп версии и changelog — при пуше в main, а не в PR
|
||||
|
||||
Версионирование и changelog генерирует бот (`bump.yml`, cocogitto) на push в
|
||||
`main`, а не на pull request.
|
||||
|
||||
- При PR-боте параллельные PR бампили бы одну и ту же версию и конфликтовали по
|
||||
`CHANGELOG.md`; коммиты бота внутри чужого PR — шум, а версия росла бы за ещё
|
||||
не влитый код. Репозиторий trunk-based: коммиты идут прямо в `main`, деплой и
|
||||
так запускается на push.
|
||||
- Цикл: push в `main` → `verify.yml` (переиспользуемый gate) → `cog bump --auto`
|
||||
бампит версии в обоих `package.json` (футер сайта читает `web/package.json`),
|
||||
обновляет `CHANGELOG.md`, ставит тег и пушит без `--skip-ci`, чтобы версионный
|
||||
коммит заново запустил деплой и сайт показал актуальную версию. Зацикливание
|
||||
исключено dry-run проверкой: после версионного коммита conventional-коммитов
|
||||
после тега нет, бот выходит.
|
||||
- Пуш идёт токеном GitHub App (`XBOCT_BOT_ID`/`XBOCT_BOT_PRIVATE_KEY`), а не
|
||||
`GITHUB_TOKEN`: пуш стандартным токеном не запускает workflow, и деплой с
|
||||
новой версией не случился бы.
|
||||
- `concurrency: bump` без `cancel-in-progress`: два бампа параллельно запушили
|
||||
бы конфликтующие версии.
|
||||
- Пересмотреть, если появятся внешние контрибьюторы и поток пойдёт через PR —
|
||||
тогда боту понадобится режим «черновик релиза в PR» или squash-стратегия с
|
||||
conventional-заголовком PR.
|
||||
@@ -0,0 +1,429 @@
|
||||
# План: исправления по аудиту UX/UI
|
||||
|
||||
> Статус: **draft** — на ревью.
|
||||
>
|
||||
> Находки — `docs/tools-audit.md` (что плохо, с датой прохода). Этот документ
|
||||
> задаёт **порядок правок** полей и раскладки волнами; сами задачи — в
|
||||
> `docs/backlog.md`. Проверки и гейты — `docs/quality-gates.md`; точки касания
|
||||
> при добавлении вида поля — `docs/architecture.md`, раздел 4.
|
||||
>
|
||||
> **Идентификаторы шагов — `FA-*` (fields-audit).** Буквы `C*`/`Q*` в других
|
||||
> документах относятся к архивным волнам редизайна и здесь не используются.
|
||||
>
|
||||
> **Как читать волны.** Шаг с пометкой «реализовано» — закрытый, его правку
|
||||
> можно не повторять; шаг без пометки — работа. Пометка стоит в первом абзаце
|
||||
> шага, а не в заголовке, чтобы не потерять её при чтении оглавления. Задачи,
|
||||
> которые в бэклоге, здесь не дублируются: этот документ задаёт **порядок**,
|
||||
> backlog — **учёт** (`docs/backlog.md`).
|
||||
>
|
||||
> **В этом документе инструменты названы по `id`** (`registry/tools/`), а не по
|
||||
> адресу страницы: поля и раскладка принадлежат схеме инструмента, а страница
|
||||
> только ссылается на список шагов (`Page.steps`), где сейчас ровно один
|
||||
> инструмент, а в будущем может быть несколько. Поэтому «у страницы `crop-png`
|
||||
> есть поля x/y» неверно — поля есть у инструмента `crop`, а страница лишь
|
||||
> показывает его. `id` не несёт png-интента: страница `crop-png` вызывает
|
||||
> `id: "crop"`, страница `png-to-hsl` — `id: "to-hsl"` (модель —
|
||||
> `docs/architecture.md`, раздел 3).
|
||||
>
|
||||
> **Как переносить пункты аудита.** `docs/tools-audit.md` назван по страницам,
|
||||
> потому что это то, что видит пользователь. При переносе пункта в шаг мапить
|
||||
> slug → `id` через `page.steps[0].id` в `registry/pages/`. Названия аудита вида
|
||||
> `color-wheel-generator` и `webp-to-png` в реестре отсутствуют (реальная
|
||||
> страница — `color-wheel-png`, инструмент — `color-wheel`).
|
||||
|
||||
## 0. Ключевое решение: select → buttons
|
||||
|
||||
Существующий `Segmented.svelte` — компонент с группированными кнопками (общая
|
||||
рамка, разделители между сегментами). Для параметров инструментов он не
|
||||
подходит: при 3+ опциях сегменты сжимаются, текст не читается.
|
||||
|
||||
**Решение:** новый компонент `ButtonsControl` — раздельные outline-кнопки с
|
||||
отступами. Каждая опция — отдельная `<button>` со своей рамкой, активная
|
||||
подсвечивается заливкой (`--color-main`). Не зависит от `Segmented`.
|
||||
|
||||
### Компонент `ButtonsControl`
|
||||
|
||||
```svelte
|
||||
<!-- web/src/lib/components/fields/schema/ButtonsControl.svelte -->
|
||||
<div class="buttons" role="group">
|
||||
{#each options as opt}
|
||||
<button
|
||||
class="btn"
|
||||
class:selected={value === opt.value}
|
||||
aria-pressed={value === opt.value}
|
||||
onclick={() => select(opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
.btn {
|
||||
padding: var(--space-m) var(--space-l);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-s);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.selected {
|
||||
background: var(--color-main);
|
||||
color: var(--color-background);
|
||||
border-color: var(--color-main);
|
||||
}
|
||||
```
|
||||
|
||||
### Схема: `field.buttons()`
|
||||
|
||||
Новый тип в `registry-schema.ts`:
|
||||
|
||||
```ts
|
||||
export interface ButtonsSpec<V extends string = string> extends FieldSpecBase {
|
||||
kind: "buttons";
|
||||
default: V;
|
||||
options: { value: V; label: string }[];
|
||||
}
|
||||
```
|
||||
|
||||
Фабрика:
|
||||
|
||||
```ts
|
||||
buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
|
||||
spec: { kind: "buttons", ...s },
|
||||
}),
|
||||
```
|
||||
|
||||
Регистрация: добавить `"buttons": {} as ButtonsSpec` в `fieldSpecs`, добавить
|
||||
`ButtonsControl` в `FIELDS` в `SchemaFields.svelte`, добавить дефолт/санитайз в
|
||||
`sanitizeSchemaParams` и `defaultSchemaParams`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Волна A — инфраструктура (1 коммит)
|
||||
|
||||
### FA-A1. Новый тип `buttons` в схеме
|
||||
|
||||
Файлы:
|
||||
|
||||
- `web/src/lib/registry-schema.ts` — `ButtonsSpec`, фабрика `field.buttons()`,
|
||||
кейс в `sanitizeSchemaParams`, кейс в `defaultSchemaParams`
|
||||
- `web/src/lib/components/fields/schema/ButtonsControl.svelte` — новый компонент
|
||||
- `web/src/lib/components/SchemaFields.svelte` — импорт + запись в `FIELDS`
|
||||
|
||||
### FA-A2. Seed → input + randomize
|
||||
|
||||
Для `field.number()` с `kind: "seed"` (или новый подтип `field.seed()`) —
|
||||
рендерить поле ввода + кнопку «Random» вместо range slider.
|
||||
|
||||
Решение: новый вид `field.seed()` (наследует от number, но рендерится иначе).
|
||||
Или проще: опциональное поле `variant?: "seed"` в `NumberSpec` — тогда
|
||||
`RangeControl` покажет input + кнопку.
|
||||
|
||||
Решение: **`variant: "seed"` в `NumberSpec`** — меньше новых типов.
|
||||
|
||||
Файлы:
|
||||
|
||||
- `web/src/lib/registry-schema.ts` — `NumberSpec.variant?: "seed"`
|
||||
- `web/src/lib/components/fields/schema/RangeControl.svelte` — условный рендер
|
||||
- `web/src/lib/registry/tools/filters.ts` — `randomizePixels`, `addNoiseTool`:
|
||||
поле seed → `{ ...field.number(...), spec: { ...spec, variant: "seed" } }`
|
||||
(или пересоздать через `field.seed()`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Волна B — select → buttons (по файлам)
|
||||
|
||||
Каждый шаг — один коммит, < 500 строк. Меняем `field.select()` →
|
||||
`field.buttons()` в перечисленных ниже схемах.
|
||||
|
||||
### FA-B1. geometry.ts
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| ------------------- | -------- | ----------------------------------------- |
|
||||
| rotate | angle | 90° / 180° / 270° |
|
||||
| flip | axis | horizontal / vertical |
|
||||
| swap-orientation | target | portrait / landscape |
|
||||
| symmetric-copy | axis | vertical / horizontal |
|
||||
| symmetric-copy | keepSide | left / right / top / bottom |
|
||||
| change-aspect-ratio | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 |
|
||||
| change-aspect-ratio | mode | crop / pad |
|
||||
|
||||
**Исключение:** `change-canvas-size` (anchor, 9 опций) — оставить
|
||||
`field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже
|
||||
отдельный компонент `PositionControl`).
|
||||
|
||||
### FA-B2. color.ts
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| -------------------- | --------- | ---------------------------------------------------- |
|
||||
| dithering | pattern | floyd-steinberg / bayer |
|
||||
| extract-channel | channel | red / green / blue |
|
||||
| swap-channels | pair | r-g / r-b / g-b |
|
||||
| decrease-color-count | maxColors | 2 / 4 / 8 / 16 / 32 / 44 / 64 / 96 / 128 / 192 / 256 |
|
||||
|
||||
**Исключение:** инструменты разложения каналов (`to-hsl`, `to-hsv`, `to-hsi`,
|
||||
`to-cmyk`, `to-ycbcr`, `to-lab`, поле `component`) — 3-6 опций, но это
|
||||
динамические инструменты, оставить `select`.
|
||||
|
||||
### FA-B3. analyze.ts
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| --------------------- | ---- | ------------------ |
|
||||
| show-transparent | mode | binary / highlight |
|
||||
| show-grayscale-pixels | mode | (тот же) |
|
||||
| show-color-pixels | mode | (тот же) |
|
||||
| light-pixel-mask | mode | (тот же) |
|
||||
| dark-pixel-mask | mode | (тот же) |
|
||||
| unique-color-mask | mode | (тот же) |
|
||||
|
||||
Пять инструментов из шести берут `mode` из общего `maskBaseFields` — правка в
|
||||
одном месте. Исключение — `show-transparent`: он переопределяет `mode` своими
|
||||
опциями и дефолтом `highlight`, поэтому его `mode` правится отдельно.
|
||||
|
||||
### FA-B4. filters.ts
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| ---------- | ---- | ------------ |
|
||||
| add-noise | mode | mono / color |
|
||||
|
||||
### FA-B5. generate.ts
|
||||
|
||||
| Инструмент | Поле | Опции |
|
||||
| -------------- | ------------ | --------------------- |
|
||||
| color-spectrum | direction | horizontal / vertical |
|
||||
| step-colors | layout | grid / strip |
|
||||
| complementary | layout | grid / strip |
|
||||
| triadic | layout | grid / strip |
|
||||
| tetradic | layout | grid / strip |
|
||||
| analogous | layout | grid / strip |
|
||||
| monochromatic | layout | grid / strip |
|
||||
| shades | layout | grid / strip |
|
||||
| sort-colors | layout | grid / strip |
|
||||
| mix-colors | (нет select) | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. Волна C — обязательные фиксы (баги + дефолты)
|
||||
|
||||
### FA-C1. Source-aware defaults для dimension-полей — реализовано
|
||||
|
||||
Source-aware defaults реализованы: общий resolver в `registry-schema`
|
||||
(`applySourceDefaults`). Для `resize` и `crop` дефолт после загрузки равен
|
||||
размерам текущего source; до загрузки используется положительный fallback `1×1`.
|
||||
Один-sided `0` у resize сохраняет режим auto. Проверено тестом
|
||||
`registry.test.ts` («resize и crop получают размеры текущего source»).
|
||||
|
||||
### FA-C2. symmetric-copy: "keep side" не работает
|
||||
|
||||
Баг в `run()`: `keepSide` не учитывает `axis`. При `axis: "vertical"` (удвоение
|
||||
ширины) работают только `left`/`right`, `top`/`bottom` не имеют смысла. Нужно:
|
||||
либо фильтровать опции `keepSide` в зависимости от `axis` (reactive schema),
|
||||
либо нормализовать в `run()`.
|
||||
|
||||
**Решение:** reactive — при смене `axis` сбрасывать `keepSide` на допустимое
|
||||
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
|
||||
{top, bottom} → заменить на `left`.
|
||||
|
||||
### FA-C3. Source-aware bounds (отдельный следующий шаг) — не начат
|
||||
|
||||
Source-aware **defaults** реализованы через `SchemaContext` и
|
||||
`applySourceDefaults`. Динамические min/max для crop x/y, shift и resize не
|
||||
реализованы и остаются отдельной задачей: bounds не следует смешивать с default
|
||||
resolver.
|
||||
|
||||
**Что нужно спроектировать и завести:**
|
||||
|
||||
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
||||
→ `PixelImage` (width/height); для bounds понадобится передать этот context в
|
||||
`SchemaFields` и контролы.
|
||||
|
||||
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
|
||||
источника вместо жёстких чисел. Кандидат:
|
||||
|
||||
```ts
|
||||
x: field.slider({
|
||||
label: "fields.x",
|
||||
bound: "sourceWidth", // min/max = ±sourceWidth
|
||||
default: 0,
|
||||
}),
|
||||
```
|
||||
|
||||
`BoundSpec = "sourceWidth" | "sourceHeight" | "minSide" | "maxSide"`,
|
||||
применимо к `NumberSpec`, `SliderSpec`, `OffsetSpec`, `DimensionSpec`.
|
||||
|
||||
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
|
||||
размера источника; при смене картинки диапазон пересчитывается, текущее
|
||||
значение пере-клампится.
|
||||
|
||||
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
|
||||
(offsetX/offsetY), resize (max → maxSide).
|
||||
|
||||
Это отдельный рефакторинг; source-aware defaults не заменяют bounds.
|
||||
|
||||
### FA-C4. verify-is-png: неверное название — решение принято, шаг не выполнен
|
||||
|
||||
Находка про **страницу**, а не про инструмент: страница `verify-is-png` обещает
|
||||
проверку PNG-файла, а проверяет текст (base64 / data-uri). Сам инструмент назван
|
||||
честно — `id: "verify-png"`, png-интент в нём означает входные данные. Адрес
|
||||
страницы — `slug` `verify-is-png` (`docs/architecture.md`, раздел 3), поэтому
|
||||
«неверное название» относится к `slug`, а не к `id`.
|
||||
|
||||
Варианты:
|
||||
|
||||
- `title` → «Verify PNG data», `description` → уточнить; `slug` и `id` не
|
||||
трогать
|
||||
- либо переименовать `slug` → `verify-png-data` с редиректом со старого URL
|
||||
(нужен вордстат, иначе страница выпадает из индекса)
|
||||
|
||||
**Решение:** поменять title и description, имя страницы не трогать.
|
||||
Переименование `slug` меняет адрес, имя файла, ключи `pages.*` и иконку, то есть
|
||||
это операция с SEO-последствиями, а не строчка в реестре — она не делается в
|
||||
одиночку, вместе с решением по `docs/plan-seo.md` §5.
|
||||
|
||||
---
|
||||
|
||||
## 4. Волна D — обязательные фиксы (новые параметры)
|
||||
|
||||
### FA-D1. remove-alpha-channel: выбор цвета фона
|
||||
|
||||
Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`.
|
||||
|
||||
Файл: `alpha.ts`, схема `removeAlphaChannelSchema`.
|
||||
|
||||
### FA-D2. extract-alpha-mask: галочка «инвертировать»
|
||||
|
||||
Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В
|
||||
`run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`.
|
||||
|
||||
Файл: `alpha.ts`.
|
||||
|
||||
### FA-D3. change-canvas-size: position9 вместо select
|
||||
|
||||
`anchor` сейчас `field.select()` с 9 опциями → `field.position9()`.
|
||||
|
||||
Файл: `geometry.ts`. Уже есть `PositionControl`.
|
||||
|
||||
### FA-D4. Generate: добавить height
|
||||
|
||||
Инструменты без `size`/`height`:
|
||||
|
||||
- blend-two — добавить
|
||||
`height: field.slider({ min: 1, max: 1024, default: 512 })`
|
||||
- step-colors — аналогично
|
||||
- complementary, triadic, tetradic, analogous, monochromatic, shades — все
|
||||
используют `paletteBaseSchema` (общий объект), добавить `height` туда
|
||||
- `sort-colors` — своя схема, `paletteBaseSchema` не использует, `height`
|
||||
добавлять вручную
|
||||
- mix-colors — добавить height
|
||||
|
||||
Файл: `generate.ts`.
|
||||
|
||||
### FA-D5. add-border: прозрачность цвета
|
||||
|
||||
Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть
|
||||
(прозрачность не поддерживается нативным color picker). Отметить в аудите как
|
||||
blocked.
|
||||
|
||||
---
|
||||
|
||||
## 5. Волна E — полировка (некритично)
|
||||
|
||||
### FA-E1. Плейсхолдер "нет параметров"
|
||||
|
||||
Инструменты с пустой схемой (`EmptyParams`) выглядят странно. Добавить
|
||||
`EmptyState` или подсказку "No parameters — click Run".
|
||||
|
||||
Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст.
|
||||
|
||||
### FA-E2. quantize: пресеты
|
||||
|
||||
`field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под
|
||||
слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера.
|
||||
|
||||
### FA-E3. trim-empty-space: 0-254 → проценты
|
||||
|
||||
Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог
|
||||
шага FA-A1 — `variant: "alpha-threshold"` в NumberSpec).
|
||||
|
||||
### FA-E4. add-text: plate offset
|
||||
|
||||
Plate снизу имеет большой отступ — добавить галочку "compact plate" или
|
||||
уменьшить дефолтный padding.
|
||||
|
||||
### FA-E5. from-emoji: выбор эмодзи
|
||||
|
||||
Поле `emoji` → добавить группу часто используемых эмодзи как кнопки-пресеты над
|
||||
полем ввода.
|
||||
|
||||
---
|
||||
|
||||
## 6. Верификация
|
||||
|
||||
Проверки и их минимальный набор — `docs/quality-gates.md`, раздел «Быстрые
|
||||
подмножества»; правила e2e — `docs/testing-strategy.md`. Для волн с изменением
|
||||
пользовательского потока (новый контрол, новая раскладка) обязателен ручной
|
||||
смоук в браузере: часть находок — про воспринимаемость, которую не ловит ни один
|
||||
гейт. `pnpm verify` — перед ревью волны.
|
||||
|
||||
---
|
||||
|
||||
## 7. Не покрыто / требует решения
|
||||
|
||||
Эти пункты из аудита не попали в волны A–E. Требуют либо дополнительного
|
||||
исследования, либо нормативного решения, либо отложены.
|
||||
|
||||
### 7.1 Требуют normative решения
|
||||
|
||||
- **Pixel ↔ Percent** (circle-mask, square-mask, star-mask, wavy-mask — %→px,
|
||||
round-corners — px→%): нужен компонент переключателя единиц в слайдере.
|
||||
Требует правки `RangeControl` + всех схем — в волны A–E не входит, нужен
|
||||
отдельный этап.
|
||||
- **Smoothing checkbox** для масок (circle-mask, square-mask, star-mask,
|
||||
wavy-mask, round-corners, invert-alpha): нужен `field.checkbox("smoothing")` +
|
||||
правка core (shapes.ts, alpha). Отложен — требует визуального тестирования.
|
||||
- **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует
|
||||
normative решения по UI.
|
||||
- **Remove-background**: выбор точки удаления — либо параметр, либо отдельный
|
||||
инструмент.
|
||||
- **Метод масштабирования в `resize`**: поля в схеме нет (только `size` и
|
||||
`keepAspect`), поэтому это уже не правка селекта, а новое поле — нужен ли
|
||||
выбор алгоритма (bilinear/bicubic/nearest)?
|
||||
- **Placeholders**: "нет параметров" — какой текст/компонент показывать.
|
||||
|
||||
### 7.2 Операционные (тест-кейсы, описания)
|
||||
|
||||
- find-contour: расширять на толщину линии (требует правки core)
|
||||
- make-thicker: расширять картинку на толщину линии
|
||||
- make-thinner: уменьшать от краев, fix выступа
|
||||
- feather-edges: расширять картинку на толщину линии
|
||||
- clean-edges: найти тест-кейс, возможно увеличить максимум
|
||||
- despeckle-alpha: найти более заметный тест-кейс
|
||||
- close-holes: проверить поведение на больших полупрозрачных областях
|
||||
- auto-contrast: найти тестовый кейс
|
||||
- sharpen: добавить тест-кейс
|
||||
- center-by-alpha: найти тест-кейс
|
||||
- is-grayscale: улучшить видимость результата
|
||||
|
||||
### 7.3 Дизайн-вопросы
|
||||
|
||||
- add-padding vs add-border: разница неочевидна, нужен review
|
||||
- change-aspect-ratio: не видно что меняется — рамка результата
|
||||
- watermark-tile: заполняет не полностью
|
||||
- sepia: нужна ли настройка силы эффекта?
|
||||
- posterize: настройка для каждого канала отдельно?
|
||||
- pixelate: разные алгоритмы?
|
||||
- swap-channels: обдумать flow через более простые инструменты
|
||||
|
||||
### 7.4 Аудит полей (компоненты)
|
||||
|
||||
- Range slider: поле ввода + кнопки +/- + кнопка сброса
|
||||
- Color picker: пипетка с картинки, прозрачность
|
||||
- Width/height с keep-aspect-ratio: единый компонент
|
||||
- Position offset: отдельные числовые поля X/Y (уже сделано в
|
||||
`fields/schema/OffsetControl.svelte`)
|
||||
@@ -0,0 +1,144 @@
|
||||
# План: платформа — общее ядро, API и PWA
|
||||
|
||||
> **Статус: active.** Этот план добавляет к `docs/roadmap.md` то, чего там нет:
|
||||
> **API** (фаза 9, возможная монетизация) и **PWA** (фаза 10, оффлайн +
|
||||
> установка), плюс шрифты (раздел 4 — частично относится к фазе 8, поэтому
|
||||
> решения фазы 8 здесь дублируются). WASM-ядро, эталоны и CLI — фазы 2–8
|
||||
> `docs/roadmap.md`, они здесь не пересказываются. Оба трека стартуют после
|
||||
> wasm-фаз; PWA не ждёт API и может идти раньше.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
- **Одно ядро — три потребителя.** Rust-крейт `core/` (roadmap фаза 4) даёт:
|
||||
wasm-пакет для сайта (фаза 5), нативный CLI (фаза 6), серверный слой API (фаза
|
||||
9). Единый контракт — JSON-пайплайн `{id, params}` (заложен в roadmap §0 и
|
||||
совпадает с `Page.steps`, реализуется в фазе 11); эталонные файлы и harness
|
||||
(фазы 2, 3, 7) гарантируют одинаковый результат во всех рантаймах.
|
||||
- **API — нативный Rust на сервере, не wasm.** В браузере wasm нужен ради
|
||||
доставки и песочницы; на сервере крейт линкуется напрямую — быстрее и проще.
|
||||
Исключение — edge-воркеры (см. спайк ниже): там исполняется тот же
|
||||
wasm-артефакт фазы 5.
|
||||
- **PWA не трогает SEO-схему.** Сайт остаётся полностью prerendered-статикой;
|
||||
service worker добавляет оффлайн-слой поверх и не отвечает за HTML ботовам.
|
||||
- **Монетизация не ломает позиционирование.** Браузерные инструменты —
|
||||
бесплатные, картинка обрабатывается в браузере и никуда не отправляется.
|
||||
Счётчик посещений GA4 этому не противоречит (границы — `docs/plan-seo.md` §0).
|
||||
API — отдельный продукт для автоматизации/бизнеса. Файлы в API обрабатываются
|
||||
в памяти: без хранения и логирования содержимого.
|
||||
|
||||
## 1. Фаза 9 — API (после фаз 5–7)
|
||||
|
||||
- [ ] **[A1]** Спайк сервера: (а) axum/actix + нативный `core/` на VPS против
|
||||
(б) wasm-артефакт фазы 5 на edge (CF Workers). Сравнить: стоимость, лимиты
|
||||
памяти/времени на больших картинках, сложность деплоя и rate-limiting.
|
||||
Решение записать в §0 этого файла.
|
||||
- [ ] **[A2]** Контракт: `POST /v1/process` — multipart (image) + JSON-пайплайн
|
||||
→ изображение/архив; лимиты размера, коды ошибок; OpenAPI-спека.
|
||||
- [ ] **[A3]** Ключи и лимиты: Bearer-ключи, free tier (N запросов/мес), rate
|
||||
limiting по ключу; самостоятельная выдача/ротация ключей в кабинете —
|
||||
позже.
|
||||
- [ ] **[A4]** Биллинг: Stripe/LemonSqueezy (merchant of record), 2–3 тира по
|
||||
запросам/размеру файла. Только после стабильного free tier.
|
||||
- [ ] **[A5]** Документация как лендинг: `/api` — индексируемая страница
|
||||
(туториал, примеры curl/JS), OpenAPI, ссылка из `llms.txt`. Dev-аудитория
|
||||
— источник бэклинков (`docs/plan-seo.md`, S4c).
|
||||
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
||||
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
||||
|
||||
## 2. Фаза 10 — PWA (после S1)
|
||||
|
||||
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
||||
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
||||
- [ ] **[P2]** Service worker (SvelteKit `src/service-worker.ts`): precache
|
||||
app-shell; immutable-ассеты (build-hash) — cache-first; навигация по
|
||||
инструментам — stale-while-revalidate; offline-fallback страница. Спайком
|
||||
оценить вес полного precache: страницы маленькие, тяжёлое тащить по
|
||||
требованию.
|
||||
- [ ] **[P3]** Оффлайн-UX: индикатор «offline — работаете локально», своя кнопка
|
||||
установки (на `beforeinstallprompt`), стратегия обновления версии (prompt
|
||||
vs skipWaiting — решить на спайке).
|
||||
- [ ] **[P4]** SEO-безопасность: SW не перехватывает запросы ботов и не меняет
|
||||
HTML-ответы; canonical/prerendered-страницы не затрагиваются. Проверка:
|
||||
Lighthouse PWA + SEO зелёные после внедрения.
|
||||
- Гейт: каталог и топ-инструменты полностью работают в airplane-mode; повторный
|
||||
визит без сети открывается мгновенно.
|
||||
|
||||
## 3. Порядок и связи
|
||||
|
||||
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8; пайплайн как продуктовая
|
||||
фича — фаза 11 того же документа.
|
||||
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
|
||||
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
||||
аргументы для Product Hunt / alternative.to / GitHub (`docs/plan-seo.md`, S4c)
|
||||
и для GEO-текстов (`llms.txt`, описания категорий).
|
||||
|
||||
## 4. Шрифты: лицензирование и DOM-free рендер текста
|
||||
|
||||
Текст — один из canvas-зависимых классов, которые roadmap (фаза 8) держит в TS
|
||||
(перечень — там же); для текста выбран курс на реимплементацию, остальные классы
|
||||
не переносятся. Для CLI/API текст всё равно придётся рендерить в Rust, поэтому
|
||||
решение: **перевести text-инструменты в ядро** (в браузере canvas-рендер
|
||||
остаётся TS-фолбэком для A/B). Два вопроса — легальность шрифтов в дистрибутиве
|
||||
и рендер без DOM.
|
||||
|
||||
**Когда начинать:** блока F1–F4 нет — они не привязаны к фазе roadmap, но F5/F6
|
||||
без них бессмысленны (нечего рендерить). Реализуется всё это в фазе 8, поэтому
|
||||
лицензирование стоит закрыть до неё, а не после.
|
||||
|
||||
### Лицензирование (решить до выбора шрифтов, backlog «Font Style»)
|
||||
|
||||
- [ ] **[F1]** В набор — только лицензии, допускающие **коммерческое
|
||||
использование и встраивание в продукт** (сайт + PWA + CLI + API): база —
|
||||
SIL OFL 1.1 (можно редистрибутировать и использовать коммерчески; нельзя
|
||||
продавать сами шрифты; требование — сохранять лицензию и имена при
|
||||
редистрибуции) и Apache 2.0 (Roboto, Open Sans, часть Noto).
|
||||
- [ ] **[F2]** Запрещённые категории: «free for personal use», shareware, шрифты
|
||||
без явной лицензии, MS core fonts (лицензия привязана к поставке с
|
||||
Windows-ПО). Отдельно проверять: бесплатность «для веба на одном домене» ≠
|
||||
право класть TTF в дистрибутив CLI/API — у OFL/Apache таких ограничений
|
||||
нет, у прочих «free»-лицензий бывают.
|
||||
- [ ] **[F3]** Паспорт шрифта в `core/fonts/<name>/` (или эквивалент): файл(ы),
|
||||
LICENSE, источник, версия, хеш; **новый гейт** «у каждого файла шрифта
|
||||
есть паспорт» — его ещё нет, проектируется здесь и добавляется в
|
||||
`pnpm verify` по правилам `docs/quality-gates.md` (решение о fail-режиме —
|
||||
`docs/decisions.md`). Браузер грузит шрифты из бандла, не из ОС и не с
|
||||
`fonts.googleapis.com`-CDN (сторонний запрос = утечка IP пользователя,
|
||||
лишняя зависимость от чужой инфраструктуры), — одинаковый результат у всех
|
||||
пользователей и во всех рантаймах.
|
||||
- [ ] **[F4]** Кириллица: приоритет семействам с полным покрытием — Liberation
|
||||
Sans, PT Sans/PT Serif, Noto Sans, Inter, Roboto (OFL/Apache), DejaVu Sans
|
||||
(пермиссивная лицензия Bitstream Vera: редистрибуция и коммерческое
|
||||
использование разрешены) — закрывает и backlog-вопрос про «только с
|
||||
кириллицей».
|
||||
- **Стартовый набор** (все OFL/Apache, с кириллицей; состав не догма —
|
||||
расширяется по запросам, каждый проходит паспорт F3). Сейчас в бандле сайта
|
||||
лежат `@fontsource/ibm-plex-sans` и `@fontsource/ibm-plex-mono` (тоже OFL) — в
|
||||
наборе ниже их нет, и при введении паспорта F3 их судьбу нужно решить явно:
|
||||
оставить или заменить.
|
||||
- базовый sans: **Inter** (дефолт add-text), **PT Sans** / **Golos Text**
|
||||
(профессиональная русская типографика), **Noto Sans** (фолбэк покрытия);
|
||||
- serif: **PT Serif**, **Lora**, **Playfair Display** (display);
|
||||
- mono: **JetBrains Mono** (дефолт), **Roboto Mono**;
|
||||
- display/акцент: **Oswald** (узкий), **Rubik** (округлый), **Comfortaa**,
|
||||
**Caveat** (рукописный), **Amatic SC** (маркерный);
|
||||
- кириллицу проверить по файлу при добавлении: Bebas Neue, Pacifico
|
||||
(добавлялась поздними версиями). Источник файлов — только Google Fonts /
|
||||
Fontsource (версионированные пакеты под паспорт F3); случайные «free
|
||||
font»-сайты — нет. Вес: woff2-сабсеты latin+cyr лениво в браузере, TTF в CLI
|
||||
(~2–4 МБ суммарно), в wasm не зашивать — грузить из статики по требованию.
|
||||
|
||||
### Рендер без DOM (спайк — фаза 5–6, реализация — фаза 8)
|
||||
|
||||
- [ ] **[F5]** Спайк стека: растеризация глифов (`ab_glyph`/`rusttype`) +
|
||||
шейпинг (`swash`/`harfrust`; для латиницы/кириллицы сложный шейпинг не
|
||||
критичен, сложные письменности — осознанно не в первой версии) → отрисовка
|
||||
в наш `Image`. Эталон — canvas-рендер TS (perceptual-класс по roadmap §3):
|
||||
совпадать обязаны позиция/выравнивание/переносы, пиксели будут отличаться
|
||||
хинтингом/антиалиасингом.
|
||||
- [ ] **[F6]** Единый текстовый API для TS-core и Rust-core: параметры текста —
|
||||
из JSON-схемы инструмента, шрифт — **id из реестра шрифтов** (не системное
|
||||
имя), метрики базовой линии считать в ядре, а не через canvas
|
||||
`measureText` (иначе TS и Rust разъедутся).
|
||||
- Гейт: один и тот же текстовый шаг пайплайна даёт в браузере (wasm), CLI и API
|
||||
визуально идентичный результат; в дистрибутиве нет ни одного файла шрифта без
|
||||
паспорта лицензии.
|
||||
@@ -0,0 +1,447 @@
|
||||
# План: SEO + GEO — индексация и конкуренция в image tools
|
||||
|
||||
> **Статус: active.** Адреса страниц инструментов — `/tools/<slug>` (`crop-png`
|
||||
> и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug`
|
||||
> ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3).
|
||||
>
|
||||
> **`noindex, nofollow` выставлен на каждой странице инструмента**
|
||||
> (`web/src/routes/tools/[slug]/+page.svelte`) — осознанно, до появления
|
||||
> уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и
|
||||
> **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают
|
||||
> эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо
|
||||
> него. Условие снятия и порядок — §4, S1.
|
||||
>
|
||||
> **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный
|
||||
> интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в
|
||||
> ответы генеративных систем (ChatGPT, Perplexity, AI Overviews) — GEO.
|
||||
>
|
||||
> **Позиционирование:** не «PNG-инструменты», а **image tools с PNG-ядром** —
|
||||
> любой инструмент принимает PNG/JPG/WebP/GIF/BMP и отдаёт результат в
|
||||
> PNG/JPG/WebP/BMP через селектор в кнопке Download. Дифференциатор —
|
||||
> прозрачность/альфа/фон и no-upload-приватность. Референс-конкурент по ядру —
|
||||
> onlinepngtools.com (§2). Долгосрочно ядро переезжает на wasm и обрастает API +
|
||||
> PWA (`docs/plan-platform.md`); для SEO это аргументы дистрибуции и GEO-факты,
|
||||
> схему сайта они не меняют.
|
||||
>
|
||||
> **Слаги в этом документе:** `jpg-to-png`, `crop-jpeg` и подобное — интенты и
|
||||
> URL конкурента, а не наши страницы. Наши страницы — в `docs/tools-map.md`
|
||||
> (карта перечисляет `slug`, не `id`); в тексте ниже названия инструментов
|
||||
> приводятся по этим слагам, потому что для SEO важны адреса.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
- **Бренд: переименовать до индексации** (новое имя и домен сознательно не
|
||||
записаны в этом репозитории — чек-лист в локальном файле
|
||||
`plan-domain.local.md`, см. §6, он вне git: выбранное имя не должно утекать в
|
||||
историю коммитов до покупки домена). Легенда честная: «started as PNG tools,
|
||||
now all formats» — это контент-плюс, а не баг. Адреса страниц
|
||||
(`/tools/crop-png`) и localStorage-ключи не переименовывать (см. решение про
|
||||
`slug` ниже). Точка истины по бренду: константа в i18n + README; хардкод в
|
||||
компонентах недопустим.
|
||||
- **Форматы: формат = выбор в UI, не отдельные страницы.** Download-кнопка
|
||||
каждого инструмента — с селектором PNG/JPG/WebP/BMP (+quality для lossy). Райз
|
||||
«выбор формата/качества в Download» вытащен из backlog №7 в S1f: механика уже
|
||||
есть (`Tool.output`), нужна как продуктовая основа «image
|
||||
tools»-позиционирования. В направлении «из формата в PNG» отдельных
|
||||
инструментов нет (загрузка принимает любой формат) — см. §5.
|
||||
- **Адреса страниц `-png` сохраняются, внутренние имена — без.** Инструмент
|
||||
работает с любым форматом, поэтому его `id` не должен врать (`crop`, а не
|
||||
`crop-png`); png-интент держат адрес страницы и имя скачиваемого файла.
|
||||
**Реализовано:** реестр разделён на инструменты (`registry/tools/`, уникальный
|
||||
`id`) и страницы (`registry/pages/`, уникальный `slug`), роут —
|
||||
`tools/[slug]/`; модель — `docs/architecture.md`, раздел 3. **Следствие для
|
||||
SEO: редиректы не понадобились, canonical и sitemap не меняются, адреса те
|
||||
же** — поэтому это была безопасная правка до индексации, а не после. Побочная
|
||||
выгода: широкие интенты («crop image online») переносятся теми же страницами
|
||||
контентом — H1 «Crop image», текст «works with PNG, JPG, WebP»; URL-алиасы без
|
||||
`-png` по-прежнему не создаются (канонический беспорядок). Побочный эффект
|
||||
модели: страница может ссылаться на один инструмент, а инструмент —
|
||||
обслуживать много страниц, поэтому следующие шаги (свести convert-страницы на
|
||||
один `convert`, селектор формата в Download — S1f) не требуют новой миграции
|
||||
реестра. Задача на консолидацию — `docs/backlog.md`.
|
||||
- **Индексируемая локаль — EN** (базовая; конкурент англоязычный). RU-версия —
|
||||
отдельно и позже: префикс `/ru/`, prerender обеих локалей, hreflang
|
||||
(сознательно отложено). Пока язык переключается на клиенте (localStorage) —
|
||||
для SEO не учитываем.
|
||||
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`, см.
|
||||
`BASE_PATH` в `web/vite.config.ts` и `deploy.yml`) ломает абсолютные URL,
|
||||
canonical и «корневость» страниц. Свой домен + корень. Текущий деплой — GitHub
|
||||
Pages (`.github/workflows/deploy.yml`); перенос на свой домен выполняется
|
||||
сменой origin в CI и `PUBLIC_SITE_ORIGIN`.
|
||||
- **URL-схема инструментов:** короткие плоские адреса — `/tools/<slug>` для
|
||||
страницы, `/list-tools` для каталога, где `<slug>` берётся из записи
|
||||
`registry/pages/`. Схему не менять без отдельного SEO-плана: после индексации
|
||||
переименование URL означает 301 и потерю позиций. Разделение инструмента и
|
||||
страницы (решение выше) адресов не меняет.
|
||||
- **Аналитика — GA4, в режиме «только агрегаты».** GA4 уже стоит в проде
|
||||
(`web/src/app.html`, prerender-оболочка, значит на каждой странице). Решение —
|
||||
не выпиливать, а ограничить: cookieless-конфигурация, без рекламных audiences,
|
||||
signals и ремаркетинга. Граница приватности проходит по данным изображения: GA
|
||||
не видит ни файла, ни его содержимого, ни результата инструмента — только факт
|
||||
открытия страницы. **Рекламу и ремаркетинг не ставим** — они противоречат
|
||||
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
|
||||
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
|
||||
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
|
||||
каждому инструменту, с ручной вычиткой.
|
||||
|
||||
## 1. Аудит (2026-09-09)
|
||||
|
||||
Фундамент уже есть:
|
||||
|
||||
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
|
||||
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
|
||||
JS. Это главный технический козырь.
|
||||
- Страницы в `web/src/lib/registry/pages` со «SEO-шными» slug (`crop-png`,
|
||||
`add-border-png`, …) — URL уже повторяют паттерн конкурента, при этом
|
||||
внутренние имена инструментов png-интента не держат. Актуальный состав:
|
||||
`docs/tools-map.md`.
|
||||
- На корневых страницах инструментов есть базовые `<title>` и `robots`; полный
|
||||
SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2.
|
||||
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
|
||||
инструментов против конкурента: `docs/tools-map.md`.
|
||||
- EN-тексты в реестре + i18n-словари ru/en.
|
||||
|
||||
Чего нет (пробелы закрываются этапами ниже):
|
||||
|
||||
| Пробел | Где сейчас | Этап |
|
||||
| -------------------------------------------------- | ----------------------------------- | ---- |
|
||||
| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
|
||||
| `sitemap.xml` | нет | S1 |
|
||||
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
|
||||
| `rel=canonical` | нет | S1 |
|
||||
| OG/Twitter-теги, OG-картинки | нет | S1 |
|
||||
| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
|
||||
| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
|
||||
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
|
||||
| `llms.txt`, хабы категорий с текстом | нет | S3 |
|
||||
| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 |
|
||||
|
||||
Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она
|
||||
блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают
|
||||
трафика. Подробности и условие снятия — §4, S1.
|
||||
|
||||
## 2. Конкурент (onlinepngtools.com)
|
||||
|
||||
Снято с живого сайта (2026-09-09):
|
||||
|
||||
- ~311 URL в sitemap, все плоские (`/crop-png`, `/create-transparent-png`);
|
||||
title вида «Crop a PNG – Online PNG Maker».
|
||||
- Страница инструмента: h1 + 2–3 абзаца («World's simplest…», import/crop
|
||||
примеры), примеры «before/after», how-to-блок, огромные футерные перелинковки
|
||||
(серии Logo*/Icon*/Stamp*), страницы-хабы, price-wall.
|
||||
- Сила: возраст домена, ссылочная масса, бренд, охват, серии-клоны под длинные
|
||||
хвосты.
|
||||
- Слабость (наш контраргумент): реклама, ежедневные лимиты free-плана, платное
|
||||
коммерческое использование, апселл-модалки, страницы по ~450 КБ.
|
||||
- Наш ответ в контенте каждой страницы: **no upload (всё локально в браузере),
|
||||
no signup, no limits, бесплатно в т.ч. для коммерческого использования, open
|
||||
source**.
|
||||
|
||||
## 3. Стратегия
|
||||
|
||||
1. **Не лобовая, а ядро интентов.** 80% объёма поиска приносят ~30–50 запросов.
|
||||
Покрытие инструментов у нас почти полное (список — `docs/tools-map.md`);
|
||||
приоритет — контент и качество страниц ядра, затем хвосты.
|
||||
2. **Страница = лендинг, а не «пустая коробка».** Шаблон контента: H1 → 1–2
|
||||
абзаца «что делает» → как использовать (шаги) → параметры → FAQ (3–5 вопросов
|
||||
уровня People-Also-Ask) → related tools по категории.
|
||||
3. **CWV как измеримое преимущество.** Лёгкие быстрые страницы против ~450-КБ
|
||||
страниц конкурента; после редизайна не потерять скорость.
|
||||
4. **GEO.** LLM-системы цитируют страницы с прямыми определениями, списками,
|
||||
таблицами и уникальными фактами. Всё это уже есть в данных реестра — отдавать
|
||||
в HTML-структуре, без воды.
|
||||
5. **Информационный контур — потом, не вместо.** Гайды по форматам (PNG/JPEG/
|
||||
WebP: история, модификации, lossy/lossless, прозрачность, «png vs jpeg») дают
|
||||
E-E-A-T, перехватывают сравнительные запросы и служат хабами внутренних
|
||||
ссылок на инструменты. Но это этап S3e и только после того, как написано ядро
|
||||
S2: транзакционная страница приносит конверсию, информационная — только
|
||||
трафик. Тонкие FAQ-страницы «про всё» не заводить — несколько сильных
|
||||
материалов с ссылками на несколько связанных инструментов.
|
||||
6. **Дистрибуция.** GitHub-репо (звёзды/форки = сигналы), alternative.to,
|
||||
Product Hunt, Reddit по интенту — реальный путь набрать первые бэклинки без
|
||||
бюджетов.
|
||||
|
||||
## 4. Этапы
|
||||
|
||||
### S1. Технический минимум (закрывает блокер индексации)
|
||||
|
||||
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
|
||||
**domain-required** (после покупки и DNS-настройки).
|
||||
|
||||
- [ ] **[S1h]** Снять `noindex, nofollow` со страниц инструментов
|
||||
(`web/src/routes/tools/[slug]/+page.svelte`) — заменить на
|
||||
`<meta name="robots" content="index, follow" />`. **Это первым
|
||||
обязательным шагом S1:** пока тег стоит, остальные задачи S1 готовят
|
||||
инфраструктуру, но не приводят трафик. Порядок: S1h ставится после
|
||||
S1b/S1c/S1d, иначе Google проиндексирует сайт без sitemap и canonical.
|
||||
Снимать `noindex` до готовности S2-контента осознанно не стоит: в индекс
|
||||
попадут тонкие страницы с одним title/description.
|
||||
|
||||
#### S1-free: без домена
|
||||
|
||||
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
|
||||
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
|
||||
файл `static/og/[slug].png`, ссылка из `Seo.svelte`. Путь к картинке
|
||||
строить через `resolve()` из `$app/paths` (как favicon в
|
||||
`+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь
|
||||
ломается. Домен для этого не нужен; при деплое на собственном домене
|
||||
OG-картинки будут абсолютными и корректными.
|
||||
- [x] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
|
||||
для lossy) для всех инструментов — механика `Tool.output` уже есть
|
||||
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
|
||||
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
|
||||
параллельно с текущими quality-задачами. Побочно снимает `quality` из схем
|
||||
**конвертеров** — после этого convert-страницы сводятся на один инструмент
|
||||
(`docs/backlog.md`). Не из всех схем: у `jpeg-artifacts` `quality` —
|
||||
функциональное поле (сила артефактов), а `output` у инструмента нет, так
|
||||
что селектор формата его не покрывает.
|
||||
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
|
||||
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в
|
||||
png. **Под вопросом:** блок мешает легальному сценарию «сохранить
|
||||
обработанный PNG / JPEG с другим качеством»; решение не принято, см.
|
||||
`docs/backlog.md`. Существующие страницы не трогать: `convert-png-to-jpg`,
|
||||
`convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами
|
||||
со своим контентом (§5).
|
||||
|
||||
#### S1-domain: с доменом (после покупки + DNS)
|
||||
|
||||
- [ ] **[S1a]** Перевести прод на собственный домен: убрать `BASE_PATH` из
|
||||
`deploy.yml`, задать `PUBLIC_SITE_ORIGIN`, перенаправить
|
||||
`https://<юзер>/<repo>/…` на корневые URL (301). Делается одновременно с
|
||||
S1d (canonical), чтобы Google проиндексировал сразу с правильным доменом.
|
||||
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
|
||||
`build/` из `PAGES` реестра (`web/src/lib/registry`) + статических
|
||||
страниц. Взять адреса у страниц, а не у инструментов: страниц может быть
|
||||
несколько на один инструмент, у инструмента адреса нет. Вызов из
|
||||
`pnpm build` после `vite build` (adapter-static пишет в `build/`,
|
||||
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
|
||||
опционального `BASE_PATH`.
|
||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
|
||||
cleanup — вернуть минимальный allow-all.
|
||||
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
||||
актуальному маршруту), OG-теги (title/description/type/url/image),
|
||||
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
||||
для абсолютных URL в canonical и OG:url.
|
||||
|
||||
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
|
||||
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
|
||||
> `build/` лежит корректный `sitemap.xml` со всеми страницами инструментов;
|
||||
> canonical/OG присутствуют на всех страницах; `noindex` отсутствует в собранном
|
||||
> HTML (`pnpm --dir web build` и проверка `build/tools/*/index.html`);
|
||||
> `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job
|
||||
> `e2e` non-blocking, поэтому его статус читается по отдельному job
|
||||
> (`docs/decisions.md`, раздел 10).
|
||||
|
||||
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
|
||||
|
||||
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
||||
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
||||
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
|
||||
вручную пачками по категориям (самый трудоёмкий этап: объём растёт вместе
|
||||
с числом инструментов × 2 локали; RU — отложить до решения о `/ru/`).
|
||||
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
|
||||
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
|
||||
внутри категории + 2–3 «соседних» интента).
|
||||
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI.
|
||||
- [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
|
||||
(приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
|
||||
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5.
|
||||
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
|
||||
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
|
||||
рантайме.
|
||||
|
||||
### S3. Структурированные данные + GEO
|
||||
|
||||
- [ ] **[S3a]** JSON-LD: `SoftwareApplication` (+ `offers` price 0) и `FAQPage`
|
||||
на страницах инструментов; `WebSite` + `Organization` на главной;
|
||||
`BreadcrumbList` на всех внутренних. Учесть: Google ограничил rich results
|
||||
по FAQ/HowTo для мелких сайтов — разметка нужна прежде всего для
|
||||
LLM-парсинга, она безвредна.
|
||||
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
|
||||
инструментов с одним предложением на каждый (генерится из реестра тем же
|
||||
скриптом, что sitemap).
|
||||
- [ ] **[S3c]** Хабы категорий с текстом: 2–3 абзаца на категорию (что за
|
||||
группа, когда какие инструменты применять), не голые списки карточек.
|
||||
- [ ] **[S3d]** GEO-редактура: каждое описание начинается прямым определением
|
||||
(«Crop PNG is a free tool that cuts a rectangular area from a PNG image
|
||||
right in your browser»), списки/таблицы для фактов, без вводных «в
|
||||
современном мире…». Проверка: страница отвечает на запрос одним абзацем
|
||||
«без скролла».
|
||||
- [ ] **[S3e]** _(опционально)_ Информационные гайды по форматам: 3–4 сильных
|
||||
материала (`/guides/png-format`, `/guides/jpeg-vs-png`, …): история и
|
||||
модификации формата, lossy/lossless, прозрачность, когда какой формат
|
||||
выбирать. Каждый — с ссылками на 3–5 связанных инструментов (хабы
|
||||
перелинковки), FAQ из гайдов продублирован как FAQPage-разметка. Приоритет
|
||||
ниже S2/S3a–d: только когда ядро написано и есть ресурс.
|
||||
- Гейты: валидация JSON-LD (Rich Results Test без ошибок); `llms.txt` актуален
|
||||
при добавлении инструментов (генерация, а не ручное редактирование).
|
||||
|
||||
### S4. Измерение и запуск
|
||||
|
||||
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
|
||||
мониторинг индексации и запросов. Бесплатно и обязательно.
|
||||
- [ ] **[S4b]** Довести GA4 до решения §0: cookieless-конфигурация, отключённые
|
||||
рекламные audiences и signals, `anonymize_ip`, запрет на передачу
|
||||
параметров с данными картинки. Мусорные попадания уже отсечены: тег
|
||||
подключается только при `PUBLIC_GA_ENABLED=true` (выставлен в
|
||||
deploy-workflow, `+layout.svelte`). GA/ЯМ как замена счётчику не
|
||||
рассматриваем.
|
||||
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
|
||||
подача на alternative.to (страница против конкурента), Product Hunt,
|
||||
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
|
||||
- [ ] **[S4d]** _(опционально, позже)_ Сравнительные посадочные «vs …» (без
|
||||
негатива: «no limits, no signup, no upload») — по мере роста доверия
|
||||
домена.
|
||||
- [ ] **[S4e]** Контроль CWV: Lighthouse на страницы ядра после каждого крупного
|
||||
этапа редизайна; цель — зелёные метрики на мобильных.
|
||||
|
||||
## 5. Приоритизация инструментов (первая волна контента)
|
||||
|
||||
Источник — `docs/tools-map.md` (наш состав и замер по конкуренту — §2). Слаги
|
||||
ниже — **фактические `slug` страниц** из `docs/tools-map.md` §1; первая волна —
|
||||
топ-интенты, где конкуренты ранжируются, а у нас уже есть инструмент и он не
|
||||
хуже:
|
||||
|
||||
- прозрачность/фон: create-empty-png, remove-background-png, change-png-opacity,
|
||||
set-alpha-channel-png, round-corners-png
|
||||
- конвертация: convert-png-to-jpg, convert-png-to-webp, png-to-bmp, svg-to-png,
|
||||
png-to-base64
|
||||
- геометрия: crop-png, resize-png, rotate-png, flip-png, change-aspect-ratio-png
|
||||
- цвет/фильтры: grayscale-png, invert-colors-png, pixelate-png
|
||||
- текст: add-text-png, watermark-tile-png
|
||||
- генерация: linear-gradient-png
|
||||
|
||||
> Группы здесь — по категории страницы в реестре (`registry/pages/`), а не по
|
||||
> смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и
|
||||
> `watermark-tile-png` — в `text`.
|
||||
|
||||
> **Открытый вопрос по интенту «сжать до N КБ».** Лимит размера теперь живёт в
|
||||
> кнопке Download (`fitWithinBytes` в `web/src/lib/core/io.ts`); интенты вида
|
||||
> «compress png to 500kb / 1mb» — high-volume и лимит-ориентированные, селектор
|
||||
> в Download их не закрывает как посадочная. Решение (отдельная страница / приём
|
||||
> в контенте существующих / ничего) требует вордстата; имя страницы не выбрано.
|
||||
> Прорабатывать вместе с вопросом «из формата в PNG» выше.
|
||||
|
||||
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
|
||||
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
|
||||
> инструментов нет и создавать их не хочется — загрузка файла уже принимает
|
||||
> любой формат, а конвертер «во что-то» закрывает обратное направление. Это
|
||||
> high-volume интент без посадочной страницы. Решение (страница-обёртка на
|
||||
> существующий инструмент / приём в H1 и тексте / ничего) требует вордстата и
|
||||
> должно быть принято до старта S2, иначе первая волна контента закроет не ядро
|
||||
> запросов.
|
||||
|
||||
Новая волна форматов (после S1f): jpg↔webp и другие конвертации «формат →
|
||||
формат» — только по подтверждённому вордстату; базовые кейсы закрывает селектор
|
||||
формата в Download (S1f), страницы заводим лишь там, где интент стабильно
|
||||
ищется.
|
||||
|
||||
- **crop-png: H1 «Crop image online — free, no upload»**, текст «works with PNG,
|
||||
JPG, WebP» — широкий интент обслуживается контентом страницы с png-слагом,
|
||||
слаг при этом остаётся якорем дешёвого png-интента.
|
||||
|
||||
### Форматные варианты страниц (`crop-jpeg`, `crop-webp`, …)
|
||||
|
||||
Три возможных механики — важно не перепутать:
|
||||
|
||||
1. **301-редирект на существующую страницу** (`/crop-jpg` → `/crop-jpeg`, или
|
||||
`/crop-png` → новый слаг после переименования) — законно **только** для
|
||||
опечаток, альтернативных написаний и переименований **уже опубликованной**
|
||||
страницы: URL, которого никогда не было в индексе, редиректом не закрывается.
|
||||
Для самостоятельного запроса редирект — это отказ от страницы: Google
|
||||
перестаёт показывать редирект, ранжировать будет только цель. Как посадочная
|
||||
под «crop jpeg» не работает вообще.
|
||||
2. **Зеркало с canonical на `/crop-png`** — технически легально, но бесполезно:
|
||||
canonical-дубль в индекс не попадает, видимости не добавляет, а массовое
|
||||
производство таких зеркал выглядит как накрутка площади и при ошибке в
|
||||
canonical превращается в дорвейный кластер. Не делаем.
|
||||
3. **Самостоятельная страница = самостоятельный контент.** Заводить `/crop-jpeg`
|
||||
можно только если: (а) интент подтверждён вордстатом, (б) на странице
|
||||
собственный текст — H1 «Crop JPEG», FAQ про качество/артефакты/ когда
|
||||
выбирать JPEG вместо PNG, отличия от png-кейса, (в) текст написан или выверен
|
||||
человеком, а не получен подстановкой формата в шаблон. Реестром
|
||||
автоматизировать **данные** (тот же tool, другой дефолтный output) — можно;
|
||||
автоматизировать **текст** — нельзя: массовая подстановка {tool}×{format} в
|
||||
шаблон — doorway-паттерн, риск deindex всего сайта.
|
||||
|
||||
Как правильно получать видимость под «jpeg»/«webp» без клонов: широкие
|
||||
формулировки закрывает контент существующей страницы (H1 «Crop image», «works
|
||||
with PNG, JPG, WebP» в тексте + селектор формата в Download, S1f). Отдельные
|
||||
форматные страницы — точечные исключения по вордстату, не серия.
|
||||
|
||||
### Convert-инструменты: страницы и «универсальный конвертер»
|
||||
|
||||
Разводить два разных интента:
|
||||
|
||||
- **«convert png to jpg»** — интент «страница-конвертер»: пользователь ищет
|
||||
конкретную пару форматов (лучшее качество конверсии, меньше ошибок), это
|
||||
готовые лендинги. Существующие (`convert-png-to-jpg`, `convert-png-to-webp`,
|
||||
`png-to-bmp`, `svg-to-png`, `base64-to-png`, …) **оставить и наполнить
|
||||
контентом как все**: свой H1 («Convert PNG to JPG — free, no upload»), FAQ про
|
||||
качество/прозрачность/фон, related. Селектор формата (S1f) их **не заменяет**
|
||||
— он про «уже открыл инструмент и хочу другой формат на выходе».
|
||||
- **«convert image» / «image converter»** — интент «швейцарский нож». Под него —
|
||||
**один будущий инструмент `/convert-image`**: сначала пустой **core-tool** в
|
||||
`core/` — функция ре-энкода («копия с перекодированием»: декодирует любой
|
||||
поддержанный исходник в ImageData и кодирует в выбранный формат; поверх неё
|
||||
S1f строит селектор формата). Инструмент и страницу добавлять **только вместе
|
||||
|
||||
с собственным текстом** («Convert any image to PNG, JPG or WebP in your
|
||||
browser») — core в коде не обязывает к странице. Этот же шаблон потом
|
||||
закрывает форматные волны (jpg→webp и т.п.) без новых страниц.
|
||||
|
||||
Не делать: серию `convert-jpeg`/`convert-webp`-клонов с подстановкой формата
|
||||
(doorway, см. выше) и «универсальный конвертер» как единственную страницу вместо
|
||||
форматных лендингов — теряется ядро «convert png to jpg»-запросов.
|
||||
|
||||
Правило добавления новых инструментов: брать из хвоста конкурента только те
|
||||
интенты, которые подтверждены вордстатом, и сразу с контентом S2 (страница без
|
||||
текста — потраченный URL).
|
||||
|
||||
## 6. Переименование бренда и домен (сделать до S1-domain, дёшево сейчас)
|
||||
|
||||
Выбранное имя и домен **в этом репозитории не записаны**: детальный чек-лист
|
||||
лежит в локальном файле `plan-domain.local.md` рядом с этим планом, он исключён
|
||||
из git через `.gitignore`, чтобы имя не попало в историю коммитов до покупки
|
||||
домена. Агент, работающий из клона, этого файла не увидит — при необходимости
|
||||
попросить чек-лист у пользователя.
|
||||
|
||||
- [ ] **[R1]** Механический переезд бренда на новое имя. Список файлов и строк с
|
||||
хардкодом — в локальном плане (раздел «Где живёт бренд в коде»); здесь
|
||||
только SEO-ограничения. **НЕ трогать:** адреса страниц инструментов
|
||||
(`/tools/crop-png`) и localStorage-ключи — `theme` и `locale` лежат без
|
||||
префикса бренда, поэтому переименование с ними не столкнётся (проверено в
|
||||
`theme.svelte.ts` и `i18n/locale.svelte.ts`); историю `docs/archive`.
|
||||
Бренд должен стать i18n-константой, а не тремя независимыми хардкодами.
|
||||
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
||||
перевести прод на него без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
||||
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
||||
- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
|
||||
блокируют). R2 (домен) — блокер для S1-domain (canonical + sitemap), а
|
||||
S1-domain — блокер для S1h (снятие `noindex`). После индексации переименование
|
||||
= переезд с 301, потеря позиций и ссылок — не делать.
|
||||
|
||||
## 7. Что осознанно НЕ делаем
|
||||
|
||||
- Клонирование всего каталога конкурента (низкокачественные дубли + нет ресурсов
|
||||
на контент) — вместо этого ядро + обоснованные хвосты.
|
||||
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
|
||||
- Реклама, ремаркетинг, pixels сторонних сетей — противоречит позиционированию.
|
||||
Счётчик посещений GA4 — не противоречит, условия ограничений — §0 и S4b.
|
||||
- Дорвеи, программные зеркала, покупные ссылки.
|
||||
- Массово-сгенерированные форматные клоны (`crop-jpeg`/`crop-webp` как копии
|
||||
`crop-png` с подстановкой формата в шаблонный текст) — doorway-паттерн, риск
|
||||
deindex всего сайта. Форматная страница — только уникальный контент + вордстат
|
||||
(§5); редиректы — только для опечаток/переименований.
|
||||
- URL-алиасы без `-png` для существующих инструментов и зеркальные
|
||||
страницы-конвертеры «на каждый формат» без вордстата.
|
||||
|
||||
## 8. Связанные документы
|
||||
|
||||
- `docs/tools-map.md` — карта «наш инструмент ↔ интент конкурента».
|
||||
- `docs/backlog.md` — пункты SEO/GEO в общем бэклоге.
|
||||
- `docs/roadmap.md` — фазы 2–8 (ядро TS→Rust/wasm, CLI), фаза 11 (пайплайн);
|
||||
фазы 9–10 — API и PWA.
|
||||
- `docs/plan-platform.md` — планы API и PWA (общее ядро, оффлайн-режим).
|
||||
- `plan-domain.local.md` — локальный чек-лист переименования и покупки домена;
|
||||
**в git не входит** (`.gitignore`), в клоне отсутствует.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Quality gates
|
||||
|
||||
> Статус: **active**. Каноническое описание быстрых проверок: команды, coverage,
|
||||
> warnings, CI. Почему гейт такой, а не иначе, — `docs/decisions.md`.
|
||||
|
||||
Команды выполняются из корня репозитория.
|
||||
|
||||
## Каноническая команда
|
||||
|
||||
```text
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
`verify` выполняет последовательно:
|
||||
|
||||
1. `pnpm --dir web check` — SvelteKit sync и `svelte-check`.
|
||||
2. `pnpm --dir web test:coverage` — полный Vitest-набор вместе с
|
||||
coverage-гейтом.
|
||||
3. `pnpm --dir web test:rules` — отдельный быстрый прогон тестов ESLint-правил.
|
||||
4. `pnpm --dir web lint:all` — ESLint, Stylelint и token audit.
|
||||
5. `pnpm --dir web format:check` — форматирование кода `web/`.
|
||||
6. `pnpm check:docs` — форматирование Markdown (`AGENTS.md`, `README.md`,
|
||||
`docs/`).
|
||||
7. `pnpm --dir web build` — production build.
|
||||
|
||||
Полный `test` уже включает тесты lint-правил; отдельный `test:rules` в `verify`
|
||||
оставлен как явный быстрый диагностический этап.
|
||||
|
||||
## Coverage
|
||||
|
||||
`pnpm --dir web test:coverage` считает покрытие только по `src/lib` и падает
|
||||
ниже порога. Пороги и список исключений из scope — в `web/vitest.config.ts`
|
||||
(`coverage.thresholds`, `coverage.exclude`); здесь они не дублируются, чтобы
|
||||
числа не устарели молча (`AGENTS.md`).
|
||||
|
||||
Порог — нижняя граница, а не оценка качества: он блокирует регрессию и не
|
||||
требует сначала закрывать весь долг. Актуальные проценты даёт
|
||||
`pnpm --dir web test:coverage`. Локальный замер занимает единицы секунд, поэтому
|
||||
отдельного прогона в `verify` нет — `test:coverage` заменяет `test`.
|
||||
|
||||
Исключения из scope и их причины:
|
||||
|
||||
| Исключение | Причина |
|
||||
| ------------------------------------------- | ------------------------------------------- |
|
||||
| `**/*.test.ts`, `**/test-helpers.ts` | сами тесты |
|
||||
| `**/*.svelte.ts` | runes, не работают в node |
|
||||
| `executor/executor.worker.ts` | entry worker, исполняется только в браузере |
|
||||
| `components/define.ts` | type-only, нет исполняемого кода |
|
||||
| `core/domText.ts`, `core/io.ts` | canvas, `createImageBitmap`, нужен браузер |
|
||||
| `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts` | данные, а не логика |
|
||||
|
||||
`registry/*` из scope НЕ исключены: низкое покрытие там означает непокрытую
|
||||
логику, а не ограничение окружения. Включение браузерных модулей в scope —
|
||||
отдельное решение, а не молчаливое расширение списка.
|
||||
|
||||
## E2E
|
||||
|
||||
E2E не входит в `verify`, потому что Playwright поднимает build и статический
|
||||
сервер:
|
||||
|
||||
```text
|
||||
pnpm --dir web test:e2e
|
||||
```
|
||||
|
||||
Запускать его обязательно для изменений маршрутов, загрузки файла, результата,
|
||||
download, темы или локализации. Изменения runtime fixtures/загрузки файла
|
||||
проверяются отдельным `web/e2e/png-fixtures.spec.ts`; ручные бинарники из
|
||||
`tests/fixtures/` не входят в `verify`.
|
||||
|
||||
Локально прогон идёт с `workers: 1` и заметно медленнее. Правила нагрузки на
|
||||
машину пользователя и диагностика флаков — `docs/agent-workflow.md`, раздел 3;
|
||||
матрица проектов и ожидаемое поведение декодеров — `docs/testing-strategy.md`,
|
||||
раздел 7; статус e2e в CI и порядок чтения прогона — `docs/decisions.md`,
|
||||
разделы 9–10.
|
||||
|
||||
## Быстрые подмножества
|
||||
|
||||
Матрица «изменение → минимальный набор проверок»:
|
||||
|
||||
| Изменение | Команда |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Только Markdown | `pnpm check:docs` |
|
||||
| TypeScript/Svelte | `pnpm --dir web check` и `pnpm --dir web test` |
|
||||
| Lint-правило или lint-конфиг | `pnpm --dir web test:rules` и `pnpm --dir web lint:all` |
|
||||
| Маршрут, загрузка, результат, download, тема, локаль | `pnpm --dir web test:e2e` |
|
||||
| Перед ревью или merge | `pnpm verify` |
|
||||
|
||||
## Errors и warnings
|
||||
|
||||
- `errors` завершают соответствующий инструмент ненулевым кодом.
|
||||
- ESLint запускается с `--max-warnings=0`: новые ESLint warnings блокируют gate.
|
||||
- Token audit оставляет unused-token сообщение неблокирующим: новый токен
|
||||
появляется вместе с потребителем, но стиль не блокирует деплой (решение —
|
||||
`docs/decisions.md`, раздел 6).
|
||||
- Резолв `var()` в `app.css` — ошибка: несуществующий токен молча роняет
|
||||
объявление, поэтому этот check failing, в отличие от unused.
|
||||
- `dict-consistency` использует base-locale fallback для registry metadata и
|
||||
pattern-based policy для временно неиспользуемых `tools.*.params`; это не
|
||||
молчаливый `allowPaths`-список.
|
||||
|
||||
`lint:all` может завершиться успешно, напечатав unused-token сообщения: это
|
||||
единственный неблокирующий сигнал в нём. ESLint warnings, наоборот, роняют gate,
|
||||
поэтому «прошёл» ≠ «нечего чинить». Агент показывает число errors, warnings и
|
||||
token warnings раздельно.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/quality.yml`:
|
||||
|
||||
- `verify` блокирует PR при typecheck, unit, rule-test, lint, format, docs или
|
||||
build ошибке;
|
||||
- `e2e` запускается отдельным job после verify и пока не блокирует PR.
|
||||
|
||||
`.github/workflows/deploy.yml` перед production build запускает тот же
|
||||
`pnpm verify`, поэтому deploy не должен проходить при ошибке быстрых проверок.
|
||||
|
||||
| Workflow | Триггер | Jobs |
|
||||
| ------------------------ | --------------------------------------- | --------------------------- |
|
||||
| `Deploy to GitHub Pages` | push в `main`, dispatch | `verify`, `build`, `deploy` |
|
||||
| `Quality` | push в `main`, `pull_request`, dispatch | `verify`, `e2e` |
|
||||
|
||||
Результат после пуша читается **по отдельным job** — процедура в
|
||||
`docs/decisions.md`, раздел 10.
|
||||
@@ -0,0 +1,187 @@
|
||||
# План развития: easy-png-tools
|
||||
|
||||
> **Статус: active.** Фаза 1 выполнена — сайт живёт на типизированном реестре,
|
||||
> production UI — корневые маршруты. Текущий фокус: SEO S1-free
|
||||
> (`docs/plan-seo.md`) + открытые задачи (`docs/backlog.md`). Домен нужен для
|
||||
> SEO (S1-domain). Фазы 2–8 — будущие; 9–10 спланированы в
|
||||
> `docs/plan-platform.md`; 11 — пайплайн.
|
||||
>
|
||||
> **Идентификаторы.** Ссылаться на фазу этого документа. Пространства
|
||||
> идентификаторов задач: `FA-*` — правки полей и раскладки
|
||||
> (`docs/plan-fields-audit-fixes.md`); `S*` — SEO/GEO (`docs/plan-seo.md`); `A*`
|
||||
> — API, `P*` — PWA, `F*` — шрифты (`docs/plan-platform.md`); `R*` — бренд и
|
||||
> домен (`docs/plan-seo.md`, §6). Буквы `Q*`/`C*` в архивных документах
|
||||
> относятся к волнам редизайна и как идентификаторы задач не используются.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
Продуктовые и стратегические — здесь; устройство кода (реестр, исполнитель,
|
||||
слои) — `docs/architecture.md`, разделы 2–3.
|
||||
|
||||
- **Набор утилит для изображений, всё в браузере** (SvelteKit +
|
||||
`adapter-static`): файл картинки не покидает машину. Счётчик посещений GA4
|
||||
этому не противоречит — границы `docs/plan-seo.md`, §0.
|
||||
- **Первый и главный приоритет — полностью рабочий сайт**: максимум простых
|
||||
инструментов, UX/UI, пайплайны (несколько инструментов подряд), описания
|
||||
инструментов.
|
||||
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` —
|
||||
эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки
|
||||
Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
|
||||
- **Пайплайн = JSON-список шагов** `{id, params}` — формат шага совпадает с
|
||||
полем `Page.steps`. Формат зафиксирован этим решением и станет общим для
|
||||
браузера, wasm и CLI; реализуется в фазе 11, не в фазе 1.
|
||||
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из
|
||||
TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций,
|
||||
**perceptual diff** для canvas-зависимых (правила сравнения — раздел 3).
|
||||
|
||||
## 1. Где что лежит
|
||||
|
||||
Актуальная карта модулей, поток данных и рецепт добавления инструмента —
|
||||
`docs/architecture.md`. Список реализованных инструментов — `docs/tools-map.md`,
|
||||
открытые продуктовые задачи — `docs/backlog.md`.
|
||||
|
||||
## 2. Фазы
|
||||
|
||||
### Фаза 1 — Полноценный TS-сайт (выполнена)
|
||||
|
||||
Поставлено: каркас SvelteKit + static, дизайн-система и общий лейаут; ядро над
|
||||
`ImageData` (загрузка/декодирование, кодирование, операции по категориям) без
|
||||
DOM-зависимостей; типизированный реестр с универсальным рендерером страницы и
|
||||
формы; массовая реализация EASY-инструментов; i18n, темы, состояния
|
||||
загрузки/ошибок. Checkpoint достигнут: загрузил PNG → применил инструмент →
|
||||
скачал результат, без wasm.
|
||||
|
||||
**В фазе 1 пайплайна нет.** Workspace цепочек с сохранением в localStorage и
|
||||
экспортом в JSON — фаза 11, не поставленная часть фазы 1. Страница инструмента
|
||||
работает ровно с одной записью реестра; что именно зарезервировано под пайплайн
|
||||
(компонент `StepCard`, ключи `chain.*`), перечислено в `docs/architecture.md`,
|
||||
раздел 1 «Чего в коде нет».
|
||||
|
||||
Незакрытый долг по фазе — в `docs/backlog.md`; он не входит в фазы 2–8 и 11.
|
||||
|
||||
### Фаза 2 — Тестовые PNG и генератор эталонов
|
||||
|
||||
- Фикстурные входы: градиенты/паттерны/шум + пара «настоящих» PNG.
|
||||
- Правило: сравниваем **распакованные пиксели**, не байты файла.
|
||||
- **Checkpoint:** в `tests/reference/<op>/` лежат `input-*.png` +
|
||||
`expected-*.png`.
|
||||
|
||||
### Фаза 3 — Эталонные файлы и UI-регресс
|
||||
|
||||
> UI-регресс на Playwright уже существует и закрывает фазу 3 частично
|
||||
> (`web/e2e/`, browser matrix). Недостающая часть фазы — именно эталоны.
|
||||
|
||||
- Скрипт генерации эталонов: грузит страницу, гоняет TS-реализацию, сохраняет
|
||||
`expected-*.png`.
|
||||
- **Checkpoint:** регресс-тесты падают при изменении выхода эталона.
|
||||
|
||||
### Фаза 4 — Rust-ядро
|
||||
|
||||
- Порт 1:1 чистых операций (первым делом те, что покрыты эталонными файлами) с
|
||||
`PixelImage` ↔ TS `ImageData`.
|
||||
- `clamp_to_u8` с семантикой `ToUint8Clamp` (round-half-to-even, ≥255 → 255) +
|
||||
юнит-тесты на краях.
|
||||
- `cargo test` читает эталонные файлы напрямую, **без браузера**.
|
||||
- **Checkpoint:** `cargo test` зелёный на всех эталонных файлах.
|
||||
|
||||
### Фаза 5 — WASM на сайте
|
||||
|
||||
- Спайк WASM + Vite (проверить интеграцию) → wasm-bindgen-биндинги.
|
||||
- Свитч A/B: TS-реализация заменяется на wasm-вызов.
|
||||
- Playwright-проверка: wasm-результат == эталон.
|
||||
- **Checkpoint:** сайт работает через WASM; A/B TS vs WASM идентично.
|
||||
|
||||
### Фаза 6 — Rust-CLI
|
||||
|
||||
- CLI: обход папки, `--pipeline pipeline.json`, запись результатов.
|
||||
- Воспроизведение эталонов из консоли.
|
||||
- **Checkpoint:** CLI выдаёт те же пиксели, что сохранены в эталонных файлах.
|
||||
|
||||
### Фаза 7 — Сквозная проверка идентичности
|
||||
|
||||
- Матрица: **эталонные файлы (из TS) ↔ Rust-native ↔ Rust-wasm**, одна команда
|
||||
`harness compare`.
|
||||
- **Checkpoint:** сходимость по всем покрытым операциям во всех трёх рантаймах.
|
||||
|
||||
### Фаза 8 — Масштабирование и продвинутое
|
||||
|
||||
- Порт оставшихся EASY → MEDIUM (свёртки, морфология, квантование; кодеки через
|
||||
крейт `image`).
|
||||
- Canvas-зависимые операции (текст, градиенты, штампы, `ctx.filter`): остаются в
|
||||
TS с perceptual-классом либо реимплементация в Rust. Для текста выбран курс на
|
||||
реимплементацию в Rust (рендер без DOM + шрифты OFL с лицензионными
|
||||
паспортами) — `docs/plan-platform.md`, §4.
|
||||
- batch-страница: дроп папки → wasm по всем файлам → zip. localStorage-пресеты
|
||||
(тот же JSON, что у CLI).
|
||||
- HARD: compress/optimize через oxipng/zopfli (в Rust — нативно).
|
||||
|
||||
### Фаза 9 — API (монетизация) — см. `docs/plan-platform.md`
|
||||
|
||||
Серверная обёртка над тем же ядром (нативный Rust или wasm на edge — решает
|
||||
спайк), ключи и free tier, платные лимиты, индексируемая документация `/api`.
|
||||
Чек-лист и решения — `docs/plan-platform.md`, §1.
|
||||
|
||||
### Фаза 10 — PWA — см. `docs/plan-platform.md`
|
||||
|
||||
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
|
||||
устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из
|
||||
`plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
|
||||
|
||||
### Фаза 11 — Пайплайн (цепочки шагов)
|
||||
|
||||
Продуктовая фича, **не зависит от wasm-фаз**: работает на существующем
|
||||
исполнителе и реестре, поэтому может идти параллельно с SEO. Номер 11 выбран
|
||||
чтобы не сдвигать уже используемые ссылки на фазы 2–10.
|
||||
|
||||
- **Формат шага** — `{id, params}`, список шагов применяется последовательно,
|
||||
результат предыдущего идёт в следующий. Формат зафиксирован в §0 как общий для
|
||||
браузера, wasm и CLI, поэтому версионируется с первого дня. Он уже совпадает с
|
||||
полем `Page.steps` (`{ id, params? }`), так что страница может быть готовой
|
||||
цепочкой без новой модели данных; исполняется пока первый шаг.
|
||||
- **Первый шаг фазы:** модуль состояния цепочки + сохранение в localStorage +
|
||||
экспорт/импорт JSON (ошибки разбора уже описаны ключами
|
||||
`errors.badPipelineShape` / `errors.pipelineVersion` / `errors.noSteps`).
|
||||
- **Затем:** редактирование шагов (drag-n-drop, удаление, добавление), переход
|
||||
«один инструмент → цепочка» без перезагрузки, сохранённые именованные цепочки,
|
||||
отображение маски результата.
|
||||
- **Checkpoint:** собрал цепочку из N шагов → сохранил → вернулся позже → скачал
|
||||
результат последнего шага, всё в браузере.
|
||||
|
||||
Ограничения и открытые вопросы (1 → many не терминален, many → 1, частичные
|
||||
ошибки, имена частей) — `docs/backlog.md`, раздел «Идеи». Пайплайн из архивного
|
||||
UI-v1 удалён; что осталось в коде под эту фазу — `docs/architecture.md`,
|
||||
раздел 1.
|
||||
|
||||
## 3. Правила сравнения (для фаз 4–7)
|
||||
|
||||
| Класс операции | Критерий |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| Чистые пиксельные: цвет, альфа, порог, свёртки, морфология, квантование | **бит-в-бит** |
|
||||
| Canvas-растеризация: текст, градиенты, антиалиасинг, `drawImage`, `ctx.filter` | **perceptual diff** (допуск: % изменившихся пикселей / цветовое расстояние) |
|
||||
| PNG-декод | бит-в-бит (lossless) |
|
||||
| JPEG-декод | perceptual (разные декодеры дают лёгкий дрейф) |
|
||||
| Кодирование | сравниваем декодированные пиксели, не байты файла |
|
||||
| compress/optimize | своя метрика: размер + визуальная дельта |
|
||||
|
||||
## 4. Риски и контрмеры
|
||||
|
||||
- **Реестр разрастается, страницы дублируются** — один универсальный рендерер
|
||||
страниц/форм из записей реестра; инструмент = данные + `run`.
|
||||
- **Семантика пайплайна** — зафиксирована: каждый шаг применяется к результату
|
||||
предыдущего; единый тип `PixelImage` на всём пути; схема параметров на шаге.
|
||||
- **Canvas-операции не бит-в-бит** — класс perceptual закладывается на Фазе 2–3,
|
||||
а не в конце.
|
||||
- **WASM + Vite** — спайк на Фазе 5 до массовой интеграции (риск отложен
|
||||
сознательно: сайт уже живёт на TS).
|
||||
- **Расползание реестра при будущем Rust-порте** — схема параметров в JSON, её
|
||||
можно парсить и из Rust.
|
||||
- **Разные декодеры PNG** — фикстурные PNG генерировать самим, чтобы не тащить
|
||||
чужие артефакты.
|
||||
|
||||
## 5. Команды верификации
|
||||
|
||||
Команды текущего этапа и матрица «изменение → проверки» —
|
||||
`docs/quality-gates.md` (`pnpm verify` — быстрый gate). Команды фаз 2–8
|
||||
(`cargo test`, `harness compare`) **в проекте не существуют** и появятся вместе
|
||||
с соответствующими фазами: до этапа 2 проверять нечем, кроме `pnpm verify` и
|
||||
e2e.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Стратегия e2e-тестирования
|
||||
|
||||
> Статус: **active**. Канонические правила для `web/e2e/`; продуктовые решения и
|
||||
> известные баги остаются в `docs/backlog.md` и
|
||||
> `docs/checklist-manual-testing.md`.
|
||||
|
||||
## 1. Что проверяем
|
||||
|
||||
E2E проверяет пользовательский контракт production UI: открытие маршрута,
|
||||
доступность элемента, загрузку или ввод данных, появление результата, ошибку,
|
||||
скачивание и переключение локали. Внутренняя реализация, порядок карточек,
|
||||
CSS-классы и конкретный формат временного URL не являются контрактом.
|
||||
|
||||
Точные переводы проверяются в unit-тестах i18n. В e2e проверяется поведение и
|
||||
отдельный RU/EN smoke, а не полный локализованный текст каждого verdict.
|
||||
|
||||
## 2. Приоритет locator-ов
|
||||
|
||||
1. `getByRole()` с доступным именем и `getByLabel()`.
|
||||
2. Стабильные `href` или `id`, если семантического API нет.
|
||||
3. Разрешённые `data-testid` только для визуальных границ, которые нельзя
|
||||
надёжно назвать через accessibility API:
|
||||
- `source-image`;
|
||||
- `result-image`;
|
||||
- `result-verdict`;
|
||||
- `empty-state`.
|
||||
4. `input[type="file"]` и download-button допускаются только внутри
|
||||
`web/e2e/helpers/page.ts`.
|
||||
|
||||
Нельзя добавлять `data-testid` для счётчиков, групп, metadata, verdict текста,
|
||||
кнопок темы или переключателя языка без отдельного обоснования.
|
||||
|
||||
## 3. Запрещённые зависимости
|
||||
|
||||
В e2e не фиксируются:
|
||||
|
||||
- абсолютные `TOTAL`, `GROUPS` и другие snapshot-числа каталога;
|
||||
- точное число fuzzy-search результатов или порядок карточек;
|
||||
- внутренние CSS-классы и HTML-структура, если есть role/label/href;
|
||||
- `alt` как способ найти результат или источник;
|
||||
- полные локализованные предложения и длинные тире в ожиданиях;
|
||||
- локализованные имена контролов в общих сценариях без явной установки `en`.
|
||||
|
||||
Поведенческий тест может проверять, что известный инструмент видим или скрыт,
|
||||
ошибка имеет текст и не является сырым i18n-ключом, результат доступен для
|
||||
скачивания. Это не требует повторять словарь в e2e.
|
||||
|
||||
## 4. Локаль и fixtures
|
||||
|
||||
Обычные сценарии запускаются с `en` через `useEnglish()` или `openTool()`. RU/EN
|
||||
проверяется отдельным `i18n.spec.ts`; после смены локали locator не должен
|
||||
зависеть от старого accessible name.
|
||||
|
||||
Изображения создаются воспроизводимыми helpers в `web/e2e/helpers/fixtures.ts`.
|
||||
Тесты не добавляют большие бинарные файлы и не зависят от локальных
|
||||
пользовательских файлов. Runtime-fixtures для malformed/special PNG проверяют
|
||||
browser-контракт: critical CRC и truncated отклоняются движком, ancillary CRC и
|
||||
хвост после `IEND` допускаются, palette/16-bit декодируются. Отказ декодера —
|
||||
свойство движка, поэтому ожидание задаётся per-engine в
|
||||
`web/e2e/png-fixtures.spec.ts`, а не через skip. Общее требование для всех
|
||||
движков: битый файл не роняет страницу, отказ показывается alert-ом, а успешный
|
||||
декод даёт результат без ошибки. `toPass()` допускается только для ограниченного
|
||||
ожидания hydration или асинхронного результата; он не должен превращать проверку
|
||||
продукта в бесконечный retry.
|
||||
|
||||
Чистая логика схемы, layout и preview-модели (defaults, sanitization,
|
||||
`resolveLayoutGroups`, `buildSchemaPreviewModel`) проверяется в
|
||||
`src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в Playwright.
|
||||
Component-тесты не дублируют e2e-контракт.
|
||||
|
||||
Состав и причины исключений из coverage-гейта, а также пороги — в
|
||||
`docs/quality-gates.md`; здесь они не дублируются.
|
||||
|
||||
## 5. Helpers и `test.fixme`
|
||||
|
||||
`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`,
|
||||
`renderText`, `resultImage`, `sourceImage`, `verdictStatus`, `textResult`,
|
||||
`errorAlert`, `emptyState`, `rangeInput`, `downloadResult`, `downloadResultFile`
|
||||
и `downloadResultBytes`. Специфика должна вызывать эти helpers, а не повторять
|
||||
raw-селекторы. Harness выбирается по контракту: image — upload + `resultImage`,
|
||||
text — `renderText` + результат, generator — auto-run без upload, verdict —
|
||||
`verdictStatus`, files — отдельный ZIP-сценарий.
|
||||
|
||||
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
||||
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
||||
ссылка обновляются вместе.
|
||||
|
||||
Если тест сравнивает два состояния одного элемента, он ждёт изменения значения
|
||||
(`not.toHaveText`, `not.toHaveAttribute`), а не его видимости. Пересчёт идёт
|
||||
через debounce, поэтому `toBeVisible()` после второго ввода проходит на старом
|
||||
значении. Такой тест флакает по таймингу и обычно ловится на CI, а не локально.
|
||||
|
||||
## 6. Проверки
|
||||
|
||||
Матрица «изменение → минимальный набор проверок» и состав `pnpm verify` — в
|
||||
`docs/quality-gates.md`. Для изменений e2e дополнительно уместны
|
||||
`pnpm --dir web check` и `pnpm --dir web format:check`.
|
||||
|
||||
`verify` не включает Playwright: E2E остаётся отдельным тяжёлым gate. В CI
|
||||
non-blocking E2E не считается заменой локальному smoke; перед ревью фиксируются
|
||||
число passed, skipped и известные fixme. Статус e2e в CI — `docs/decisions.md`,
|
||||
раздел 9.
|
||||
|
||||
## 7. Browser matrix
|
||||
|
||||
`web/playwright.config.ts` запускает четыре проекта:
|
||||
|
||||
| Проект | Движок | Спецификации |
|
||||
| ----------------- | -------- | ------------------------------------------------------------------------ |
|
||||
| `chromium` | Chromium | все e2e |
|
||||
| `firefox` | Firefox | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
|
||||
| `webkit` | WebKit | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
|
||||
| `mobile-chromium` | Chromium | `navigation`, `catalog`, `generators`, `tool-flows`, 390×844, `hasTouch` |
|
||||
|
||||
- Название `tool-flows.spec.ts` описывает содержание: сквозной поток одного
|
||||
инструмента (загрузка → правка параметра → результат → download) на наборе
|
||||
представительных страниц. Пайплайна в коде нет (`docs/architecture.md`, раздел
|
||||
1), поэтому спеку нельзя называть `pipeline` — при её переименовании поправить
|
||||
`browserContractTests` и `mobileTests` в `web/playwright.config.ts` и
|
||||
`allowDefaultProject` в `web/eslint.config.js`.
|
||||
- Рядом живёт `tools-smoke.spec.ts` — дешёвый проход по выборке страниц («выдал
|
||||
результат без ошибок»): по одной-двум на категорию, а не по каждому
|
||||
инструменту. Остальные страницы закрыты точечными спеками (`generators`,
|
||||
`text-and-verdicts`, `tool-flows`). `tool-flows` отвечает за более глубокие
|
||||
проверки, `tools-smoke` — за дешёвый прогон подряд; дублировать их цели не
|
||||
стоит.
|
||||
- Новые engine-специфичные фичи сначала добавляются в `chromium`, потом
|
||||
решается, попадает ли spec в `browserContractTests`.
|
||||
- Расхождения декодеров не ужесточаются и не скрываются skip-ом: они описаны в
|
||||
тесте как ожидаемое поведение движка.
|
||||
|
||||
| Fixture | Отклоняют |
|
||||
| ------------------------------------ | ------------------------- |
|
||||
| `crc-bad-idat.png` | Chromium, Firefox |
|
||||
| `truncated-header.png` | Chromium, Firefox, WebKit |
|
||||
| `truncated-no-iend.png` | Chromium |
|
||||
| `idat-cut.png` | Chromium |
|
||||
| ancillary CRC, tail, palette, 16-bit | ни один движок |
|
||||
|
||||
- `workers` — 1 локально и 2 в CI: Playwright не умеет per-project лимит
|
||||
воркеров, а browser matrix ограничена памятью, а не CPU. Правила нагрузки на
|
||||
машину и порядок диагностики флаков — `docs/agent-workflow.md`, раздел 3;
|
||||
обоснование отсутствия per-project таймаутов — `docs/decisions.md`, раздел 7.
|
||||
- Visual snapshots и per-project таймауты сознательно не используются —
|
||||
`docs/decisions.md`, разделы 5 и 7.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Аудит использования - UX\UI
|
||||
|
||||
> Статус: **active**. **Дата прохода: 2026-09-13** (инструменты и поля). Здесь
|
||||
> только «что плохо» — наблюдения, без порядка работ и без критериев приёмки.
|
||||
> Новый проход — новая дата сверху; закрытая находка не удаляется, а помечается
|
||||
> ссылкой на задачу в `docs/backlog.md` или на уже сделанное в коде.
|
||||
>
|
||||
> **Слаги сверены с реестром** на дату прохода (`docs/tools-map.md` §1).
|
||||
> Отдельно: `webp-to-png` и `color-wheel-generator` в коде отсутствуют (реальная
|
||||
> страница — `color-wheel-png`), а `png-info` — ещё не заведённая страница
|
||||
> (`docs/backlog.md`, «Идеи»). Такие пункты переименовываются по `PAGES`, а не
|
||||
> resurrect'ятся. Перед работой по любому пункту — найти соответствующую
|
||||
> страницу в `web/src/lib/registry/pages/` или `docs/tools-map.md`; ищи по
|
||||
> смыслу: категория в заголовке раздела + ключевое слово в пункте.
|
||||
>
|
||||
> **Заголовки разделов ниже — это id категорий из `web/src/lib/categories.ts`**,
|
||||
> по ним и ищи страницу в `registry/pages/<group>.ts`. Раздела `convert` в
|
||||
> аудите нет: конвертация проверялась вместе с остальными инструментами,
|
||||
> отдельного прохода по ней не было.
|
||||
>
|
||||
> Как исправлять — `docs/backlog.md` (задачи) и
|
||||
> `docs/plan-fields-audit-fixes.md` (порядок правок полей). Находка в аудите и
|
||||
> задача в бэклоге — не дубль: пока задачи нет, находка просто висит.
|
||||
|
||||
## везде
|
||||
|
||||
- возможность переключать пиксели\проценты
|
||||
- инструменты без параметров выглядят странно, пересмотреть, может добавить
|
||||
какую-то надпись "нет параметров"
|
||||
- удаление\маски и т.д. - надо добавить галочку сглаживание
|
||||
- производительность: почему flip-png заметно тормозит, но нет индикации работы
|
||||
инструмента (также sharpen-png)
|
||||
- производительность: исследовать, есть ли смысл на инструментах геометрии
|
||||
выводить рамку результата до полноценной работы (например на skew \ rotate
|
||||
будет удобнее)
|
||||
|
||||
## аудит инструментов
|
||||
|
||||
### alpha
|
||||
|
||||
(в UI раздел называется «Transparency» / «Прозрачность»)
|
||||
|
||||
- add-stroke-png: добавить галочку "расширить на толщину линии"
|
||||
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо
|
||||
расширять на толщину линии
|
||||
- circle-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- square-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- star-mask-png: размеры заданы в процентах, надо задавать в пикселях, проверить
|
||||
форму звезды - сейчас выпуклые края, радиус задан до 100%, а более 50 - смысле
|
||||
нет
|
||||
- wavy-mask-png:размеры заданы в процентах, надо задавать в пикселях,
|
||||
пересмотреть форму и параметры. как будто неудобно конфигурировать
|
||||
- remove-alpha-channel-png: выбор цвета фона, как будет работать с
|
||||
полупрозрачной картинкой?
|
||||
- extract-alpha-mask-png: галочка инвертировать маску
|
||||
- round-corners-png: размеры заданы в пикселях, должна быть возможность задать в
|
||||
процентах, нету сглаживания по вырезанным пикселям
|
||||
- invert-alpha-png: края со сглаживанием странно обрабатываются, может
|
||||
галочка\слайдер обрезать сглаживание?
|
||||
- remove-background-png: возможность выбора точки, от которой удалять (или
|
||||
отдельный инструмент?)
|
||||
- make-thicker-png: расширять картинку на толщину линии
|
||||
- make-thinner-png: уменьшать от краев, если картинка начинается от края, то
|
||||
остается выступ
|
||||
- feather-edges-png: расширять картинку на толщину линии, (выбор цвета возможен?
|
||||
другой инструмент?)
|
||||
- clean-edges-png: непонятный инструмент (слишком маленький максимум?). найти
|
||||
тест кейс для него
|
||||
- despeckle-alpha-png: найти более заметный тест-кейс
|
||||
- close-holes-png: проверить, как должен работать, картинки с большой
|
||||
полупрозрачной областью - закрывает всё, по описанию должен закрывать только
|
||||
небольшие дырки
|
||||
|
||||
### color
|
||||
|
||||
- two-colors-png: в range slider добавить описание что это luminance threshold
|
||||
- gamma-png: проверить правильность работы - яркое изображение выглядит не
|
||||
контрастным, проверить максимум и минимум
|
||||
- temperature-png: проверить правильность работы - не слишком ли отдает
|
||||
желтым\синим?
|
||||
- quantize-png: максимум 64 - не слишком мало? сделать кнопки с пресетами
|
||||
- custom-palette-png: копирует two-colors-png, только ввод через текстовое поле
|
||||
и нет выбора порога luminance
|
||||
- dithering-png: проверить алгоритм Floyd–Steinberg на малом количестве цветов
|
||||
- sepia-png: не нужна настройка силы эффекта?
|
||||
- extract-channel-png: мульти вывод сразу в 3 канала?
|
||||
- swap-channels-png: обдумать описание подобных инструментов через flow более
|
||||
простых?
|
||||
- black-and-white-png: аналог two-colors-png, только без выбора цветов
|
||||
- posterize-png: не нужен вариант настройки для каждого канала отдельно?
|
||||
- auto-contrast-png: найти тестовый кейс
|
||||
- decrease-color-count-png: сделать кнопки вместо селекта
|
||||
- png-to-hsl: проверить, что `space as rgb` должна выдавать одинаковые
|
||||
результаты, вне зависимости от выбора h s l. может выбрать отдельные кнопки?
|
||||
- png-to-hsv: тоже самое ^
|
||||
- png-to-hsi: тоже самое ^, нужно ли разделять на отдельные инструменты?
|
||||
- png-to-cmyk: тоже самое ^
|
||||
- png-to-lab: тоже самое ^
|
||||
|
||||
### geometry
|
||||
|
||||
- add-border-png: прозрачность цвета (аудит полей)
|
||||
- fit-on-background-png: цвет
|
||||
- change-canvas-size-png: использовать position9 вместо селекта
|
||||
- resize-png: по умолчанию размеры 0х0 — сделано, дефолт берётся из source
|
||||
(`defaultFromSource`), до загрузки 1×1. Выбор метода масштабирования — поля в
|
||||
схеме нет вовсе, это не правка селекта, а новое поле
|
||||
(`docs/plan-fields-audit-fixes.md` §7.1)
|
||||
- crop-png: offset - слишком большие значения
|
||||
- rotate-png: кнопки вместо select
|
||||
- flip-png: кнопки вместо select
|
||||
- add-padding-png: чем отличается от add-border-png?
|
||||
- center-by-alpha-png: найти тест кейс
|
||||
- zoom-png: выбор точки центра
|
||||
- trim-empty-space-png: выбор от 0 до 254 (надо везде привести к единому стилю -
|
||||
переключение 0-100 проценты и 0-255)
|
||||
- change-aspect-ratio-png: не видно что меняется, добавить рамку вокруг
|
||||
результата, а не вокруг div
|
||||
- swap-orientation-png: кнопки вместо select
|
||||
- symmetric-copy-png: кнопки вместо select, два выбора в селекте `keep side` не
|
||||
работают (в зависимости от выбора `axis`)
|
||||
- shift-png: цвет (прозрачность)
|
||||
- split-into-parts-png: результат с несколькими файлами, обдумать процесс
|
||||
|
||||
### filters
|
||||
|
||||
- vignette-png: выбор точки центра, цвет, кроме силы нужен еще параметр прироста
|
||||
силы (как называется?)
|
||||
- pixelate-png: разные алгоритмы? выглядит не очень. тест кейс?
|
||||
- randomize-pixels-png: настройка размеров другим вариантом - на сколько кусков
|
||||
делить. seed - поменять
|
||||
- add-noise-png: кнопки вместо select, seed - поменять
|
||||
- sharpen-png: добавить тест кейс
|
||||
- silhouette-png: аналог extract-alpha-mask-png?
|
||||
|
||||
### text
|
||||
|
||||
- add-text-png: plate снизу большой отступ, сделать галочку для этого (некоторые
|
||||
буквы будут обрезаться - y, g)
|
||||
- watermark-tile-png: заполняет не полностью
|
||||
|
||||
### analyze
|
||||
|
||||
- extract-color-from-png: надо описать или дать выбор на основе какого параметра
|
||||
работает threshold
|
||||
- show-transparent-png: кнопки вместо select
|
||||
- show-grayscale-pixels-png: кнопки вместо select
|
||||
- show-color-pixels-png: кнопки вместо select
|
||||
- light-pixel-mask-png: кнопки вместо select
|
||||
- verify-is-png: название неверное, в описании указано изображение, а фактически
|
||||
проверяется текст
|
||||
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный
|
||||
вердикт? может отдельные поля - 0\1, yes\no?
|
||||
|
||||
### generate
|
||||
|
||||
(в UI раздел называется «Generate» / «Генерация»)
|
||||
|
||||
- color-spectrum-png: кнопки вместо select
|
||||
- draw-grid-png: выбор цвета фона
|
||||
- placeholder-png: с галочкой "показать текст" нет заполнения фона
|
||||
- blend-two-png: высота не задается
|
||||
- step-colors-png: кнопки вместо select, высота не задается, не слишком ли
|
||||
маленький max steps?
|
||||
- emoji-to-png: выбор эмодзи
|
||||
- color-wheel-png: сглаживание краев
|
||||
- complementary-png: кнопки вместо select(?), не меняется, всего 2 цвета, высота
|
||||
не задается
|
||||
- triadic-png: кнопки вместо select, высота не задается
|
||||
- tetradic-png: кнопки вместо select, высота не задается
|
||||
- analogous-png: кнопки вместо select, высота не задается
|
||||
- monochromatic-png: кнопки вместо select, высота не задается
|
||||
- shades-png: кнопки вместо select, высота не задается
|
||||
- mix-colors-png: высота не задается
|
||||
- sort-colors-png: кнопки вместо select, высота не задается
|
||||
|
||||
## аудит полей
|
||||
|
||||
- у полей plate, position9 и font-style вторая подпись дублирует первую
|
||||
- position offset: сделано — отдельные числовые поля X и Y в
|
||||
`fields/schema/OffsetControl.svelte` (не range slider); задача на px/% вместо
|
||||
них открыта, `docs/plan-fields-audit-fixes.md` §7.1
|
||||
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
|
||||
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
|
||||
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
|
||||
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
|
||||
- поле seed (random) - range slider не нужен, поле для ввода и "randomize"
|
||||
кнопка
|
||||
- поле текста: нормальная высота по умолчанию (не одна строка), для svg —
|
||||
форматирование или подсветка синтаксиса, для bin\hex — колонки, для
|
||||
hex\bytes\rgba — пример в placeholder
|
||||
@@ -0,0 +1,260 @@
|
||||
# Карта инструментов: реализовано · добавить · идеи
|
||||
|
||||
> Статус: **active**. Навигационный список: что уже есть по адресам
|
||||
> `/tools/<slug>`, что можно добавить, что отложено. Источник правды — реестр
|
||||
> (`web/src/lib/registry/`), модель страницы и инструмента —
|
||||
> `docs/architecture.md`, раздел 3; сверяться с реестром перед использованием.
|
||||
> Количество инструментов здесь не фиксируется — оно выводится из раздела 1 и из
|
||||
> `PAGES` (`AGENTS.md`).
|
||||
>
|
||||
> **Как читать строку.** `slug` — адрес страницы и имя скачиваемого файла;
|
||||
> страница вызывает инструмент через `steps[0].id`, и у `id` нет png-интента
|
||||
> (`crop-png` → `id: "crop"`, `png-to-hsl` → `id: "to-hsl"`). Имена полей — это
|
||||
> id полей из `schema.fields` инструмента; составные поля (`size`, `pair`,
|
||||
> `offset`, `style`, `plate`, `gradient`) в скобках раскрыты, но не выдуманы как
|
||||
> отдельные поля. Сравнение с каталогом источника (объём, нишевые клоны серий
|
||||
> Logo\*/Icon\*/Stamp\*) — замер в `docs/plan-seo.md` §2; здесь он не
|
||||
> дублируется.
|
||||
>
|
||||
> **Формат строки (проверяется `web/src/lib/registry/tools-map-doc.test.ts`):**
|
||||
> всё **до первого тире** `—` — это slug страниц через `/` или `,`; всё после
|
||||
> тире — свободное описание параметров и не разбирается. Второй slug нельзя
|
||||
> оставлять после тире, иначе он не попадёт в разбор и страница будет считаться
|
||||
> неописанной.
|
||||
|
||||
---
|
||||
|
||||
## 1. Реализовано
|
||||
|
||||
Slugs приведены к фактическим в `web/src/lib/registry/pages/`. Разделы идут по
|
||||
файлу реестра; поля — по схеме инструмента, который страница вызывает.
|
||||
|
||||
### Конвертация (`convert`)
|
||||
|
||||
Отдельных «из формата в PNG» инструментов **нет**: входной формат выбирается
|
||||
загрузкой файла, любой инструмент принимает PNG/JPG/WebP/GIF/BMP. Все конвертеры
|
||||
исходят из PNG.
|
||||
|
||||
- `convert-png-to-jpg` — background, quality
|
||||
- `convert-png-to-webp` — quality
|
||||
- `png-to-bmp` — без параметров (24-бит, фон вместо альфы)
|
||||
- `svg-to-png` — width (ширина результата; вход — SVG-код)
|
||||
- `png-to-base64`, `base64-to-png` — без параметров (строка)
|
||||
- `png-to-data-uri`, `data-uri-to-png` — без параметров (строка)
|
||||
- `png-to-hex`, `hex-to-png` — вход `width`; rrggbbaa по строкам / tokens
|
||||
- `png-to-bytes`, `bytes-to-png` — вход `width`; десятичные RGBA-байты по
|
||||
строкам / tokens
|
||||
- `png-to-rgb-values`, `rgb-values-to-png` — вход `width`; rgba(r,g,b,a) по
|
||||
пикселям / числа
|
||||
|
||||
### Прозрачность (`alpha`)
|
||||
|
||||
- `add-stroke-png` — color, thickness
|
||||
- `find-contour-png` — color, thickness
|
||||
- `remove-color-from-png` — targetColor, tolerance
|
||||
- `remove-background-png` — color, tolerance, outerOnly, smooth
|
||||
- `make-thicker-png`, `make-thinner-png` — radius
|
||||
- `harden-alpha-png` — threshold
|
||||
- `feather-edges-png` — radius (размытие только альфы)
|
||||
- `clean-edges-png` — radius (defringe: RGB от ближайшего непрозрачного)
|
||||
- `despeckle-alpha-png`, `close-holes-png` — radius
|
||||
- `round-corners-png` — radius (% от половины меньшей стороны)
|
||||
- `invert-alpha-png` — без параметров
|
||||
- `extract-alpha-mask-png` — без параметров
|
||||
- `set-alpha-channel-png` — percent
|
||||
- `remove-alpha-channel-png` — без параметров (фон `#ffffff` захардкожен,
|
||||
`docs/plan-fields-audit-fixes.md` FA-D1)
|
||||
- `circle-mask-png` — size (диаметр, % меньшей стороны), offset (x/y)
|
||||
- `square-mask-png` — widthPct, heightPct, offset (x/y)
|
||||
- `star-mask-png` — points, innerRadius, size, rotation, offset (x/y)
|
||||
- `wavy-mask-png` — size, amplitude, waves, phase, offset (x/y)
|
||||
|
||||
### Цвет (`color`)
|
||||
|
||||
- `grayscale-png`, `invert-colors-png`, `sepia-png`, `auto-contrast-png` — без
|
||||
параметров
|
||||
- `adjust-brightness-contrast-png` — brightness, contrast
|
||||
- `change-png-opacity` — percent
|
||||
- `change-png-hue` — degrees
|
||||
- `temperature-png` — percent
|
||||
- `gamma-png` — value
|
||||
- `tint-png` — color, strength
|
||||
- `two-colors-png` — pair (светлый/тёмный), threshold
|
||||
- `black-and-white-png` — threshold
|
||||
- `posterize-png` — levels
|
||||
- `quantize-png` — colors (k, median-cut)
|
||||
- `decrease-color-count-png` — maxColors (2…256)
|
||||
- `custom-palette-png` — colors (hex через запятую, ближайший цвет)
|
||||
- `dithering-png` — colors (k), pattern (Floyd–Steinberg / Bayer 4×4)
|
||||
- `extract-channel-png` — channel (r/g/b)
|
||||
- `swap-channels-png` — pair (r-g/r-b/g-b)
|
||||
|
||||
### Разложение каналов (`color`)
|
||||
|
||||
- `png-to-hsl`, `png-to-hsv`, `png-to-hsi`, `png-to-cmyk`, `png-to-ycbcr`,
|
||||
`png-to-lab` — component (h/s/l и т.п.), display (`gray` | `space-as-rgb`)
|
||||
|
||||
### Геометрия (`geometry`)
|
||||
|
||||
- `resize-png` — size (по умолчанию размер source, 0 по одной стороне = авто),
|
||||
keepAspect
|
||||
- `crop-png` — x, y, size (по умолчанию вся область source)
|
||||
- `rotate-png` — angle (90/180/270)
|
||||
- `rotate-free-png` — angle
|
||||
- `flip-png` — axis (h/v)
|
||||
- `skew-png` — degX, degY
|
||||
- `zoom-png` — scale
|
||||
- `shift-png` — offsetX, offsetY, color
|
||||
- `add-padding-png` — padding, transparent, color
|
||||
- `add-border-png` — thickness, color
|
||||
- `fit-on-background-png` — size, transparent, color
|
||||
- `tile-png` — columns, rows
|
||||
- `split-into-parts-png` — columns, rows (мультифайловый вывод, zip)
|
||||
- `trim-empty-space-png` — threshold (по альфе)
|
||||
- `change-canvas-size-png` — size, anchor (3×3)
|
||||
- `change-aspect-ratio-png` — ratio (пресеты), mode (crop/pad)
|
||||
- `swap-orientation-png` — target (portrait/landscape)
|
||||
- `symmetric-copy-png` — axis, keepSide
|
||||
- `center-by-alpha-png` — без параметров
|
||||
|
||||
### Фильтры (`filters`)
|
||||
|
||||
- `blur-png` — radius
|
||||
- `sharpen-png` — strength
|
||||
- `vignette-png` — strength
|
||||
- `jpeg-artifacts-png` — quality (имитация пережатия jpg/webp; поле
|
||||
функциональное, у инструмента нет `output` — `docs/plan-seo.md` S1f)
|
||||
- `pixelate-png` — blockSize
|
||||
- `randomize-pixels-png` — blockSize, seed
|
||||
- `add-noise-png` — amount, mode (mono/color), seed
|
||||
- `silhouette-png` — color, threshold
|
||||
|
||||
### Анализ и вердикты (`analyze`)
|
||||
|
||||
- `png-file-size` — без параметров (DOM-инструмент: кодирует в PNG и отдаёт
|
||||
размер)
|
||||
- `png-is-transparent`, `png-is-grayscale`, `png-orientation` — без параметров,
|
||||
текстовый вердикт
|
||||
- `verify-is-png` — без параметров (вход — текст: base64/data-uri), вердикт по
|
||||
сигнатуре
|
||||
|
||||
### Маски по свойствам пикселей (`analyze`)
|
||||
|
||||
- `show-transparent-png` — mode, color, opacity
|
||||
- `show-grayscale-pixels-png` — mode, tolerance, color, opacity
|
||||
- `show-color-pixels-png` — mode, tolerance, color, opacity
|
||||
- `light-pixel-mask-png` — mode, threshold, color, opacity
|
||||
- `dark-pixel-mask-png` — mode, threshold, color, opacity
|
||||
- `unique-color-mask-png` — mode, rarity (макс. повторов), color, opacity
|
||||
- `extract-color-from-png` — color, tolerance
|
||||
|
||||
### Генерация (`generate`)
|
||||
|
||||
- `create-empty-png` — size, transparent, color
|
||||
- `single-color-png` — size, color
|
||||
- `random-noise-png` — size, seed
|
||||
- `linear-gradient-png` — size, gradient (от/к, направление)
|
||||
- `color-spectrum-png` — size, direction, saturation, lightness
|
||||
- `random-colors-png` — size, blockSize, seed
|
||||
- `draw-grid-png` — size, cols, rows, lineWidth, color, transparentBg
|
||||
- `placeholder-png` — size, backgroundColor, color, showText
|
||||
- `blend-two-png` — pair (от/к), width
|
||||
- `step-colors-png` — pair (от/к), steps, width, layout (grid/strip)
|
||||
- `color-wheel-png` — size, lightness
|
||||
- `mix-colors-png` — colors, width
|
||||
- `sort-colors-png` — colors, order (hue/brightness/saturation), width, layout
|
||||
- `emoji-to-png` — emoji, size
|
||||
- `text-to-png` — text, style (шрифт/размер/цвет/жирность), transparentBg,
|
||||
backgroundColor, padding
|
||||
- `complementary-png`, `triadic-png`, `tetradic-png`, `analogous-png`,
|
||||
`monochromatic-png`, `shades-png` — baseColor, width, layout; у `analogous`
|
||||
ещё spread и count, у `monochromatic` — count и range, у `shades` — count и
|
||||
depth
|
||||
|
||||
### Текст (`text`)
|
||||
|
||||
- `add-text-png` — text, style (шрифт/размер/цвет/жирность), position (3×3),
|
||||
margin, plate (плашка, её цвет и прозрачность)
|
||||
- `date-stamp-png` — format, style, position, margin, plate
|
||||
- `watermark-tile-png` — text, style, opacity, angle, stepX, stepY
|
||||
|
||||
---
|
||||
|
||||
## 2. Можно добавить — из onlinepngtools
|
||||
|
||||
Всё из этого списка **в реестре отсутствует** — это план, а не факт. Список
|
||||
проверяется `web/src/lib/registry/tools-map-doc.test.ts` в обратную сторону:
|
||||
реализованный slug, оставшийся здесь, роняет тест.
|
||||
|
||||
### Разложение каналов — остаток
|
||||
|
||||
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → см. идеи в
|
||||
`docs/backlog.md`)
|
||||
|
||||
### Края и силуэт — остаток
|
||||
|
||||
- glow — radius, color, intensity
|
||||
- shadow — offsetX, offsetY, blur, color, alpha
|
||||
|
||||
### Эффекты — остаток
|
||||
|
||||
- censor-region / erase-region — область (MEDIUM: нужен UI выделения → см. идеи)
|
||||
- whirl — угол, центр, радиус (MEDIUM)
|
||||
|
||||
### Сортировка/блоки пикселей
|
||||
|
||||
- sort-pixels — blockSize, ключ (яркость/канал), направление (MEDIUM)
|
||||
- slow-reveal / fade-in / fade-out / disappear — анимационные (→ идеи)
|
||||
|
||||
### Генераторы — остаток
|
||||
|
||||
- multi-color-gradient — список стопов (нужен новый тип параметра → MEDIUM-UI)
|
||||
|
||||
### Конвертеры — остаток
|
||||
|
||||
- png-to-gif — MEDIUM (однокадровый GIF-энкодер руками)
|
||||
- gif-to-frames — MEDIUM (мультифайловый вывод → идеи)
|
||||
- change-bit-depth — MEDIUM (пересборка PNG)
|
||||
|
||||
### Сжатие и качество
|
||||
|
||||
- compress, reduce-to-size, pick-a-color, extract-barcode — **не реализованы**;
|
||||
заведены в `docs/backlog.md`. Сжатие до целевого размера — прежде всего
|
||||
селектор формата в Download (S1f, `docs/plan-seo.md` §4): для lossy это
|
||||
quality-ручка, а для PNG — квантование палитры. Нужен ли отдельный инструмент
|
||||
под этот интент, решается по вордстату, имя страницы пока не выбрано. Пипетка
|
||||
для выбора цвета уже есть в превью, отдельная страница не планируется.
|
||||
Извлечение штрихкодов — HARD, вне планов.
|
||||
|
||||
---
|
||||
|
||||
## 3. Идеи на рассмотрение (нужна архитектура или спорная ценность)
|
||||
|
||||
Развёрнутые описания этих идей живут в `docs/backlog.md`, раздел «Идеи»; здесь
|
||||
только список.
|
||||
|
||||
Буллиты этого раздела, как и раздела 2, перечисляют **несуществующие** страницы:
|
||||
уже реализованные в них не называются, иначе тест решит, что slug пора
|
||||
переносить в раздел 1. Реализованные примеры пишутся прозой между буллитами, как
|
||||
ниже.
|
||||
|
||||
- **Region-инструменты** — нужен selection-компонент на превью: censor-region,
|
||||
erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area.
|
||||
Один раз делаем selection — получаем шесть инструментов.
|
||||
- **Мультифайловый выход** — остальные потребители механизма «результат = набор
|
||||
файлов»: gif-to-frames, separate-colors, multiply-grid-as-files — подключаются
|
||||
по мере нужды.
|
||||
|
||||
Механизм уже реализован на `split-into-parts-png` (`docs/architecture.md`,
|
||||
раздел 7), он в разделе 1.
|
||||
|
||||
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
|
||||
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
|
||||
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
|
||||
extract-barcode.
|
||||
- **Нишевые серии** (logo/icon/stamp/signature — замер в шапке документа) —
|
||||
сознательно не копируем: это обычные операции над конкретным контентом, у нас
|
||||
они доступны через базовые инструменты + цепочки.
|
||||
- **Входные «из формата в PNG»** (jpg-to-png, webp-to-png) — решение и его
|
||||
SEO-последствия описаны в `docs/plan-seo.md` §5; отдельные страницы под эти
|
||||
интенты — предмет решения там же.
|
||||
@@ -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.5.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "pnpm --dir web test",
|
||||
"verify": "pnpm --dir web check && pnpm --dir web test:coverage && pnpm --dir web test:rules && pnpm --dir web lint:all && pnpm --dir web format:check && pnpm check:docs && pnpm --dir web build",
|
||||
"dev": "pnpm --dir web dev",
|
||||
"build": "pnpm --dir web build",
|
||||
"format": "pnpm format:docs && pnpm --dir web format",
|
||||
"format:docs": "prettier --write AGENTS.md README.md docs --log-level warn --cache",
|
||||
"check:docs": "prettier --check AGENTS.md README.md docs --cache",
|
||||
"generate:fixtures": "node tests/fixtures/manual-edge-cases/generate.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ku6epXBOCTuK",
|
||||
"license": "MIT",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "^11.20.0",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"prettier": "^3.9.6"
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const version = process.argv[2];
|
||||
if (!version) {
|
||||
console.error("usage: node scripts/sync-version.mjs <version>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const file of ["package.json", "web/package.json"]) {
|
||||
const raw = readFileSync(file, "utf8");
|
||||
const manifest = JSON.parse(raw);
|
||||
manifest.version = version;
|
||||
const indent = raw.match(/(?<=\n)([^\S\n]+)"/)?.[1] ?? 2;
|
||||
const eol = raw.endsWith("\n") ? "\n" : "";
|
||||
writeFileSync(file, JSON.stringify(manifest, null, indent) + eol);
|
||||
}
|
||||
|
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,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`);
|
||||
}
|
||||
|
||||
/** Builds a valid RGBA PNG at runtime (8-bit, filter type 0, deflate/zlib). */
|
||||
export function makePng(
|
||||
width: number,
|
||||
height: number,
|
||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||
): Buffer {
|
||||
const stride = width * 4 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 4;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
raw[p + 3] = a;
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk("IHDR", makeIhdr(width, height, 8, 6)),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function makeIndexedPng(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
pixelAt: (x: number, y: number) => number,
|
||||
): Buffer {
|
||||
const plte = Buffer.alloc(palette.length * 3);
|
||||
for (const [index, color] of palette.entries()) {
|
||||
plte[index * 3] = color[0];
|
||||
plte[index * 3 + 1] = color[1];
|
||||
plte[index * 3 + 2] = color[2];
|
||||
}
|
||||
const stride = width + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
raw[y * stride + 1 + x] = pixelAt(x, y);
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk("IHDR", makeIhdr(width, height, 8, 3)),
|
||||
chunk("PLTE", plte),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function make16BitPng(
|
||||
width: number,
|
||||
height: number,
|
||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||
): Buffer {
|
||||
const stride = width * 8 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 8;
|
||||
raw.writeUInt16BE(r, p);
|
||||
raw.writeUInt16BE(g, p + 2);
|
||||
raw.writeUInt16BE(b, p + 4);
|
||||
raw.writeUInt16BE(a, p + 6);
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk("IHDR", makeIhdr(width, height, 16, 6)),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function withPngChunk(png: Buffer, type: string, data: Buffer): Buffer {
|
||||
const iendOffset = png.length - 12;
|
||||
return Buffer.concat([
|
||||
png.subarray(0, iendOffset),
|
||||
chunk(type, data),
|
||||
png.subarray(iendOffset),
|
||||
]);
|
||||
}
|
||||
|
||||
export function withBadChunkCrc(png: Buffer, type: string): Buffer {
|
||||
const copy = Buffer.from(png);
|
||||
const offset = findChunk(copy, type);
|
||||
const length = copy.readUInt32BE(offset);
|
||||
copy[offset + 8 + length + 3] ^= 0xff;
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function truncatePng(png: Buffer, length: number): Buffer {
|
||||
return Buffer.from(png.subarray(0, length));
|
||||
}
|
||||
|
||||
export function truncateChunkData(
|
||||
png: Buffer,
|
||||
type: string,
|
||||
length: number,
|
||||
): Buffer {
|
||||
const offset = findChunk(png, type);
|
||||
return truncatePng(png, offset + 8 + length);
|
||||
}
|
||||
|
||||
export function withTrailingBytes(png: Buffer, tail: Buffer): Buffer {
|
||||
return Buffer.concat([png, Buffer.from(tail)]);
|
||||
}
|
||||
|
||||
const checker = (x: number, y: number): [number, number, number, number] =>
|
||||
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
|
||||
|
||||
const alphaGrid = (x: number, y: number): [number, number, number, number] =>
|
||||
(x + y) % 3 === 0 ? [0, 0, 0, 0] : [0, 128, 255, 255];
|
||||
|
||||
const solidRed = (): [number, number, number, number] => [255, 0, 0, 255];
|
||||
|
||||
export function asSourceFile(buffer: Buffer, name: string): SourceFile {
|
||||
return { name, mimeType: "image/png", buffer };
|
||||
}
|
||||
|
||||
export const opaquePng: SourceFile = asSourceFile(
|
||||
makePng(64, 48, checker),
|
||||
"opaque.png",
|
||||
);
|
||||
export const transparentPng: SourceFile = asSourceFile(
|
||||
makePng(64, 64, alphaGrid),
|
||||
"transparent.png",
|
||||
);
|
||||
export const onePixelPng: SourceFile = asSourceFile(
|
||||
makePng(1, 1, solidRed),
|
||||
"1px.png",
|
||||
);
|
||||
export const landscapePng: SourceFile = asSourceFile(
|
||||
makePng(64, 16, checker),
|
||||
"landscape.png",
|
||||
);
|
||||
export const largePng: SourceFile = asSourceFile(
|
||||
makePng(256, 256, checker),
|
||||
"large.png",
|
||||
);
|
||||
export const corruptPng: SourceFile = {
|
||||
name: "corrupt.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("this is definitely not a png file", "utf8"),
|
||||
};
|
||||
|
||||
const specialPng = makePng(8, 8, checker);
|
||||
const specialWithText = withPngChunk(
|
||||
specialPng,
|
||||
"tEXt",
|
||||
Buffer.from("Comment\0q6d", "ascii"),
|
||||
);
|
||||
const palette4 = [
|
||||
[255, 0, 0],
|
||||
[0, 255, 0],
|
||||
[0, 0, 255],
|
||||
[255, 255, 255],
|
||||
] as [number, number, number][];
|
||||
const palette256 = Array.from({ length: 256 }, (_, index) => [
|
||||
index,
|
||||
(index * 3) % 256,
|
||||
(index * 7) % 256,
|
||||
]) as [number, number, number][];
|
||||
|
||||
export const crcBadIdatPng = asSourceFile(
|
||||
withBadChunkCrc(specialPng, "IDAT"),
|
||||
"crc-bad-idat.png",
|
||||
);
|
||||
export const crcBadAncillaryPng = asSourceFile(
|
||||
withBadChunkCrc(specialWithText, "tEXt"),
|
||||
"crc-bad-ancillary.png",
|
||||
);
|
||||
export const truncatedHeaderPng = asSourceFile(
|
||||
truncatePng(specialPng, PNG_SIGNATURE.length + 12 + 13),
|
||||
"truncated-header.png",
|
||||
);
|
||||
export const truncatedNoIendPng = asSourceFile(
|
||||
truncatePng(specialPng, specialPng.length - 12),
|
||||
"truncated-no-iend.png",
|
||||
);
|
||||
export const idatCutPng = asSourceFile(
|
||||
truncateChunkData(specialPng, "IDAT", 4),
|
||||
"idat-cut.png",
|
||||
);
|
||||
export const garbageTailPng = asSourceFile(
|
||||
withTrailingBytes(specialPng, Buffer.from("trailing-garbage", "ascii")),
|
||||
"garbage-tail.png",
|
||||
);
|
||||
export const palette4Png = asSourceFile(
|
||||
makeIndexedPng(
|
||||
8,
|
||||
8,
|
||||
palette4,
|
||||
(x, y) => (Math.floor(x / 2) + Math.floor(y / 2)) % palette4.length,
|
||||
),
|
||||
"palette-4.png",
|
||||
);
|
||||
export const palette256Png = asSourceFile(
|
||||
makeIndexedPng(16, 16, palette256, (x, y) => (x + y * 16) % 256),
|
||||
"palette-256.png",
|
||||
);
|
||||
export const sixteenBitPng = asSourceFile(
|
||||
make16BitPng(8, 8, (x, y) => [x * 8000, y * 8000, 32768, 65535]),
|
||||
"16bit.png",
|
||||
);
|
||||
|
||||
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
|
||||
|
||||
export const svgMarkup =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6">' +
|
||||
'<rect width="10" height="6" fill="#ff0000"/></svg>';
|
||||
|
||||
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
|
||||
`${r} ${g} ${b} ${a}`;
|
||||
|
||||
/** Input row for bytes-to-png / rgb-values-to-png, 32 pixels per line by default. */
|
||||
export function pixelRow(
|
||||
count: number,
|
||||
rgba: [number, number, number, number],
|
||||
): string {
|
||||
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ru } from "../../src/lib/i18n/ru";
|
||||
|
||||
/** RU page title from the dictionary; RU text is never hardcoded in specs. */
|
||||
export function ruPageTitle(slug: string): string {
|
||||
const title = ru.pages?.[slug]?.title;
|
||||
if (!title) throw new Error(`ru dictionary has no pages.${slug}.title`);
|
||||
return title;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
expect,
|
||||
type Download,
|
||||
type Locator,
|
||||
type Page,
|
||||
} from "playwright/test";
|
||||
import type { SourceFile } from "./fixtures";
|
||||
|
||||
export const TEST_IDS = {
|
||||
source: "source-image",
|
||||
result: "result-image",
|
||||
verdict: "result-verdict",
|
||||
empty: "empty-state",
|
||||
} as const;
|
||||
|
||||
export type ErrorSink = { errors: string[] };
|
||||
|
||||
export function trackErrors(page: Page): ErrorSink {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") errors.push(`console: ${m.text()}`);
|
||||
});
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export async function useEnglish(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
});
|
||||
}
|
||||
|
||||
export async function openTool(page: Page, slug: string): Promise<void> {
|
||||
await useEnglish(page);
|
||||
await page.goto(`/tools/${slug}`);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
}
|
||||
|
||||
export function toolLink(page: Page, slug: string): Locator {
|
||||
return page.locator(`a[href="/tools/${slug}"]`);
|
||||
}
|
||||
|
||||
export function resultImage(page: Page): Locator {
|
||||
return page.getByTestId(TEST_IDS.result);
|
||||
}
|
||||
|
||||
export function sourceImage(page: Page): Locator {
|
||||
return page.getByTestId(TEST_IDS.source);
|
||||
}
|
||||
|
||||
export function verdictStatus(page: Page): Locator {
|
||||
return page.getByTestId(TEST_IDS.verdict);
|
||||
}
|
||||
|
||||
export function textResult(page: Page): Locator {
|
||||
return page.getByLabel("Text result");
|
||||
}
|
||||
|
||||
export function errorAlert(page: Page): Locator {
|
||||
return page.getByRole("alert");
|
||||
}
|
||||
|
||||
export function emptyState(page: Page): Locator {
|
||||
return page.getByTestId(TEST_IDS.empty);
|
||||
}
|
||||
|
||||
export function rangeInput(page: Page, name: RegExp | string): Locator {
|
||||
return page.getByRole("slider", { name });
|
||||
}
|
||||
|
||||
export async function renderText(page: Page, value: string): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Text data" });
|
||||
await input.fill(value);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
}
|
||||
|
||||
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
||||
// The page can have two input[type=file]: dropzone + Open image button;
|
||||
// the canonical one is the first (the button in the panel header).
|
||||
const input = page.locator('input[type="file"]').first();
|
||||
await expect(input).toBeAttached();
|
||||
await input.setInputFiles({
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
buffer: file.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadViaDrop(
|
||||
page: Page,
|
||||
file: SourceFile,
|
||||
): Promise<void> {
|
||||
const dataTransfer = await page.evaluateHandle(
|
||||
({
|
||||
bytes,
|
||||
name,
|
||||
mimeType,
|
||||
}: {
|
||||
bytes: number[];
|
||||
name: string;
|
||||
mimeType: string;
|
||||
}) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([new Uint8Array(bytes)], name, { type: mimeType }));
|
||||
return dt;
|
||||
},
|
||||
{
|
||||
bytes: Array.from(file.buffer),
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
},
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: /drop a png/i })
|
||||
.dispatchEvent("drop", { dataTransfer });
|
||||
}
|
||||
|
||||
export async function downloadResultFile(page: Page): Promise<Download> {
|
||||
// Split button: the main part is "Download" (image) or "Download ZIP" (files).
|
||||
const button = page.getByRole("button", { name: /^Download( ZIP)?$/ });
|
||||
await expect(button).toBeVisible();
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
button.click(),
|
||||
]);
|
||||
return download;
|
||||
}
|
||||
|
||||
export async function chooseDownloadFormat(
|
||||
page: Page,
|
||||
format: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Choose download format" }).click();
|
||||
await page.getByRole("menuitemradio", { name: format, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function downloadResultBytes(
|
||||
page: Page,
|
||||
): Promise<{ name: string; bytes: Uint8Array }> {
|
||||
const download = await downloadResultFile(page);
|
||||
const path = await download.path();
|
||||
if (!path) throw new Error("download path is unavailable");
|
||||
return {
|
||||
name: download.suggestedFilename(),
|
||||
bytes: new Uint8Array(await readFile(path)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadResult(page: Page): Promise<string> {
|
||||
return (await downloadResultFile(page)).suggestedFilename();
|
||||
}
|
||||
|
||||
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
||||
await expect(errorAlert(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
||||
expect(sink.errors, "no console/page errors").toEqual([]);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { pixelRow } from "./helpers/fixtures";
|
||||
import { ruPageTitle } from "./helpers/i18n";
|
||||
import { openTool, toolLink, useEnglish } from "./helpers/page";
|
||||
|
||||
const flipTitleRu = ruPageTitle("flip-png");
|
||||
|
||||
test("catalog search finds a tool by a title from another locale", async ({
|
||||
page,
|
||||
}) => {
|
||||
await useEnglish(page);
|
||||
await page.goto("/list-tools");
|
||||
const input = page.getByRole("textbox");
|
||||
const flipCard = toolLink(page, "flip-png");
|
||||
await input.fill(flipTitleRu.split(" ")[0].toLowerCase());
|
||||
await expect(flipCard).toContainText("Flip PNG");
|
||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||
await expect(flipCard).toContainText(flipTitleRu);
|
||||
await input.fill("flip");
|
||||
await expect(flipCard).toContainText(flipTitleRu);
|
||||
});
|
||||
|
||||
test("RU/EN: switching the locale translates the tool heading", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
|
||||
const enBtn = page.getByRole("button", { name: "EN", exact: true });
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
await ruBtn.click();
|
||||
await expect(heading).toHaveText(ruPageTitle("resize-png"));
|
||||
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
||||
await enBtn.click();
|
||||
await expect(heading).toHaveText("Resize PNG");
|
||||
});
|
||||
|
||||
test("run error is localized and translated on language switch", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "bytes-to-png");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Text data" })
|
||||
.fill(pixelRow(33, [255, 0, 0, 255]));
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
const alert = page.getByRole("alert");
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toHaveText(/\S/);
|
||||
await expect(alert).not.toHaveText(/^errors\./);
|
||||
const english = await alert.textContent();
|
||||
|
||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||
await expect(alert).not.toHaveText(english ?? "");
|
||||
await expect(alert).not.toHaveText(/^errors\./);
|
||||
});
|
||||
@@ -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,84 @@
|
||||
import { expect, test, type Page } from "playwright/test";
|
||||
import {
|
||||
crcBadAncillaryPng,
|
||||
crcBadIdatPng,
|
||||
garbageTailPng,
|
||||
idatCutPng,
|
||||
palette4Png,
|
||||
palette256Png,
|
||||
sixteenBitPng,
|
||||
truncatedHeaderPng,
|
||||
truncatedNoIendPng,
|
||||
type SourceFile,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
errorAlert,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
openTool,
|
||||
resultImage,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
} from "./helpers/page";
|
||||
|
||||
async function expectRejected(page: Page, file: SourceFile): Promise<void> {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, file);
|
||||
await expect(errorAlert(page)).toBeVisible();
|
||||
await expect(resultImage(page)).toHaveCount(0);
|
||||
expectNoErrors(sink);
|
||||
}
|
||||
|
||||
async function expectAccepted(page: Page, file: SourceFile): Promise<void> {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, file);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
}
|
||||
|
||||
type DecodeContract = {
|
||||
file: SourceFile;
|
||||
rejectedIn: readonly string[];
|
||||
};
|
||||
|
||||
const decodeContracts: readonly DecodeContract[] = [
|
||||
{ file: crcBadIdatPng, rejectedIn: ["chromium", "firefox"] },
|
||||
{ file: truncatedHeaderPng, rejectedIn: ["chromium", "firefox", "webkit"] },
|
||||
{ file: truncatedNoIendPng, rejectedIn: ["chromium"] },
|
||||
{ file: idatCutPng, rejectedIn: ["chromium"] },
|
||||
{ file: crcBadAncillaryPng, rejectedIn: [] },
|
||||
{ file: garbageTailPng, rejectedIn: [] },
|
||||
{ file: palette4Png, rejectedIn: [] },
|
||||
{ file: palette256Png, rejectedIn: [] },
|
||||
{ file: sixteenBitPng, rejectedIn: [] },
|
||||
];
|
||||
|
||||
// On these files createImageBitmap in webkit never settles: no result and no
|
||||
// error, so the "accept or reject" contract cannot be verified.
|
||||
const webkitHangs = new Set([
|
||||
"truncated-header.png",
|
||||
"crc-bad-ancillary.png",
|
||||
"palette-256.png",
|
||||
]);
|
||||
|
||||
test.describe("PNG special fixtures", () => {
|
||||
for (const { file, rejectedIn } of decodeContracts) {
|
||||
test(`${file.name}: matches the engine decode contract`, async ({
|
||||
browserName,
|
||||
page,
|
||||
}) => {
|
||||
test.fixme(
|
||||
browserName === "webkit" && webkitHangs.has(file.name),
|
||||
"webkit createImageBitmap hangs on this file",
|
||||
);
|
||||
if (rejectedIn.includes(browserName)) {
|
||||
await expectRejected(page, file);
|
||||
} else {
|
||||
await expectAccepted(page, file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,323 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
corruptPng,
|
||||
landscapePng,
|
||||
largePng,
|
||||
onePixelPng,
|
||||
opaquePng,
|
||||
transparentPng,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
chooseDownloadFormat,
|
||||
downloadResultBytes,
|
||||
errorAlert,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
openTool,
|
||||
rangeInput,
|
||||
resultImage,
|
||||
sourceImage,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
uploadViaDrop,
|
||||
useEnglish,
|
||||
} from "./helpers/page";
|
||||
|
||||
test("resize-png uses the source dimensions by default", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||
"64",
|
||||
);
|
||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||
"48",
|
||||
);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("resize-png keeps user-set size when another image is uploaded", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
const width = page.getByRole("spinbutton", { name: "Width" });
|
||||
await width.fill("25");
|
||||
// keepAspect is on by default: height is recomputed from 64×48 -> 19
|
||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||
"19",
|
||||
);
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(width).toHaveValue("25");
|
||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||
"19",
|
||||
);
|
||||
});
|
||||
|
||||
test("resize-png applies size preset chips", async ({ page }) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await page.getByRole("button", { name: "32×32" }).click();
|
||||
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||
"32",
|
||||
);
|
||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||
"32",
|
||||
);
|
||||
});
|
||||
|
||||
test("crop-png starts with the full source area", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "crop-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||
"64",
|
||||
);
|
||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||
"48",
|
||||
);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("flip-png: upload via drag-and-drop on the dropzone", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadViaDrop(page, opaquePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("flip-png: full flow - upload, result, download", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
const { name, bytes } = await downloadResultBytes(page);
|
||||
expect(name).toBe("flip-png.png");
|
||||
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
||||
137, 80, 78, 71, 13, 10, 26, 10,
|
||||
]);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("convert-png-to-jpg: produces JPEG and quality affects size", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "convert-png-to-jpg");
|
||||
await uploadImage(page, largePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
const quality = rangeInput(page, "Quality");
|
||||
await quality.fill("10");
|
||||
const low = await downloadResultBytes(page);
|
||||
expect(low.name).toBe("convert-png-to-jpg.jpg");
|
||||
expect(Array.from(low.bytes.subarray(0, 3))).toEqual([255, 216, 255]);
|
||||
await quality.fill("90");
|
||||
const high = await downloadResultBytes(page);
|
||||
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("convert-png-to-webp: produces WebP and quality affects size", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "convert-png-to-webp");
|
||||
await uploadImage(page, largePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
const quality = rangeInput(page, "Quality");
|
||||
await quality.fill("10");
|
||||
const low = await downloadResultBytes(page);
|
||||
expect(low.name).toBe("convert-png-to-webp.webp");
|
||||
expect(Array.from(low.bytes.subarray(0, 4))).toEqual([82, 73, 70, 70]);
|
||||
expect(Array.from(low.bytes.subarray(8, 12))).toEqual([87, 69, 66, 80]);
|
||||
await quality.fill("90");
|
||||
const high = await downloadResultBytes(page);
|
||||
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("download format selector: JPG warns about alpha and produces JPEG", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, transparentPng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await chooseDownloadFormat(page, "JPG");
|
||||
await expect(
|
||||
page.getByRole("status").filter({ hasText: /transparency/ }),
|
||||
).toBeVisible();
|
||||
// JPG has a quality setting in the dropdown
|
||||
await rangeInput(page, "Quality").fill("10");
|
||||
const { name, bytes } = await downloadResultBytes(page);
|
||||
expect(name).toBe("flip-png.jpg");
|
||||
expect(Array.from(bytes.subarray(0, 3))).toEqual([0xff, 0xd8, 0xff]);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("png-to-bmp: produces a BMP download", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "png-to-bmp");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
const { name, bytes } = await downloadResultBytes(page);
|
||||
expect(name).toBe("png-to-bmp.bmp");
|
||||
expect(Array.from(bytes.subarray(0, 2))).toEqual([66, 77]);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("reset restores defaults and re-runs with the source", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "blur-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
const radius = rangeInput(page, /radius/i);
|
||||
await radius.fill("20");
|
||||
await expect(radius).toHaveValue("20");
|
||||
|
||||
await page.getByRole("button", { name: "Reset", exact: true }).click();
|
||||
await expect(radius).toHaveValue("4");
|
||||
await expect(sourceImage(page)).toBeVisible();
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("split-into-parts-png downloads a zip with named PNG parts", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "split-into-parts-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await rangeInput(page, "Columns").fill("3");
|
||||
await rangeInput(page, "Rows").fill("2");
|
||||
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 }, testInfo) => {
|
||||
// newContext() does not inherit use.baseURL from the config - pass it
|
||||
// explicitly, otherwise the relative goto() in helpers does not resolve.
|
||||
const context = await browser.newContext({
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
});
|
||||
try {
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(window, "Worker", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await useEnglish(page);
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("chain: add step extends pipeline without reload and persists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Add step" }).last().click();
|
||||
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
|
||||
const stepTwo = page.getByRole("heading", {
|
||||
name: "Grayscale PNG",
|
||||
exact: true,
|
||||
});
|
||||
await expect(stepTwo).toBeVisible();
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
// the chain survives a reload (localStorage)
|
||||
await page.reload();
|
||||
await expect(stepTwo).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("chain: remove step returns to a single tool", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await page.getByRole("button", { name: "Add step" }).last().click();
|
||||
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
|
||||
const stepTwo = page.getByRole("heading", {
|
||||
name: "Grayscale PNG",
|
||||
exact: true,
|
||||
});
|
||||
await expect(stepTwo).toBeVisible();
|
||||
// Click remove in the Grayscale card specifically: step order depends on
|
||||
// render timing and can differ between engines.
|
||||
const card = page.getByRole("article").filter({ hasText: "Grayscale PNG" });
|
||||
await card.getByLabel("Remove step").click();
|
||||
await expect(stepTwo).toHaveCount(0);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, corruptPng);
|
||||
await expect(errorAlert(page)).toBeVisible();
|
||||
await expect(resultImage(page)).toHaveCount(0);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
for (const fixture of [onePixelPng, largePng]) {
|
||||
test(`handles ${fixture.name} correctly`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, fixture);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
@@ -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: tolerates the hydration race (input before SvelteKit
|
||||
// hydration may not be processed on the first attempt).
|
||||
await expect(async () => {
|
||||
await openTool(page, id);
|
||||
await uploadImage(page, fixture ?? opaquePng);
|
||||
await expect(outputLocator()).toBeVisible({ timeout: 8_000 });
|
||||
}).toPass({ timeout: 25_000 });
|
||||
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
# ESLint-плагины и дизайн-токены: детали
|
||||
|
||||
> Статус: **active**. Область применения — `web/eslint-plugins/**`,
|
||||
> `web/scripts/**`, конфиги линтинга. Остальная рутина разработки её не требует;
|
||||
> детали нужны только при правке самих линт-правил или конфигов. Команды и
|
||||
> пороги — `docs/quality-gates.md`.
|
||||
|
||||
## Структура
|
||||
|
||||
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
||||
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
||||
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
||||
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
||||
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
||||
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
||||
токенов) и мини-словари `lib/i18n/`.
|
||||
- `web/scripts/`:
|
||||
- `lint-all.mjs` — оркестратор `lint:all`.
|
||||
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
||||
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
|
||||
- `__tests__/` — тесты аудита и HCT-плагина; подхватываются обычным
|
||||
`vitest run` через `scripts/**/*.test.mjs`, поэтому входят в `verify`.
|
||||
- `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css.
|
||||
|
||||
## Конфигурация ESLint (`web/eslint.config.js`)
|
||||
|
||||
Устроена инкрементально:
|
||||
|
||||
- На **весь код** — парсинг TS/Svelte + правило
|
||||
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
|
||||
`prefer: 'type-imports'`).
|
||||
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
|
||||
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
|
||||
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
|
||||
устаревшие пути в `eslint.config.js`.
|
||||
- Базовый `js.configs.recommended` + `globals.node` навешаны на инфраструктуру
|
||||
линтинга: `eslint-plugins/**/*.js` и `scripts/**/*.mjs`.
|
||||
TS/svelte-рекомендации туда не подключаются.
|
||||
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
||||
`let`).
|
||||
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
||||
там запрещены tseslint) — при добавлении файлов в `__tests__/` /
|
||||
`__fixtures__/` дописать их туда же.
|
||||
- design-tokens правила применяются к тем же scoped-путям
|
||||
(`src/lib/components/**`, `src/routes/**`).
|
||||
- `i18n/no-hardcoded-user-text` применяется к обоим scoped-путям
|
||||
(`src/lib/components/**` и `src/routes/**`): хардкод в `+page.svelte` и
|
||||
`+layout.svelte` — такой же production-UI. Исключение одно — `/kit`, витрина с
|
||||
намеренно латинскими подписями; выключается отдельным объектом конфига с
|
||||
`files: ["**/src/routes/kit/**/*.svelte"]`, потому что негативные паттерны в
|
||||
`files` не сужают область действия правила. Опция `allowWords` — brand не
|
||||
хардкодится в правиле.
|
||||
|
||||
## Правила плагина design-tokens
|
||||
|
||||
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
|
||||
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
|
||||
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
|
||||
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
|
||||
color-токен (`--color-*`/`--brand-*`) и наоборот.
|
||||
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
|
||||
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
|
||||
производные от токенов (`var()`, `calc()`, unitless-числа).
|
||||
- `design-tokens/no-undefined-in-svelte` — `var(--x)` в `<style>` должен быть
|
||||
определён в `src/app.css` (словарь токенов) или локально в компоненте. Файл
|
||||
читается один раз и кэшируется (не `glob`-зависим).
|
||||
|
||||
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
||||
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
||||
|
||||
## Плагин i18n (`i18n/`)
|
||||
|
||||
### `i18n/dict-consistency`
|
||||
|
||||
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
||||
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
||||
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
|
||||
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
|
||||
правила.
|
||||
|
||||
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
||||
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
||||
расхождения:
|
||||
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
|
||||
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
|
||||
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
|
||||
пропавшая целая секция репортится один раз (не по каждому потомку);
|
||||
- **пустые значения**: `""` и строка из одних пробелов;
|
||||
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
||||
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
|
||||
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
|
||||
когда отклоняется одна-единственная локаль.
|
||||
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
||||
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
||||
(синтакс, без типов).
|
||||
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
||||
(осознанные отклонения до достижения zero-warn).
|
||||
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
|
||||
отсутствовать только в базовой локали; в текущей конфигурации это тексты
|
||||
страниц `pages.*.title` и `pages.*.description` (EN-строка берётся из
|
||||
реестра). Пустые значения и placeholders для присутствующих ключей всё равно
|
||||
проверяются.
|
||||
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
|
||||
проверяются на missing; в текущей конфигурации это неиспользуемые
|
||||
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
|
||||
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||
диске.
|
||||
|
||||
### `i18n/no-hardcoded-user-text`
|
||||
|
||||
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
|
||||
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
|
||||
report-only (`warn`), baseline — 0 срабатываний.
|
||||
|
||||
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
|
||||
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
|
||||
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
|
||||
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
|
||||
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
|
||||
скобках (`{expr}`) тоже — они считаются в другом месте.
|
||||
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
|
||||
имена файлов и версии (`result.png`, `v0.1.0`, `v1`), размеры (`512×512`),
|
||||
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
|
||||
`/kit` — витрина компонентов с намеренно латинскими подписями (исключение
|
||||
живёт в `eslint.config.js`, а не в правиле).
|
||||
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
|
||||
продукта передаётся конфигом, а не зашито в правило.
|
||||
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
|
||||
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
|
||||
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
|
||||
|
||||
## Плагин conventions: текстовые конвенции
|
||||
|
||||
- `conventions/ascii-only` — в файлах, заданных scope'ом конфига (`*.test.ts`,
|
||||
`*.spec.ts`, `e2e/**`), только ASCII: типографика и неанглийский текст в
|
||||
`describe`/`it` и ожиданиях мешают поиску и скрывают хардкод локализованных
|
||||
строк. Осознанные глифы (например `×` в пайплайне) — через опцию `allowChars`
|
||||
в `eslint.config.js`. Один репорт на строку.
|
||||
- `conventions/comments-english` — комментарии только на английском, по **белому
|
||||
списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности
|
||||
(кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы —
|
||||
через опцию `allowChars`. Строковые литералы не трогаем — локализованные
|
||||
значения легальны в словарях.
|
||||
- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по
|
||||
умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15)
|
||||
— доля комментарных строк от непустых строк файла; density проверяется только
|
||||
в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в
|
||||
маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу
|
||||
кода, а не к сжатию комментария.
|
||||
|
||||
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
|
||||
`*.config.*` живут по тем же конвенциям (dogfooding). Фикстуры и тесты правил
|
||||
(`__fixtures__/`, `__tests__/`) — намеренные входные кейсы и из scope исключены.
|
||||
Все три правила — `error`, baseline нулевой. Русские ожидания в тестах не
|
||||
хардкодятся: значения берутся из словарей `lib/i18n/` (см.
|
||||
`src/lib/i18n/*.test.ts` и `e2e/helpers/i18n.ts`). Тесты правил:
|
||||
`__tests__/ascii-only.test.ts`, `__tests__/comments-english.test.ts`,
|
||||
`__tests__/comment-format.test.ts`.
|
||||
|
||||
## Токены-префиксы (целевой словарь дизайна)
|
||||
|
||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
||||
переменные, которым разрешено быть hex/rgb, остальные цвета — только `hct()` и
|
||||
только в app.css.
|
||||
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
||||
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
|
||||
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в app.css,
|
||||
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
|
||||
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
|
||||
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
|
||||
подтягиваются через `@csstools/postcss-global-data`).
|
||||
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
|
||||
|
||||
Нюансы stylelint:
|
||||
|
||||
- Правило `custom-property-pattern` тестирует паттерн **без** `--` (`--x` →
|
||||
`x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
|
||||
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
|
||||
`ignore`, не в `except`): пустые строки между подряд идущими токенами
|
||||
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
|
||||
токены в app.css отдельными блоками.
|
||||
|
||||
## Токен-аудит (`check-tokens.mjs`)
|
||||
|
||||
Лёгкий оркестратор поверх `web/scripts/token-audit/*`. Проверяет:
|
||||
|
||||
- **Parity**: каждый цветовой токен из `:root` обязан иметь пару в
|
||||
`[data-theme="dark"]` и наоборот. Производные токены (значение содержит
|
||||
`var()`, напр. `hct(from var(--...))`) из пары исключены — они наследуют тему
|
||||
автоматически.
|
||||
- **hct-only для цветов**: ЛЮБОЕ цветовое значение в app.css обязано быть
|
||||
`hct(...)` — и литерал, и производное `hct(from var(...) h c t)`. Исключения:
|
||||
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
|
||||
`color-mix(...)` (единственный легальный способ смешать два токена).
|
||||
`oklch()/rgb()/#hex` в `--color-*` запрещены.
|
||||
|
||||
- **Резолв `var()`**: каждая ссылка `var(--x)` в app.css должна указывать на
|
||||
токен, определённый в этом же файле. `var(--x, fallback)` не считается ошибкой
|
||||
— fallback легален без определения. Провал (exit 1), потому что несуществующий
|
||||
токен молча роняет объявление.
|
||||
- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не
|
||||
используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`,
|
||||
поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение
|
||||
оставить проверку неблокирующей — `docs/decisions.md`, раздел 6 (как оно
|
||||
выглядит в гейте — `docs/quality-gates.md`). Провалами (exit 1) считаются
|
||||
parity, hct-авторство и резолв `var()`.
|
||||
|
||||
## Тесты кастомных линт-правил
|
||||
|
||||
Линт-правила покрыты юнит-тестами (Vitest, `test:rules`):
|
||||
|
||||
- `__tests__/design-tokens.test.ts` — три чистых правила
|
||||
(`no-hardcoded-in-svelte`, `no-category-mismatch`,
|
||||
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
|
||||
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
|
||||
`__fixtures__/src/app.css` (не из реального `src/app.css`).
|
||||
- `__tests__/interface-props.test.ts` и
|
||||
`__tests__/no-string-union-alias.test.ts` — конвенции плагина `conventions`
|
||||
(интерфейс пропсов, запрет union-алиасов).
|
||||
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
|
||||
`process.cwd()` не трогается, реальные `src/` не читаются.
|
||||
|
||||
Запуск: `pnpm --dir web test:rules` (`vitest run eslint-plugins/__tests__`).
|
||||
Полный `pnpm --dir web test` тоже их гоняет.
|
||||
|
||||
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
|
||||
добавление/правка стабов — тоже работа с валидным кодом.
|
||||
@@ -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,28 @@
|
||||
import type { Dict } from "./dict";
|
||||
|
||||
export const de: Dict = {
|
||||
header: {
|
||||
workspace: "Arbeitsbereich",
|
||||
catalog: "Katalog",
|
||||
},
|
||||
home: {
|
||||
hero: "Hallo {name}!",
|
||||
restoreLast: "{title} wiederherstellen",
|
||||
},
|
||||
errors: {
|
||||
sourceRequired: "Quelle erforderlich",
|
||||
},
|
||||
pages: {
|
||||
addBorder: {
|
||||
title: "Rahmen hinzufügen",
|
||||
description: "Zeichnet einen {kind}-Rahmen.",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
addBorder: {
|
||||
results: {
|
||||
done: "Rahmen {what}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export const LOCALES = ["en", "ru", "de"] as const;
|
||||
|
||||
export const BASE_LOCALE: "en" = "en";
|
||||
|
||||
export type Dict = {
|
||||
header: Record<string, string>;
|
||||
home: Record<string, string>;
|
||||
errors: Record<string, string>;
|
||||
pages?: Record<string, PageStrings>;
|
||||
tools: Record<string, ToolStrings>;
|
||||
};
|
||||
|
||||
export type PageStrings = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ToolStrings = {
|
||||
results?: Record<string, string>;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Dict } from "./dict";
|
||||
|
||||
export const en: Dict = {
|
||||
header: {
|
||||
workspace: "Workspace",
|
||||
catalog: "Catalog",
|
||||
},
|
||||
home: {
|
||||
hero: "Hello {name}!",
|
||||
restoreLast: "Restore {title}",
|
||||
},
|
||||
errors: {
|
||||
sourceRequired: "Source image required",
|
||||
},
|
||||
// Базовая локаль: тексты страниц берутся из EN-строк реестра.
|
||||
pages: {},
|
||||
tools: {
|
||||
addBorder: {
|
||||
results: {
|
||||
done: "Border {what}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Dict } from "./dict";
|
||||
|
||||
export const ru: Dict = {
|
||||
header: {
|
||||
workspace: "Рабочая область",
|
||||
catalog: "Каталог",
|
||||
},
|
||||
home: {
|
||||
hero: "Привет, {name}!",
|
||||
restoreLast: "Вернуть {title}",
|
||||
},
|
||||
errors: {
|
||||
sourceRequired: "Нужен исходник",
|
||||
},
|
||||
pages: {
|
||||
addBorder: {
|
||||
title: "Добавить рамку",
|
||||
description: "Рисует рамку {kind}.",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
addBorder: {
|
||||
results: {
|
||||
done: "Рамка {what}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,79 @@
|
||||
// Tests for the conventions/ascii-only rule
|
||||
// (web/eslint-plugins/conventions/ascii-only.js).
|
||||
//
|
||||
// Pure file-content check, no filesystem: standard RuleTester with the
|
||||
// tseslint parser. The scoping to test files lives in eslint.config.js, so
|
||||
// filenames here are arbitrary.
|
||||
import { RuleTester } from "eslint";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, it } from "vitest";
|
||||
import asciiOnly from "../conventions/ascii-only.js";
|
||||
import { asRuleModule } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const tester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
});
|
||||
|
||||
tester.run("ascii-only", asRuleModule(asciiOnly), {
|
||||
valid: [
|
||||
{ code: "const x = 1;", filename: "a.test.ts" },
|
||||
{
|
||||
code: "describe('resize pipeline', () => {});",
|
||||
filename: "a.test.ts",
|
||||
},
|
||||
{
|
||||
// Glyphs listed in allowChars pass.
|
||||
code: "expect(label).toBe('512×512');",
|
||||
filename: "a.test.ts",
|
||||
options: [{ allowChars: ["×"] }],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "it('ресайзит картинку', () => {});",
|
||||
filename: "a.test.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
code: "// arrow → here\nconst x = 1;",
|
||||
filename: "a.test.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// Em dash and ellipsis on one line collapse to a single report.
|
||||
code: "it('a — b … c', () => {});",
|
||||
filename: "a.test.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// Two offending lines produce two reports.
|
||||
code: "// über\n// café\nconst x = 1;",
|
||||
filename: "a.test.ts",
|
||||
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// allowChars does not whitelist other glyphs on the same line.
|
||||
code: "expect(s).toBe('512×512 → done');",
|
||||
filename: "a.test.ts",
|
||||
options: [{ allowChars: ["×"] }],
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// Control characters (here: vertical tab) are not printable ASCII.
|
||||
code: "const s = '\x0B';",
|
||||
filename: "a.test.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// DEL (U+007F) is not printable either.
|
||||
code: "const s = '\x7F';",
|
||||
filename: "a.test.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// Tests for the conventions/comment-format rule
|
||||
// (web/eslint-plugins/conventions/comment-format.js).
|
||||
//
|
||||
// Pure AST check (comment nodes + line counts), standard RuleTester.
|
||||
import { RuleTester } from "eslint";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, it } from "vitest";
|
||||
import commentFormat from "../conventions/comment-format.js";
|
||||
import { asRuleModule } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const tester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
});
|
||||
|
||||
const longBlock = [
|
||||
"// one",
|
||||
"// two",
|
||||
"// three",
|
||||
"// four",
|
||||
"// five",
|
||||
"// six",
|
||||
"const x = 1;",
|
||||
].join("\n");
|
||||
|
||||
tester.run("comment-format", asRuleModule(commentFormat), {
|
||||
// Density is off (maxRatio: 1) in the block-focused cases: tiny snippets
|
||||
// are comment-dominated by construction.
|
||||
valid: [
|
||||
{
|
||||
code: "// why\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
options: [{ maxRatio: 1 }],
|
||||
},
|
||||
{
|
||||
code: "// a\n// b\n// c\n// d\n// e\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
options: [{ maxRatio: 1 }],
|
||||
},
|
||||
{
|
||||
// Blank line breaks the run: two 3-line blocks, both under the cap.
|
||||
code: "// a\n// b\n// c\n\n// d\n// e\n// f\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
options: [{ maxRatio: 1 }],
|
||||
},
|
||||
{
|
||||
// Trailing inline comment does not extend the block above it.
|
||||
code: "// a\n// b\nconst x = 1; // tail",
|
||||
filename: "a.ts",
|
||||
options: [{ maxRatio: 1 }],
|
||||
},
|
||||
{
|
||||
// A wider block cap admits the same comment.
|
||||
code: longBlock,
|
||||
filename: "a.ts",
|
||||
options: [{ maxBlockLines: 6, maxRatio: 1 }],
|
||||
},
|
||||
{
|
||||
// Density is skipped below minLines: small files are exempt.
|
||||
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
|
||||
filename: "a.ts",
|
||||
options: [{ maxBlockLines: 5, maxRatio: 0.4, minLines: 10 }],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: longBlock,
|
||||
filename: "a.ts",
|
||||
options: [{ maxRatio: 1 }],
|
||||
errors: [{ messageId: "blockTooLong" }],
|
||||
},
|
||||
{
|
||||
// A single block comment counts its full line span.
|
||||
code: "/*\n1\n2\n3\n4\n5\n6\n*/\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
options: [{ maxRatio: 1 }],
|
||||
errors: [{ messageId: "blockTooLong" }],
|
||||
},
|
||||
{
|
||||
// Density: 2 comment lines against 2 code lines is 50%.
|
||||
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
|
||||
filename: "a.ts",
|
||||
options: [{ maxBlockLines: 5, maxRatio: 0.4 }],
|
||||
errors: [{ messageId: "tooDense" }],
|
||||
},
|
||||
{
|
||||
// Both violations in one file produce two reports.
|
||||
code: longBlock + "\n// extra",
|
||||
filename: "a.ts",
|
||||
options: [{ maxBlockLines: 5, maxRatio: 0.5 }],
|
||||
errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// Tests for the conventions/comments-english rule
|
||||
// (web/eslint-plugins/conventions/comments-english.js).
|
||||
//
|
||||
// Pure AST check over comment nodes with an ASCII whitelist; string literals
|
||||
// are ignored by construction (they are not comments), which the cases below
|
||||
// pin down.
|
||||
import { RuleTester } from "eslint";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, it } from "vitest";
|
||||
import commentsEnglish from "../conventions/comments-english.js";
|
||||
import { asRuleModule } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const tester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
});
|
||||
|
||||
tester.run("comments-english", asRuleModule(commentsEnglish), {
|
||||
valid: [
|
||||
{ code: "// resize the canvas\nconst x = 1;", filename: "a.ts" },
|
||||
{ code: "/* multi\nline */\nconst x = 1;", filename: "a.ts" },
|
||||
{
|
||||
// Localized strings are dictionary content, not comments.
|
||||
code: "const title = 'Пример';",
|
||||
filename: "a.ts",
|
||||
},
|
||||
{
|
||||
// Glyphs listed in allowChars pass.
|
||||
code: "// size 512×512\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
options: [{ allowChars: ["×"] }],
|
||||
},
|
||||
{
|
||||
// A tab inside a comment is legitimate whitespace.
|
||||
code: "//\tnote\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "// ресайз холста\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// Whitelist catches scripts beyond Cyrillic too.
|
||||
code: "// 调整画布大小\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
code: "// تغيير حجم اللوحة\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// Typography in an otherwise English comment is still non-ASCII.
|
||||
code: "// resize — then crop\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
code: "/* много\nстрочный */\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
code: "const x = 1; // хвостовой комментарий",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// One report per offending comment, not per file.
|
||||
code: "// раз\n// два\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// allowChars does not whitelist other glyphs in the same comment.
|
||||
code: "// 512×512 → done\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
options: [{ allowChars: ["×"] }],
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
{
|
||||
// Control characters (here: vertical tab) are not printable ASCII.
|
||||
code: "// \x0B note\nconst x = 1;",
|
||||
filename: "a.ts",
|
||||
errors: [{ messageId: "nonAscii" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// Tests for the design-tokens plugin rules (web/eslint-plugins/design-tokens).
|
||||
//
|
||||
// Strategy:
|
||||
// - no-hardcoded-in-svelte, no-category-mismatch, no-token-definition-in-svelte
|
||||
// are pure AST checks -> standard RuleTester with string cases.
|
||||
// - no-undefined-in-svelte reads the token dictionary from <cwd>/src/app.css,
|
||||
// so it runs through the Linter API with cwd pinned to the fixtures dir
|
||||
// (see __tests__/helpers.ts). The fixture dictionary has exactly five
|
||||
// tokens; anything else must be reported even if it exists in the REAL
|
||||
// web/src/app.css.
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import noCategoryMismatch from "../design-tokens/no-category-mismatch.js";
|
||||
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
|
||||
import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js";
|
||||
import noUndefined from "../design-tokens/no-undefined-in-svelte.js";
|
||||
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
|
||||
|
||||
const parserOptions = {
|
||||
parser: tseslint.parser,
|
||||
};
|
||||
|
||||
// Vitest doesn't expose describe/it as globals (no `globals: true` in the
|
||||
// config), so RuleTester would fall back to its synchronous default handler.
|
||||
// Register the real ones so each case becomes a proper vitest test.
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions,
|
||||
},
|
||||
});
|
||||
|
||||
/** Wrap CSS from a <style> block into a minimal Svelte component. */
|
||||
const component = (style: string): string =>
|
||||
`<div class="box">x</div>\n\n<style>\n${style}\n</style>\n`;
|
||||
|
||||
const frame = (style: string) => ({
|
||||
code: component(style),
|
||||
filename: "Component.svelte",
|
||||
});
|
||||
|
||||
describe("design-tokens/no-hardcoded-in-svelte", () => {
|
||||
ruleTester.run("no-hardcoded-in-svelte", asRuleModule(noHardcoded), {
|
||||
valid: [
|
||||
frame(".box { color: var(--color-fg); }"),
|
||||
frame(".box { padding: var(--space-3); }"),
|
||||
frame(".box { background-color: transparent; }"),
|
||||
frame(".box { width: 100%; }"),
|
||||
frame(".box { line-height: 1.5; }"),
|
||||
frame(".box { transition: var(--duration-fast); }"),
|
||||
frame(".box { z-index: var(--z-nav); }"),
|
||||
frame(".box { font-size: var(--text-md); }"),
|
||||
frame("<div>no style block at all</div>"),
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
...frame(".box { color: #ff0000; }"),
|
||||
errors: [{ messageId: "hardcodedColor" }],
|
||||
},
|
||||
{
|
||||
...frame(".box { padding: 16px; }"),
|
||||
errors: [{ messageId: "hardcodedSize" }],
|
||||
},
|
||||
{
|
||||
...frame(".box { transition: 200ms; }"),
|
||||
errors: [{ messageId: "hardcodedDuration" }],
|
||||
},
|
||||
{
|
||||
...frame(".box { z-index: 100; }"),
|
||||
errors: [{ messageId: "hardcodedZIndex" }],
|
||||
},
|
||||
{
|
||||
...frame(
|
||||
"@media (max-width: 640px) { .box { width: var(--text-md); } }",
|
||||
),
|
||||
errors: [{ messageId: "hardcodedBreakpoint" }],
|
||||
},
|
||||
{
|
||||
...frame(
|
||||
"@media (min-width: var(--bp-m)) { .box { width: var(--text-md); } }",
|
||||
),
|
||||
errors: [{ messageId: "varInMedia" }],
|
||||
},
|
||||
{
|
||||
...frame(
|
||||
".box { color: color-mix(in srgb, var(--color-a), var(--color-b)); }",
|
||||
),
|
||||
errors: [{ messageId: "colorMix" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("design-tokens/no-category-mismatch", () => {
|
||||
ruleTester.run("no-category-mismatch", asRuleModule(noCategoryMismatch), {
|
||||
valid: [
|
||||
frame(".box { padding: var(--space-1); }"),
|
||||
frame(".box { color: var(--color-fg); }"),
|
||||
frame(".box { border: 1px solid var(--color-border); }"),
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
...frame(".box { padding: var(--color-fg); }"),
|
||||
errors: [{ messageId: "categoryMismatch" }],
|
||||
},
|
||||
{
|
||||
...frame(".box { color: var(--space-1); }"),
|
||||
errors: [{ messageId: "categoryMismatch" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("design-tokens/no-token-definition-in-svelte", () => {
|
||||
ruleTester.run(
|
||||
"no-token-definition-in-svelte",
|
||||
asRuleModule(noTokenDefinition),
|
||||
{
|
||||
valid: [
|
||||
frame(".box { --local: var(--color-fg); }"),
|
||||
frame(".box { --local: calc(var(--space-1) * 2); }"),
|
||||
frame(".box { --ratio: 1.5; }"),
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
...frame(".box { --local: #ff0000; }"),
|
||||
errors: [{ messageId: "tokenPrimitive" }],
|
||||
},
|
||||
{
|
||||
...frame(".box { --local: 12px; }"),
|
||||
errors: [{ messageId: "tokenPrimitive" }],
|
||||
},
|
||||
{
|
||||
...frame(".box { --local: oklch(0.5 0.1 240); }"),
|
||||
errors: [{ messageId: "tokenPrimitive" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("design-tokens/no-undefined-in-svelte", () => {
|
||||
const undefinedConfig: FlatConfig = [
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
plugins: {
|
||||
"design-tokens": {
|
||||
rules: { "no-undefined-in-svelte": asRuleModule(noUndefined) },
|
||||
},
|
||||
},
|
||||
rules: { "design-tokens/no-undefined-in-svelte": "error" },
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it("accepts tokens defined in the fixture app.css", () => {
|
||||
const messages = verifyInFixtures(
|
||||
undefinedConfig,
|
||||
component(".box { color: var(--color-fg); }"),
|
||||
"lib/components/Foo.svelte",
|
||||
);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts component-local overrides", () => {
|
||||
const messages = verifyInFixtures(
|
||||
undefinedConfig,
|
||||
component(".box { --local-x: var(--color-fg); color: var(--local-x); }"),
|
||||
"lib/components/Foo.svelte",
|
||||
);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags an unknown token even if it exists in the real web/src/app.css", () => {
|
||||
const messages = verifyInFixtures(
|
||||
undefinedConfig,
|
||||
component(".box { color: var(--color-love); }"),
|
||||
"lib/components/Foo.svelte",
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].messageId).toBe("undefinedToken");
|
||||
expect(messages[0].message).toContain("--color-love");
|
||||
});
|
||||
|
||||
it("flags unknown tokens referenced inside @media params", () => {
|
||||
const messages = verifyInFixtures(
|
||||
undefinedConfig,
|
||||
component(
|
||||
"@media (width >= var(--bp-xs)) { .box { width: var(--text-md); } }",
|
||||
),
|
||||
"lib/components/Foo.svelte",
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].messageId).toBe("undefinedToken");
|
||||
expect(messages[0].message).toContain("--bp-xs");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// Tests for i18n/dict-consistency (web/eslint-plugins/i18n/dict-consistency.js).
|
||||
//
|
||||
// The rule reads its own dict dir from disk (LOCALES in dict.ts + sibling
|
||||
// locale files), so it runs through the Linter API with cwd pinned to the
|
||||
// fixture tree (see __tests__/helpers.ts). The committed fixtures at
|
||||
// __fixtures__/src/lib/i18n/ are mutually consistent (zero-warn) — tests feed
|
||||
// MODIFIED variants of en.ts / ru.ts as the linted code so each check is
|
||||
// exercised against the on-disk references.
|
||||
//
|
||||
// Reporting contract: each file reports only its OWN gaps. A key missing in a
|
||||
// file produces exactly ONE `missingKey` warning, emitted on the incomplete
|
||||
// file itself (the union of all keys comes from the on-disk dicts), regardless
|
||||
// of how many other locales have the key. Placeholder parity is compared
|
||||
// against the canonical set — the one shared by the most locales (ties by
|
||||
// LOCALES order) — so a deviation is reported exactly once.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import tseslint from "typescript-eslint";
|
||||
import dictConsistency from "../i18n/dict-consistency.js";
|
||||
import {
|
||||
asRuleModule,
|
||||
FIXTURES_SRC,
|
||||
verifyInFixtures,
|
||||
type FlatConfig,
|
||||
} from "./helpers.js";
|
||||
|
||||
const I18N_DIR = path.join(FIXTURES_SRC, "lib", "i18n");
|
||||
|
||||
const fixture = (name: string) =>
|
||||
fs.readFileSync(path.join(I18N_DIR, name), "utf8");
|
||||
|
||||
function lint(code: string, relFile: string, options?: unknown) {
|
||||
const rule = options ? ["warn", options] : "warn";
|
||||
const config: FlatConfig = [
|
||||
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
plugins: {
|
||||
i18n: {
|
||||
rules: { "dict-consistency": asRuleModule(dictConsistency) },
|
||||
},
|
||||
},
|
||||
rules: { "i18n/dict-consistency": rule as "warn" },
|
||||
},
|
||||
];
|
||||
return verifyInFixtures(config, code, relFile);
|
||||
}
|
||||
|
||||
function messageIds(messages: { messageId?: string }[]) {
|
||||
return messages.map((m) => m.messageId);
|
||||
}
|
||||
|
||||
const registryPolicy = {
|
||||
baseLocaleFallback: ["pages.*.title", "pages.*.description"],
|
||||
ignoreMissingPatterns: ["tools.*.params"],
|
||||
};
|
||||
|
||||
describe("i18n/dict-consistency", () => {
|
||||
it("stays clean on the consistent fixture dicts", () => {
|
||||
expect(
|
||||
messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts", registryPolicy)),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
messageIds(lint(fixture("ru.ts"), "lib/i18n/ru.ts", registryPolicy)),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts", registryPolicy)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows page texts only in the base locale", () => {
|
||||
// EN не переводит заголовки страниц: их берёт EN-строка реестра.
|
||||
expect(
|
||||
messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts", registryPolicy)),
|
||||
).toEqual([]);
|
||||
|
||||
const ruMissing = fixture("ru.ts").replace(
|
||||
'\t\t\ttitle: "Добавить рамку",\n',
|
||||
"",
|
||||
);
|
||||
expect(
|
||||
messageIds(lint(ruMissing, "lib/i18n/ru.ts", registryPolicy)),
|
||||
).toEqual(["missingKey"]);
|
||||
});
|
||||
|
||||
it("does not warn for a tool object with nothing but ignored results", () => {
|
||||
const enMissingTool = fixture("en.ts").replace(
|
||||
/\t\taddBorder: \{[\s\S]*?\n\t\t\},\n/,
|
||||
"",
|
||||
);
|
||||
expect(
|
||||
messageIds(
|
||||
lint(enMissingTool, "lib/i18n/en.ts", {
|
||||
...registryPolicy,
|
||||
ignoreMissingPatterns: [
|
||||
...registryPolicy.ignoreMissingPatterns,
|
||||
"tools.*.results",
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for configured legacy missing paths", () => {
|
||||
const enMissingResults = fixture("en.ts").replace(
|
||||
/\t\t\tresults: \{[\s\S]*?\n\t\t\t\},\n/,
|
||||
"",
|
||||
);
|
||||
expect(
|
||||
messageIds(
|
||||
lint(enMissingResults, "lib/i18n/en.ts", {
|
||||
...registryPolicy,
|
||||
ignoreMissingPatterns: ["tools.*.results"],
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still reports empty page text", () => {
|
||||
const ruEmpty = fixture("ru.ts").replace(
|
||||
'title: "Добавить рамку",',
|
||||
'title: "",',
|
||||
);
|
||||
expect(messageIds(lint(ruEmpty, "lib/i18n/ru.ts", registryPolicy))).toEqual(
|
||||
["emptyValue"],
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a leaf key missing in the current file", () => {
|
||||
const enMissing = fixture("en.ts").replace(
|
||||
'\t\trestoreLast: "Restore {title}",',
|
||||
"",
|
||||
);
|
||||
const messages = lint(enMissing, "lib/i18n/en.ts", registryPolicy);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||
expect(messages[0].message).toContain('"home.restoreLast"');
|
||||
expect(messages[0].message).toContain(" in en");
|
||||
});
|
||||
|
||||
it("reports a missing whole section once, not per child key", () => {
|
||||
const enNoHeader = fixture("en.ts").replace(/\theader: \{[\s\S]*?\},/, "");
|
||||
const messages = lint(enNoHeader, "lib/i18n/en.ts", registryPolicy);
|
||||
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||
expect(messages[0].message).toContain('"header"');
|
||||
expect(messages[0].message).not.toContain("header.workspace");
|
||||
});
|
||||
|
||||
it("emits ONE warning per gap even when several locales own the key", () => {
|
||||
const enMissing = fixture("en.ts").replace(
|
||||
'\t\tsourceRequired: "Source image required",',
|
||||
"",
|
||||
);
|
||||
const messages = lint(enMissing, "lib/i18n/en.ts", registryPolicy);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||
expect(messages[0].message).toContain('"errors.sourceRequired"');
|
||||
expect(messages[0].message).toContain(" in en");
|
||||
});
|
||||
|
||||
it("flags empty and whitespace-only values", () => {
|
||||
const enEmpty = fixture("en.ts")
|
||||
.replace('workspace: "Workspace",', 'workspace: "",')
|
||||
.replace('catalog: "Catalog",', 'catalog: " ",');
|
||||
const messages = lint(enEmpty, "lib/i18n/en.ts", registryPolicy);
|
||||
expect(messageIds(messages)).toEqual(["emptyValue", "emptyValue"]);
|
||||
expect(messages[0].message).toContain('"header.workspace"');
|
||||
expect(messages[0].message).toContain(" in en");
|
||||
expect(messages[1].message).toContain('"header.catalog"');
|
||||
});
|
||||
|
||||
it("flags a placeholder lost in translation (vs the canonical set)", () => {
|
||||
const ruLostPlaceholder = fixture("ru.ts").replace(
|
||||
'hero: "Привет, {name}!",',
|
||||
'hero: "Привет!",',
|
||||
);
|
||||
const messages = lint(ruLostPlaceholder, "lib/i18n/ru.ts");
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
|
||||
expect(messages[0].message).toContain('"home.hero"');
|
||||
expect(messages[0].message).toContain("{} vs expected {name}");
|
||||
});
|
||||
|
||||
it("flags an extra placeholder introduced in one locale", () => {
|
||||
const ruExtraPlaceholder = fixture("ru.ts").replace(
|
||||
'description: "Рисует рамку {kind}.",',
|
||||
'description: "Рисует рамку {kind} от {author}.",',
|
||||
);
|
||||
const messages = lint(ruExtraPlaceholder, "lib/i18n/ru.ts");
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
|
||||
expect(messages[0].message).toContain('"pages.addBorder.description"');
|
||||
expect(messages[0].message).toContain("{author, kind} vs expected {kind}");
|
||||
});
|
||||
|
||||
it("honours allowPaths for conscious deviations", () => {
|
||||
const enMissing = fixture("en.ts").replace(
|
||||
'\t\tsourceRequired: "Source image required",',
|
||||
"",
|
||||
);
|
||||
const messages = lint(enMissing, "lib/i18n/en.ts", {
|
||||
...registryPolicy,
|
||||
allowPaths: ["errors.sourceRequired"],
|
||||
});
|
||||
expect(messageIds(messages)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores files that are not locale dicts", () => {
|
||||
const notALocale = `
|
||||
export const helper = { a: "b" };
|
||||
`;
|
||||
expect(messageIds(lint(notALocale, "lib/i18n/t.ts"))).toEqual([]);
|
||||
expect(messageIds(lint(notALocale, "lib/i18n/misc.ts"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Shared harness for testing the custom ESLint rules in eslint-plugins/.
|
||||
//
|
||||
// Two strategies, picked per rule in the test files:
|
||||
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
|
||||
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
|
||||
// - Linter API + fixtures — needed when a rule reads real files, such as
|
||||
// no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
|
||||
// dictionary. `verifyInFixtures` runs a rule with cwd fixed to
|
||||
// `__fixtures__/`, without touching process.cwd() or the real src/ tree.
|
||||
import { Linter } from "eslint";
|
||||
import type { Rule } from "eslint";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const FIXTURES_DIR = path.resolve(HERE, "../__fixtures__");
|
||||
export const FIXTURES_SRC = path.join(FIXTURES_DIR, "src");
|
||||
|
||||
/** Flat-config shape the Linter.verify accepts. */
|
||||
export type FlatConfig = Parameters<Linter["verify"]>[1];
|
||||
|
||||
/**
|
||||
* Run a rule (or a config) against `code` as if it were the fixture file
|
||||
* `relFile` under `__fixtures__/src`. Returns the lint messages.
|
||||
*/
|
||||
export function verifyInFixtures(
|
||||
config: FlatConfig,
|
||||
code: string,
|
||||
relFile: string,
|
||||
) {
|
||||
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
|
||||
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
|
||||
}
|
||||
|
||||
/** Wrap a script body into a minimal Svelte component (runs in runes mode). */
|
||||
export function svelteComponent(script: string): string {
|
||||
return `<main>Hello</main>\n\n<script lang="ts">\n${script}\n</script>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an inferred JS rule to the typed `Rule.RuleModule`. The plugin rule
|
||||
* files are plain JS, so TypeScript infers a widened shape (`meta.type: string`)
|
||||
* that `RuleTester.run` rejects; the shape is structurally correct at runtime,
|
||||
* which is why existing rules keep their JS inference and only the tests cast.
|
||||
*/
|
||||
export function asRuleModule(rule: unknown): Rule.RuleModule {
|
||||
return rule as Rule.RuleModule;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Tests for the conventions/interface-props rule
|
||||
// (web/eslint-plugins/conventions/interface-props.js).
|
||||
//
|
||||
// The rule is a pure AST check (no filesystem), so it runs through the standard
|
||||
// RuleTester with svelte-eslint-parser + the TS sub-parser. No fixtures needed.
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, it } from "vitest";
|
||||
import interfaceProps from "../conventions/interface-props.js";
|
||||
import { asRuleModule, svelteComponent } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: { parser: tseslint.parser },
|
||||
},
|
||||
});
|
||||
|
||||
const frame = (script: string) => ({
|
||||
code: svelteComponent(script),
|
||||
filename: "Component.svelte",
|
||||
});
|
||||
|
||||
ruleTester.run("interface-props", asRuleModule(interfaceProps), {
|
||||
valid: [
|
||||
{ code: "<main>Hello</main>", filename: "Component.svelte" },
|
||||
frame("let x = 1;"),
|
||||
// $state / $derived calls are unrelated, not props.
|
||||
frame("const s = $state(0);"),
|
||||
frame("const doubled = $derived(s * 2);"),
|
||||
frame(
|
||||
"interface Props { label: string }\nlet { label }: Props = $props();",
|
||||
),
|
||||
frame(
|
||||
"interface Props { label: string }\nlet { label = 'x' }: Props = $props();",
|
||||
),
|
||||
frame(
|
||||
"interface Props { value: string }\nlet { value = $bindable() }: Props = $props();",
|
||||
),
|
||||
// Interface declared after the destructuring is still fine.
|
||||
frame(
|
||||
"let { label }: Props = $props();\ninterface Props { label: string }",
|
||||
),
|
||||
frame(
|
||||
"interface Props { id: string; rest?: unknown }\nlet { id, ...rest }: Props = $props();",
|
||||
),
|
||||
frame(
|
||||
"import type { Snippet } from 'svelte';\n" +
|
||||
"interface Props { children?: Snippet }\n" +
|
||||
"let { children }: Props = $props();",
|
||||
),
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
// Untyped destructuring with a local interface -> autofixable.
|
||||
...frame("interface Props { label: string }\nlet { label } = $props();"),
|
||||
output: svelteComponent(
|
||||
"interface Props { label: string }\nlet { label }: Props = $props();",
|
||||
),
|
||||
errors: [{ messageId: "untypedDestructure" }],
|
||||
},
|
||||
{
|
||||
// Untyped destructuring without an interface -> report, no fix.
|
||||
...frame("let { label } = $props();"),
|
||||
errors: [{ messageId: "untypedDestructure" }],
|
||||
},
|
||||
{
|
||||
// Binding the whole props object instead of destructuring.
|
||||
...frame("let props = $props();"),
|
||||
errors: [{ messageId: "noDestructure" }],
|
||||
},
|
||||
{
|
||||
// Inline generic is banned outright.
|
||||
...frame("const props = $props<{ a: string }>();"),
|
||||
errors: [{ messageId: "inlineGeneric" }],
|
||||
},
|
||||
{
|
||||
// A different type name than the local interface Props.
|
||||
...frame(
|
||||
"interface Props { a: string }\nlet { a }: StageProps = $props();",
|
||||
),
|
||||
errors: [{ messageId: "notNamedProps" }],
|
||||
},
|
||||
{
|
||||
// Inline object props type instead of interface Props.
|
||||
...frame("let { a }: { a: string } = $props();"),
|
||||
errors: [{ messageId: "inlineObjectType" }],
|
||||
},
|
||||
{
|
||||
// Reference to Props without any local declaration.
|
||||
...frame("let { a }: Props = $props();"),
|
||||
errors: [{ messageId: "missingInterface" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Tests for the i18n/no-hardcoded-user-text rule
|
||||
// (web/eslint-plugins/i18n/no-hardcoded-user-text.js).
|
||||
//
|
||||
// The rule is a pure AST check over the Svelte template, so it runs through the
|
||||
// standard RuleTester with svelte-eslint-parser + the TS sub-parser. The
|
||||
// exclusion list (brand, formats, units, registry source strings) is the whole
|
||||
// point of the pilot, so every exclusion has a case here.
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import rule, { hasUserWords } from "../i18n/no-hardcoded-user-text.js";
|
||||
import { asRuleModule } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: { parser: tseslint.parser },
|
||||
},
|
||||
});
|
||||
|
||||
const OPTIONS = [{ allowWords: ["easy-png-tools"] }];
|
||||
|
||||
ruleTester.run("no-hardcoded-user-text", asRuleModule(rule), {
|
||||
valid: [
|
||||
// Translated text comes from t(), never from a literal.
|
||||
{ code: '<p>{t("ui.reset")}</p>', filename: "Component.svelte" },
|
||||
{
|
||||
code: '<Button label={t("actions.download")} />',
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
// Formats, units and file names are not copy.
|
||||
{ code: "<p>PNG</p>", filename: "Component.svelte" },
|
||||
{ code: "<p>result.png</p>", filename: "Component.svelte" },
|
||||
{ code: "<p>512×512</p>", filename: "Component.svelte" },
|
||||
{ code: '<span class="u">px</span>', filename: "Component.svelte" },
|
||||
{ code: "<p>1.0 s</p>", filename: "Component.svelte" },
|
||||
{ code: "<p>v0.1.0</p>", filename: "Component.svelte" },
|
||||
// Registry source strings and other shouted identifiers.
|
||||
{ code: "<p>BACKGROUND</p>", filename: "Component.svelte" },
|
||||
{ code: '<StepCard type="TRANSFORM" />', filename: "Component.svelte" },
|
||||
// Technical attributes are out of scope.
|
||||
{
|
||||
code: '<Button variant="outline" tone="danger" size="lg" />',
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
{
|
||||
code: '<a href="/tools/resize-png">{t("nav.home")}</a>',
|
||||
filename: "x.svelte",
|
||||
},
|
||||
// Values written as expressions are computed elsewhere.
|
||||
{
|
||||
code: "<p>{value}</p>",
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
{
|
||||
code: "<EmptyState title={copy} description={hint} />",
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
// Brand configured as an allowed word.
|
||||
{
|
||||
code: "<span>easy-png-tools</span>",
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
{
|
||||
code: "<footer><span>easy-png-tools</span><span>v0.1.0</span></footer>",
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
// <style> and <script> bodies are code, not copy.
|
||||
{
|
||||
code: '<p>{t("ui.reset")}</p>\n<style>.a { color: red; }</style>',
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
{
|
||||
code: '<script>const hint = "internal message";</script>\n<p>{t("ui.reset")}</p>',
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "<p>Visible text</p>",
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<span class="axis-label">From</span>',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<Button label="Download result" />',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<Button label="outline" variant="outline" />',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<ImageCard alt="Preview of the processed image" />',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
// Mixed content: the text part carries copy, the unit part does not.
|
||||
code: "<p>Result {value} px</p>",
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
// Navigation copy is user text too.
|
||||
code: '<a href="/tools/resize-png">Home</a>',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
// Nested elements are walked too.
|
||||
code: "<div><span><b>Delete file</b></span></div>",
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
// Without the option the brand word is reported: the exclusion is
|
||||
// configuration, not a hardcoded constant inside the rule.
|
||||
code: "<span>easy-png-tools</span>",
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("hasUserWords", () => {
|
||||
it("accepts technical fragments", () => {
|
||||
for (const value of [
|
||||
"",
|
||||
" ",
|
||||
"px",
|
||||
"512×512",
|
||||
"result.png",
|
||||
"1.0 s",
|
||||
"v0.1.0",
|
||||
"× ·",
|
||||
"BACKGROUND",
|
||||
"·",
|
||||
]) {
|
||||
expect(hasUserWords(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts configured brand words", () => {
|
||||
expect(hasUserWords("easy-png-tools", ["easy-png-tools"])).toBe(false);
|
||||
expect(hasUserWords("easy-png-tools")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts formats and units next to real copy", () => {
|
||||
expect(hasUserWords("PNG 512×512")).toBe(false);
|
||||
expect(hasUserWords("Result PNG")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Tests for the conventions/no-string-union-alias rule
|
||||
// (web/eslint-plugins/conventions/no-string-union-alias.js).
|
||||
//
|
||||
// The rule is a pure AST check (no filesystem), so it runs through the standard
|
||||
// RuleTester: plain .ts via the tseslint parser, and (because the rule is also
|
||||
// enabled on new .svelte scripts) .svelte via the svelte tester. No fixtures.
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, it } from "vitest";
|
||||
import noStringUnionAlias from "../conventions/no-string-union-alias.js";
|
||||
import { asRuleModule, svelteComponent } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const tsTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
});
|
||||
|
||||
const svelteTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: { parser: tseslint.parser },
|
||||
},
|
||||
});
|
||||
|
||||
const frame = (script: string) => ({
|
||||
code: svelteComponent(script),
|
||||
filename: "Component.svelte",
|
||||
});
|
||||
|
||||
tsTester.run("no-string-union-alias", asRuleModule(noStringUnionAlias), {
|
||||
valid: [
|
||||
{ code: "type X = 1 | 2;", filename: "types.ts" },
|
||||
{ code: "type X = 'a' | number;", filename: "types.ts" },
|
||||
{ code: "type X = string;", filename: "types.ts" },
|
||||
{ code: "const KIND = { a: 1, b: 2 } as const;", filename: "types.ts" },
|
||||
{ code: "function f(x: 'a' | 'b') {}", filename: "types.ts" },
|
||||
{ code: "const x: 'a' | 'b' = 'a';", filename: "types.ts" },
|
||||
{ code: "type X = 'a' | TemplateStrings;", filename: "types.ts" },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "type Kind = 'a' | 'b' | 'c';",
|
||||
filename: "types.ts",
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
{
|
||||
// Nested/parenthesized unions still resolve to string literals.
|
||||
code: "type Kind = ('a' | 'b') | 'c';",
|
||||
filename: "types.ts",
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
{
|
||||
code: "type Lang =\n\t'en'\n\t| 'ru';",
|
||||
filename: "types.ts",
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// The rule also runs on new .svelte files (their scripts are TS).
|
||||
svelteTester.run(
|
||||
"no-string-union-alias (svelte script)",
|
||||
asRuleModule(noStringUnionAlias),
|
||||
{
|
||||
valid: [frame("type X = 1 | 2;"), frame("const M = { a: 1 } as const;")],
|
||||
invalid: [
|
||||
{
|
||||
...frame("type Kind = 'a' | 'b' | 'c';"),
|
||||
errors: [{ messageId: "stringUnion" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
// Rule: ASCII-ONLY SOURCE (scoped to test files via eslint.config.js).
|
||||
// Non-ASCII in test names and expectations hurts grep-ability and hides
|
||||
// hardcoded localized strings; intentional glyphs go to the allowChars
|
||||
// option. File-content based (not AST), one report per line.
|
||||
// Details: web/eslint-plugins/README.md.
|
||||
|
||||
const DEFAULT_ALLOW = [];
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Require ASCII-only source in scoped files (tests); exceptions go to the allowChars option.",
|
||||
category: "Conventions",
|
||||
recommended: false,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
allowChars: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 1, maxLength: 1 },
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
nonAscii:
|
||||
"Non-ASCII character {{chars}} on this line. Tests are written in plain ASCII English; keep intentional glyphs in the rule's allowChars option.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const allowed = new Set(context.options[0]?.allowChars ?? DEFAULT_ALLOW);
|
||||
return {
|
||||
Program() {
|
||||
const sourceCode = context.sourceCode;
|
||||
const text = sourceCode.getText();
|
||||
const reportedLines = new Set();
|
||||
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
|
||||
for (const match of text.matchAll(/[^\x09\x0A\x0D\x20-\x7E]/g)) {
|
||||
const char = match[0];
|
||||
if (allowed.has(char)) continue;
|
||||
const loc = sourceCode.getLocFromIndex(match.index);
|
||||
if (reportedLines.has(loc.line)) continue;
|
||||
reportedLines.add(loc.line);
|
||||
context.report({
|
||||
loc,
|
||||
messageId: "nonAscii",
|
||||
data: {
|
||||
chars: `'${char}' (U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// Rule: COMMENT FORMAT: bounded blocks and bounded density.
|
||||
// maxBlockLines caps one comment block; maxRatio caps the share of comment
|
||||
// lines in files with minLines+ non-blank lines. Warn-only tripwires:
|
||||
// excess means refactor the code, not squeeze the comment.
|
||||
// Details: web/eslint-plugins/README.md.
|
||||
|
||||
function lineSpan(comment) {
|
||||
return comment.loc.end.line - comment.loc.start.line + 1;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Cap comment block length and overall comment density per file.",
|
||||
category: "Conventions",
|
||||
recommended: false,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
maxBlockLines: { type: "integer", minimum: 1 },
|
||||
maxRatio: { type: "number", minimum: 0, maximum: 1 },
|
||||
minLines: { type: "integer", minimum: 0 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
blockTooLong:
|
||||
"Comment block is {{lines}} lines (limit {{max}}). Explain 'why' briefly; move 'what' into names, structure, or a linked doc.",
|
||||
tooDense:
|
||||
"Comments take {{percent}}% of this file (limit {{max}}%). Prefer extracting functions/modules over narrating the code.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const maxBlockLines = context.options[0]?.maxBlockLines ?? 5;
|
||||
const maxRatio = context.options[0]?.maxRatio ?? 0.15;
|
||||
const minLines = context.options[0]?.minLines ?? 0;
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
const sourceCode = context.sourceCode;
|
||||
const comments = sourceCode.getAllComments();
|
||||
if (comments.length === 0) return;
|
||||
|
||||
const commentLines = new Set();
|
||||
let blockStart = comments[0];
|
||||
let blockEndLine = comments[0].loc.end.line;
|
||||
let blockLines = lineSpan(comments[0]);
|
||||
|
||||
const flushBlock = () => {
|
||||
if (blockLines > maxBlockLines) {
|
||||
context.report({
|
||||
loc: blockStart.loc,
|
||||
messageId: "blockTooLong",
|
||||
data: { lines: String(blockLines), max: String(maxBlockLines) },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < comments.length; i++) {
|
||||
const comment = comments[i];
|
||||
for (let l = comment.loc.start.line; l <= comment.loc.end.line; l++) {
|
||||
commentLines.add(l);
|
||||
}
|
||||
if (i === 0) continue;
|
||||
// Adjacent `//` lines form one block; anything else starts a new one.
|
||||
if (
|
||||
comment.type === "Line" &&
|
||||
comments[i - 1].type === "Line" &&
|
||||
comment.loc.start.line === blockEndLine + 1
|
||||
) {
|
||||
blockEndLine = comment.loc.end.line;
|
||||
blockLines += 1;
|
||||
} else {
|
||||
flushBlock();
|
||||
blockStart = comment;
|
||||
blockEndLine = comment.loc.end.line;
|
||||
blockLines = lineSpan(comment);
|
||||
}
|
||||
}
|
||||
flushBlock();
|
||||
|
||||
const totalLines = sourceCode.lines.filter(
|
||||
(l) => l.trim() !== "",
|
||||
).length;
|
||||
const ratio = commentLines.size / totalLines;
|
||||
if (totalLines >= minLines && ratio > maxRatio) {
|
||||
context.report({
|
||||
loc: node.loc,
|
||||
messageId: "tooDense",
|
||||
data: {
|
||||
percent: (ratio * 100).toFixed(1),
|
||||
max: (maxRatio * 100).toFixed(1),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Rule: COMMENTS IN ENGLISH ONLY. Whitelist, not blacklist: a comment must
|
||||
// be plain ASCII, so Arabic/CJK/Korean are caught just like Cyrillic.
|
||||
// Intentional glyphs go to the allowChars option. String literals are NOT
|
||||
// checked: localized dictionary values legitimately contain other scripts.
|
||||
// Details: web/eslint-plugins/README.md.
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Require comments to be written in English (ASCII whitelist; exceptions via allowChars).",
|
||||
category: "Conventions",
|
||||
recommended: false,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
allowChars: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 1, maxLength: 1 },
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
nonAscii:
|
||||
"Comment contains non-ASCII text ({{chars}}). Write comments in English; localized user-facing strings belong in lib/i18n dictionaries.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const allowed = new Set(context.options[0]?.allowChars ?? []);
|
||||
return {
|
||||
Program() {
|
||||
for (const comment of context.sourceCode.getAllComments()) {
|
||||
const offenders = new Set();
|
||||
for (const match of comment.value.matchAll(
|
||||
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
|
||||
/[^\x09\x0A\x0D\x20-\x7E]/g,
|
||||
)) {
|
||||
if (!allowed.has(match[0])) offenders.add(match[0]);
|
||||
}
|
||||
if (offenders.size === 0) continue;
|
||||
context.report({
|
||||
loc: comment.loc,
|
||||
messageId: "nonAscii",
|
||||
data: {
|
||||
chars: [...offenders]
|
||||
.map(
|
||||
(c) =>
|
||||
`'${c}' (U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
|
||||
)
|
||||
.join(", "),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||