Compare commits

..
8 Commits
101 changed files with 1793 additions and 1215 deletions
@@ -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
+77
View File
@@ -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: ${{ secrets.XBOCT_BOT_ID }}
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Setup Cocogitto
uses: ./.github/actions/setup-cocogitto
- name: Check if bump is needed
id: check-bump
run: |
BUMP_OUTPUT=$(cog bump --auto --dry-run 2>&1)
if [[ "$BUMP_OUTPUT" == *"No conventional commits"* ]]; then
echo "No bump needed."
echo "bumped=false" >> "$GITHUB_OUTPUT"
else
echo "Changes detected, proceeding..."
echo "bumped=true" >> "$GITHUB_OUTPUT"
fi
- name: Bump and push
if: steps.check-bump.outputs.bumped == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
cog bump --auto
git push origin main --tags
- name: Get new version
id: get-tag
if: steps.check-bump.outputs.bumped == 'true'
run: |
VERSION=$(git describe --tags --abbrev=0)
echo "Detected version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
release:
needs: bump
if: needs.bump.outputs.bumped == 'true'
uses: ./.github/workflows/release.yml
with:
version: ${{ needs.bump.outputs.new_version }}
secrets: inherit
+2 -28
View File
@@ -17,34 +17,7 @@ concurrency:
jobs: jobs:
verify: verify:
runs-on: ubuntu-latest uses: ./.github/workflows/verify.yml
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install root dependencies
run: pnpm install --frozen-lockfile
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run quality gate
run: pnpm verify
build: build:
needs: verify needs: verify
@@ -77,6 +50,7 @@ jobs:
run: pnpm build run: pnpm build
env: env:
BASE_PATH: /easy-png-tools BASE_PATH: /easy-png-tools
PUBLIC_GA_ENABLED: "true"
- name: Upload Pages artifact - name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3
+1 -29
View File
@@ -16,35 +16,7 @@ concurrency:
jobs: jobs:
verify: verify:
name: Fast quality gate uses: ./.github/workflows/verify.yml
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install root dependencies
run: pnpm install --frozen-lockfile
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run quality gate
run: pnpm verify
e2e: e2e:
name: E2E (non-blocking baseline) name: E2E (non-blocking baseline)
+58
View File
@@ -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 }}
+39
View File
@@ -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
+10 -1
View File
@@ -27,7 +27,7 @@
Читать только нужную строку, а не все документы подряд. Читать только нужную строку, а не все документы подряд.
| Задача | Документ | | Задача | Документ |
| --------------------------------------------- | ---------------------------------- | | ---------------------------------------------- | ---------------------------------- |
| Слои, поток данных, новый инструмент/поле | `docs/architecture.md` | | Слои, поток данных, новый инструмент/поле | `docs/architecture.md` |
| Какие проверки запускать, пороги, CI | `docs/quality-gates.md` | | Какие проверки запускать, пороги, CI | `docs/quality-gates.md` |
| Правка самих гейтов: «почему так, а не иначе» | `docs/decisions.md` | | Правка самих гейтов: «почему так, а не иначе» | `docs/decisions.md` |
@@ -38,8 +38,17 @@
| Актуальный список инструментов | `docs/tools-map.md` | | Актуальный список инструментов | `docs/tools-map.md` |
| Правка полей и раскладки по волнам | `docs/plan-fields-audit-fixes.md` | | Правка полей и раскладки по волнам | `docs/plan-fields-audit-fixes.md` |
| Ручная приёмка UI | `docs/checklist-manual-testing.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/README.md` |
† `docs/plan-domain.local.md` вне git, в клоне отсутствует — в этом случае
задачу ищут по `docs/plan-seo.md` §6.
## Инварианты, которые линтер не проверяет ## Инварианты, которые линтер не проверяет
- **Сначала искать.** Новая константа, тип или утилита не заводится, пока не - **Сначала искать.** Новая константа, тип или утилита не заводится, пока не
+242
View File
@@ -0,0 +1,242 @@
# Changelog
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - -
## [v0.1.1](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/1db83d12ff717bc2e282fe21d35d282f261147f5..v0.1.1) - 2026-10-04
#### Bug Fixes
- google analytics only on production - ([e1fc11c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1fc11cb5491a6f5be5c823140830672ea669a62)) - Ku6epXBOCTuK
- shapes offset calculate - ([1db83d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1db83d12ff717bc2e282fe21d35d282f261147f5)) - Ku6epXBOCTuK
- - -
## [v0.1.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/fdf7e2a3cd68d6693f1c5581796fafaf8939dad4..v0.1.0) - 2026-10-04
#### Features
- change id - remove png intents - ([7b7e12f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b7e12fafcbd7444be61426cd0b90104de9de40f)) - Ku6epXBOCTuK
- move routes to slugs - ([d09c954](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d09c95469494ec5f243f1981f6bc08b6e9fa6eb7)) - Ku6epXBOCTuK
- separate i18n dict pages - ([9776e1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9776e1d3251761347440ed43d57fef851b2af150)) - Ku6epXBOCTuK
- separate slug pages from tools - ([7a3498c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7a3498c173daa63f0a9ec88f09e10798525be9bd)) - Ku6epXBOCTuK
- add source aware resolver, update reset, update generator - ([5f77c09](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5f77c097e2d58d1826a10f05217af973c4de7e81)) - Ku6epXBOCTuK
- remove v1 routes and libs - ([16d4288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/16d42889120566e45008fa94285fd518b37f967f)) - Ku6epXBOCTuK
- add new dics\translations - ([9a27388](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9a27388119bb1b14bd759a830a9444be6c498e1b)) - Ku6epXBOCTuK
- add icon for split tool, update docs - ([b91c1ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b91c1ab7ee5bad72a90d2c66543a2e04e8f8c70e)) - Ku6epXBOCTuK
- add ui for new split tool - ([2be8871](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2be887193084b0fc87f16c110264bad58dc7279c)) - Ku6epXBOCTuK
- add executor support for multifiles result - ([adb5785](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adb578540f95ccafd3d0e8c419760da1cb031dcb)) - Ku6epXBOCTuK
- add split to parts to geometry registry - ([a7a42d0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a7a42d0091af3cc8e61c16962c4000d5c0bd8bdd)) - Ku6epXBOCTuK
- add error texts - ([e1c3d0a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1c3d0a95ffd1b1a1245a88bf8db247c28fc0b64)) - Ku6epXBOCTuK
- add download zip with result files - ([ceee9ad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ceee9adead94c437785ede7053e4563a7cbf5054)) - Ku6epXBOCTuK
- add split to parts core tool - ([d94c5d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d94c5d1297bfbc35b08feab28b565eab152217f4)) - Ku6epXBOCTuK
- add files tool result - ([ee5f660](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ee5f660572676edf829faca3a238796468ab2c12)) - Ku6epXBOCTuK
- rename old -> v1 folder - ([33e3f3c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/33e3f3c4f035c0daeae8de687cd464b1572242e5)) - Ku6epXBOCTuK
- flatten lib/preview -> lib folder - ([1e5e096](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e5e096d5d61b5ca69aaf196928868ad67f2f4f5)) - Ku6epXBOCTuK
- flatten components/kit -> components folder - ([f8a1da6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f8a1da6ff79be1a22e0046db7c3701110479011a)) - Ku6epXBOCTuK
- move preview pages to root - ([45dc54b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/45dc54b6aa136f753de16856a2378fec5ecbd10f)) - Ku6epXBOCTuK
- move old root routes to /v1 routes - ([b6318f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6318f03ab60cea602606d88dd94ed3d0601dd0f)) - Ku6epXBOCTuK
- update filter tools to new types - ([d6706ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d6706abea22cd9fa587f377338200032cce0f38a)) - Ku6epXBOCTuK
- update alpha and colors tools to new types - ([bea010d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bea010dd907e1c5ffe0b3b2f43c5db6efac9d348)) - Ku6epXBOCTuK
- update geometry tools to new types - ([2937974](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2937974fe732690ca58e4d693e5c1a69ef4bfc90)) - Ku6epXBOCTuK
- update text tools to new types - ([1e85fe3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e85fe36a636a7ab4e4daf48c2877ba9e91eeccb)) - Ku6epXBOCTuK
- update convert and analyze tools to new types - ([60c7d61](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/60c7d61daba60f656522e3ab45ffb87149910e69)) - Ku6epXBOCTuK
- update generate tool to new types - ([6b0b064](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b0b0646d51d3b3f74e4d132df2bfd44be411530)) - Ku6epXBOCTuK
- update schema components - ([b6db1c8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6db1c8f19ceb15c6a5e81f4108c9a25dc180525)) - Ku6epXBOCTuK
- update executor for tools - ([ba8a518](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba8a51886129d3c128d21ee625a8c3a0d16ae5b0)) - Ku6epXBOCTuK
- update tools types - ([292e53c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/292e53c9c76d4e02efa4990b5d62868a197d39c7)) - Ku6epXBOCTuK
- add text tools - ([e5b76ca](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e5b76ca3e11eb0313988ea1d49578adb6f24bf1e)) - Ku6epXBOCTuK
- add text core tools and views - ([a497857](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a49785764b39af1707be5990b5adcb43eb7356c9)) - Ku6epXBOCTuK
- add output format for download - ([c0cebdc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0cebdc81f229fc2fab57b40c48b2fb6dd84f598)) - Ku6epXBOCTuK
- add generate tools - ([b3d460a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b3d460a951f69b505b153ae2e1af5596f8321049)) - Ku6epXBOCTuK
- add multiple colors field - ([6be9d10](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6be9d10c42580a984b78e95fb0e317b2d4d6993a)) - Ku6epXBOCTuK
- add analyze, generate, text tools and update plan - ([cc70750](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cc70750727aaf5d5c758aa64fa577262a5197b9b)) - Ku6epXBOCTuK
- add alpha tools - ([e2735c5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e2735c52abf1e3968b24e334bb3d52c4e7f4dcf3)) - Ku6epXBOCTuK
- add geometry tools - ([d383cf2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d383cf29e30ad3722b754eb07cad10251af784d3)) - Ku6epXBOCTuK
- add filters tools - ([bfad0c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bfad0c7d020fe8e19a5919ed42011710b955a1df)) - Ku6epXBOCTuK
- add colors tools - ([6750d5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6750d5a373c9f10b44fa75c7c407f12e2d8d6680)) - Ku6epXBOCTuK
- add layout groupt to tools - ([e662d1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e662d1df248469f4e554b39a4e42d25a3296192d)) - Ku6epXBOCTuK
- add tool schema layout - ([d48262e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d48262e48269785c7edd414868fd952ff6d85f5f)) - Ku6epXBOCTuK
- add gradient control component - ([cad9bf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cad9bf6cfd48aef22d27610d735a3e76bd4baaab)) - Ku6epXBOCTuK
- add plate (under text) field component - ([b99ccf5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b99ccf5d7808bdd3bca7349b10e1b3858c7e56c3)) - Ku6epXBOCTuK
- add font style field component - ([0a7c4a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0a7c4a84c9009ab907579dc1750d8bd203d34f67)) - Ku6epXBOCTuK
- add position9 field component - ([f24ccf9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f24ccf924031c7384b22c83087401fbdfbfc4125)) - Ku6epXBOCTuK
- add offset field component - ([0c28925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c28925673facc40500c3c5e8e63d94db617f94b)) - Ku6epXBOCTuK
- add two colors field control - ([19f2e13](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/19f2e13b8874b60bb0d34d2d0ce8a575bfd9fe5c)) - Ku6epXBOCTuK
- update use dimension tools - ([f286044](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f286044ab92773af36b712d847b4eccc1f31d81b)) - Ku6epXBOCTuK
- add dimension tools for new flow - ([2601aa5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2601aa5b5c9e6d5c20a4df6d1eab2a0a68126f60)) - Ku6epXBOCTuK
- add registry tools for new preview flow - ([1e9b354](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e9b35406d8e3fd4071ba72d1dc6a043a0bf318f)) - Ku6epXBOCTuK
- double executor to use with preview - ([520a1e1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/520a1e1d37f4b63692ca906c06bdf694e297f0ca)) - Ku6epXBOCTuK
- add new registry for preview design - ([a19d3db](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a19d3db929f5d840900f1190c9efd10bb0595548)) - Ku6epXBOCTuK
- add single tool schema rendering - ([1c74629](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1c746299c9780a237cff2197c8b6b46310bc49d1)) - Ku6epXBOCTuK
- add new registry schema for new ui - ([1ae4b36](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ae4b36c9c2b0268a39486c935220cad9290cfb7)) - Ku6epXBOCTuK
- css audit fixes - ([dbeffac](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dbeffac082f56990fa1d08089b00e79da28b276c)) - Ku6epXBOCTuK
- WIP: add css fixes - ([2ef4709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2ef47090387fc47bd6c93365c7471532402e514d)) - Ku6epXBOCTuK
- add refs css audit - ([1831c3d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1831c3d10051510842fbf1a591c95d39cabf3ce4)) - Ku6epXBOCTuK
- fix tools pages, dom audit fix - ([9ef6ae0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ef6ae03c69a8b343bc8f05dac832876b9ea184e)) - Ku6epXBOCTuK
- add preview catalog and catalog group and header - ([b695f5d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b695f5d58c2e78f7d076d175ae819811b874cfe1)) - Ku6epXBOCTuK
- fix list tools page to correspond refs - ([7578a18](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7578a185b64e48c970e27c18c580b4055ba5c23b)) - Ku6epXBOCTuK
- fix demo page to correspond refs - ([d29ec73](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d29ec73322d6caf3d7ebff3305b2cf20cb0f0fbb)) - Ku6epXBOCTuK
- add new simple components - ([c0f48ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0f48ecd9762f0c507933409525f5e22bf759a76)) - Ku6epXBOCTuK
- some refactor, fix some design - ([65f12c9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/65f12c931baec26732a6fc3f39284bd423a3af0b)) - Ku6epXBOCTuK
- add link to preview design - ([40eee98](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/40eee984daeb2d82af375ff322c66a6c0e7ca869)) - Ku6epXBOCTuK
- update tool page - add step card steps - ([d373cc0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d373cc0daf8672c1febb3c3a844cb62714ff1dc7)) - Ku6epXBOCTuK
- add dropzone, update tool page - ([a16a7b9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a16a7b92c737273b27fd2611a77c65b746d63231)) - Ku6epXBOCTuK
- add single tool pages, wiring with toolcard - ([f990ad6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f990ad67671662637acc6b890145a2d2b0109bf0)) - Ku6epXBOCTuK
- separate topbar, fix important styles - ([f4216e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f4216e6868f975e511395c729359109a9a001a5c)) - Ku6epXBOCTuK
- add demo page - preselected pipeline - ([adcc255](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adcc2551073d0a57517b655239100d807e643fe7)) - Ku6epXBOCTuK
- add list tools page - ([73118ea](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/73118ea393c60d69108be3a08d72608c937465b5)) - Ku6epXBOCTuK
- add main page - ([e9578fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9578fc15e179b9ae5df3c0f9e1690f95b113a26)) - Ku6epXBOCTuK
- add ui kit page - ([06d185a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/06d185add66864662b0a173183f69c22078dc098)) - Ku6epXBOCTuK
- add ui components - ([8437c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8437c95a28343d6b24971d41776b8fd65e831810)) - Ku6epXBOCTuK
- add ui components - ([ac75665](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ac75665deb78d9b9459e4c34f4a9f9719a027c63)) - Ku6epXBOCTuK
- add new design primitives - ([0acfa3a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0acfa3aeb923fe7cbe95846055d2e199a62257e4)) - Ku6epXBOCTuK
- add new design2.css, add fonts - ([418a091](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/418a091505d80e4b2798a80740906d2d215c2488)) - Ku6epXBOCTuK
- add compression tools - ([83d3ce1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/83d3ce11986c908ea64c54c23bbfba0395cc838d)) - Ku6epXBOCTuK
- add quantize tools - ([501d2dd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/501d2dda4e186b2f7ece3f1be41b552393ca18a3)) - Ku6epXBOCTuK
- add text and generation tools - ([2b67414](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2b67414e79ef646b78bf6372d35e2262099d65a0)) - Ku6epXBOCTuK
- add pixel effects core tools - ([56ebe5b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/56ebe5b53be2bd797911faf285b5fe7b414358a1)) - Ku6epXBOCTuK
- add geometry masks - ([84cc56c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/84cc56cc6cf6627382d4f76f464c787a90181308)) - Ku6epXBOCTuK
- add shape masks - ([d65adcd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d65adcd16cb9bbd888b356784b46489e611f6ace)) - Ku6epXBOCTuK
- add masks core tools - ([553601a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/553601acc1ec4bb5a5fd74b50e60afb09070dd5d)) - Ku6epXBOCTuK
- add channel tools - ([5c37eb2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c37eb2e7b6bc41a219550e8be237697266ce5a1)) - Ku6epXBOCTuK
- add pallette tools - ([db02cbd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/db02cbd16558a367d328184b96f570ec692e5d67)) - Ku6epXBOCTuK
- add watermarks and text tools - ([116d7c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/116d7c72aac7ba2463f249c962251ee9d4a56744)) - Ku6epXBOCTuK
- add text and date draw tools - ([54af762](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/54af7626d6615c379e841e38e528fc9572d11196)) - Ku6epXBOCTuK
- add text draw core - ([38e7cd2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/38e7cd20d28e7fc26d3cbfb3ec960a2b136d1202)) - Ku6epXBOCTuK
- add demo page - ([6e0647d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6e0647dfbf9eb68d980ae8d76d6affdc9f1965d6)) - Ku6epXBOCTuK
- inline tool page - parameters beetween source and result images - ([148e4b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/148e4b195739774c5e892dbfd16f5a83617b93dc)) - Ku6epXBOCTuK
- add theme switcher, dark theme - ([f1f5bd4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f1f5bd4409797cb6c28fdee55e3dbd73ac6ce5f0)) - Ku6epXBOCTuK
- add base path for deploy - ([6d770dc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6d770dc642cd6110bb4ab3d7af9c9364bb29d0bb)) - Ku6epXBOCTuK
- search use both languages and remove diacrites - ([c04a1fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c04a1fc1dc4d266f47164e735668a53e14c2d61b)) - Ku6epXBOCTuK
- use i18n in components - ([3960368](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/396036802c1ee1e8fe52877826b22b9a3a427bee)) - Ku6epXBOCTuK
- base english language, also fallback to english - ([74a18d9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/74a18d91a77f3a049683f73122279eb9392a61df)) - Ku6epXBOCTuK
- move all common messages to i18n (ru\en) - ([2301b0e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2301b0e1654874744d2ddf72b878997aa461620b)) - Ku6epXBOCTuK
- add i18n - dict, en\ru, state - ([8255d33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8255d330988f9f6f98c51407e6e98613f7306150)) - Ku6epXBOCTuK
- add tool registry, icons - ([8745812](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8745812a5f077f715b003ca4022d20a72eb783ba)) - Ku6epXBOCTuK
- add io extensions - svg, jpeg - ([0909e14](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0909e14cde8ba6ab7249e54bc84bdc4a095cbf8d)) - Ku6epXBOCTuK
- add core tools - transfor, light, effects - ([7ef4b90](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7ef4b908e2f314e776ffd10f904085ac2eb4f3ea)) - Ku6epXBOCTuK
- add tools into registry, add icons - ([a2dd288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a2dd288851572b9634664f647e5c76a7f8184869)) - Ku6epXBOCTuK
- add alpha and morphology core tools - ([cb5ccf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cb5ccf6da8d3f21590e481a617507a9f899090e8)) - Ku6epXBOCTuK
- add tool search cards - ([5e44a45](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e44a454571e99a33d019f5eacfaed840a00c8c5)) - Ku6epXBOCTuK
- add tool popularity and icons - ([76f1770](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/76f1770286258097000ee18a9d11adc6b55ae08c)) - Ku6epXBOCTuK
- combine tool in single card - ([702b962](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/702b96261d0266fd904fa740f5a4bdab12ffa24e)) - Ku6epXBOCTuK
- move tool executor to web worker - ([9fbb90b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9fbb90b6418641154c40017ca03d7fd3bcec1452)) - Ku6epXBOCTuK
- add registry - background, blur, sharpen - ([c143011](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c14301130f23e9a4787f13d26b0bbf1a14bd084e)) - Ku6epXBOCTuK
- add background and convolution core tools - ([fd5236c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fd5236c19b5a6daba78d8780d3c217e92d33f74f)) - Ku6epXBOCTuK
- restore old toolchain button - ([91b3492](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91b3492231974d3b67b694a313c1f91dcf5b545d)) - Ku6epXBOCTuK
- add chain tools workflow - ([306c26a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/306c26a3602d64a6a24ed938f20c04882f4637c4)) - Ku6epXBOCTuK
- add tool search component, add chainable flag - ([c93a8e4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c93a8e493ef8464d1b1ee96869b6f99ddc0a583a)) - Ku6epXBOCTuK
- add step picker\list\editor - ([8532eb9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8532eb98c3f985ed4ee9ba0c7a51eabe0f86dc3c)) - Ku6epXBOCTuK
- add pipeline for multi instrument flow - ([23c985b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/23c985bde90cdbc24af5f1c137c0153e98658408)) - Ku6epXBOCTuK
- add registry - ([6fae98b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6fae98b25c9db23f27effdd6c73914cad5a9a647)) - Ku6epXBOCTuK
- add core operations - ([f9a4973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f9a4973b471b19f2aeaa8b71bf56bff2fd4de18d)) - Ku6epXBOCTuK
- add core operations - ([7fa2c52](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7fa2c52bb83538898398460de5ed520a26372c02)) - Ku6epXBOCTuK
- add text\hex conver tools - ([17b0c68](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/17b0c68eb52cf8e4e495bd01754ba8e7d3130202)) - Ku6epXBOCTuK
- add color picker glass - ([96c37aa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96c37aabc568896f7a279069d0c65e6ff4f794bb)) - Ku6epXBOCTuK
- add color picker - ([7b79996](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b799969c4bc0229de490921f29a15b84387ee21)) - Ku6epXBOCTuK
- add slider field - ([3701fc8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3701fc80451f94989d412a954715b1ffe9333836)) - Ku6epXBOCTuK
- add show mask workflow - ([aa5372b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/aa5372b771ef0834c2221f045c27cdd4570a1c41)) - Ku6epXBOCTuK
- reactive tool flow, with debounce - ([d0ee4ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d0ee4ec77f7999655e55c79df50b98a68d3fab9a)) - Ku6epXBOCTuK
- update drop file flow - ([caf71ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/caf71ec8f0418ebe43d9e04b007e9c3379864afd)) - Ku6epXBOCTuK
- add ctrl+v flow - ([f576701](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f576701baabbd0172550efe18c422650e0d15ee1)) - Ku6epXBOCTuK
- update - use design system components - ([c7b56bb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c7b56bbda79b67d4e13c5062c55266c7b6de2c5c)) - Ku6epXBOCTuK
- add design system components - ([15e2e01](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/15e2e010ec3227dae0786ebf81afe71ed0c8b011)) - Ku6epXBOCTuK
- add design system tokens - ([3620c5f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3620c5fc309e840eea5ac6927534b50c2957928f)) - Ku6epXBOCTuK
- add route pages - ([3ec1199](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3ec11999d82c118b72416d11fd0aa6b2698359f6)) - Ku6epXBOCTuK
- add svelte components - ([c022555](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0225552e4d5970b81a3dba51e357821da38977b)) - Ku6epXBOCTuK
- add instruments registry - ([719bda2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/719bda2e225331d04da35b7a35fb980b2af63f00)) - Ku6epXBOCTuK
- core instruments - ([9ccc9f9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ccc9f94acb5cc9d6f5ab1c13a7b3641160e3edd)) - Ku6epXBOCTuK
- core types and io - ([4ec2b41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4ec2b418fd001bf0c0d95534cd38988b6a44cd1d)) - Ku6epXBOCTuK
- initial sveltekit - ([85f595f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85f595f14b9cd059723d4ae84265b6a6e1633a67)) - Ku6epXBOCTuK
#### Bug Fixes
- show version in footer from manifest file - ([f89811c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f89811c116fbff42d0323ed811104bdce06dc68d)) - Ku6epXBOCTuK
- update crop tool max sizes - 2000x1200 - ([becf03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/becf03f4165e744e05b4e39d906b72a4804f6094)) - Ku6epXBOCTuK
- use base path for favicon - ([44cfca9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/44cfca92ce2eb2cef5119fb3701719f78d97f579)) - Ku6epXBOCTuK
- worker use only i18n keys instead of full i18n - ([c2f3838](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c2f3838485a78b12246a480cda564feb5a19255e)) - Ku6epXBOCTuK
- update i18n - update registry for tools - ([371387c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/371387ce81569921f5e143daec81275b63a1bab4)) - Ku6epXBOCTuK
- update i18n - update schema fields descriptions - ([08c9fbc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/08c9fbce3b21a7530bba12d8cdaa77f4286e69ae)) - Ku6epXBOCTuK
- update i18n - tool group descriptions - ([4de42b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4de42b1e402a17f43992b132d8d3d97b869fc0d9)) - Ku6epXBOCTuK
- update i18n - text verdicts from analyze tools - ([372babc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/372babc3b741e0fd5e10709b4b6e3e70589b6074)) - Ku6epXBOCTuK
- update i18n - fields components - ([4b44bc4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b44bc439ce2a36baf1c3f68fde8776d31ccb3f1)) - Ku6epXBOCTuK
- update i18n - schema components - ([6c88a23](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c88a23e4882bebd20e9bd7cc45c70f15818d5cb)) - Ku6epXBOCTuK
- update i18n - catalog, nav, common components - ([a4c85c0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a4c85c08140bd802bfb0dc2952ea475d4dca48f5)) - Ku6epXBOCTuK
- update i18n text in pages and layout - ([e069709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e06970997e5a402139d2f2b9c78cab8310cad293)) - Ku6epXBOCTuK
- update lang toggle component - ([1097186](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/10971864872fb305bbcad4e6470b7afac8fa1312)) - Ku6epXBOCTuK
- restart tool after change source - ([5cd2a58](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5cd2a586ce70116730cc9ce11653943194992454)) - Ku6epXBOCTuK
- update design - colors, remove texts - ([f2829de](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f2829de216f48f0d2bc5b35c0a1bf34a51ad10e8)) - Ku6epXBOCTuK
- disable download button while running jobs, auto run generator jobs - ([0c2de3f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c2de3f191cb9cfd10c4186cd16db7db2f8545bf)) - Ku6epXBOCTuK
- separate preview tile component - ([35df076](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/35df07673211f2734c4f52ab4e232580006efc9c)) - Ku6epXBOCTuK
- source image checker background - ([0b107c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b107c7d93a8dd27b8ad40c2c73a70518a45056f)) - Ku6epXBOCTuK
- update topbar links - ([f7623ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f7623ab3bbc3d3998938e918e963219039f97f88)) - Ku6epXBOCTuK
- v1 layout - ([85effce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85effce7d9ae940858fda6ed3f4b8336799ac93c)) - Ku6epXBOCTuK
- css fixes - ([420d03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/420d03f58c3ed26f0dd5658cdc9391f881847ba1)) - Ku6epXBOCTuK
- lint errors - ([6f1771c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6f1771cb07da8b43b3ef79c8ebb4033f429b491c)) - Ku6epXBOCTuK
- update tool card component - route resolve issue - ([987f453](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/987f4535c8609e25ae27db192ba598683604803f)) - Ku6epXBOCTuK
- resolve always text input issue, separate to simple components - ([a1438e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a1438e6f7f227f7489465070c02abdbb495a9dd1)) - Ku6epXBOCTuK
- add compress\decrease colors presets - ([37a4a05](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/37a4a0576c505ee3a87c0886e92faa9521092097)) - Ku6epXBOCTuK
- update tools list - show all tools - ([d1a386f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d1a386f92f2e9e7fa859cef950ca68d5f3729194)) - Ku6epXBOCTuK
- update segmented component styles - ([a24a182](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a24a182d1813f9bc882d3dff2cbf58a0396c799c)) - Ku6epXBOCTuK
- increase font size - ([1e63eaa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e63eaa03bc7d4c6ee21eb88cd0dff4cb54b4922)) - Ku6epXBOCTuK
- update field component - ([3d0f8eb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3d0f8ebb100294b970e9b45a854126ca031df9d7)) - Ku6epXBOCTuK
- update download button style - ([ef74cab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ef74cabf1e8bea77e88e0e29c802e02ee57eaf3d)) - Ku6epXBOCTuK
- update button component styles - ([c590bdf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c590bdf91a896fab0fd8a3e347a67db639be26c7)) - Ku6epXBOCTuK
- update css variables, update badge component - ([4794523](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/47945230846bacc4063bf8fc21620fefe57f3099)) - Ku6epXBOCTuK
- update some styles - ([191ca71](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/191ca71553187b45efb6bc26fb278a1e10b93aeb)) - Ku6epXBOCTuK
- change style, demo page visual looks as done - ([749b97c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/749b97cb4f55b145067bc169da8d4d08848ee4e3)) - Ku6epXBOCTuK
- resolve new audit styles - ([80def1f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/80def1f38043b3fdd081fa8c8b3558c6c6f01ce5)) - Ku6epXBOCTuK
- update font styles - ([0b13d4c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b13d4c9e829e5a0c1d2e8be210ae9ba659bbe39)) - Ku6epXBOCTuK
- update style, resolve old css leaks issue - ([586c66a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/586c66a159a9a4e30ae414070ffa83f06d9209ed)) - Ku6epXBOCTuK
- resolve style issues - ([bbd5532](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bbd5532529257262faa133d4cd5e7dee0f943927)) - Ku6epXBOCTuK
- step fields styles - ([8eff9a9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8eff9a94836fa2a6219888a5599a3fa011699d9b)) - Ku6epXBOCTuK
- update styles - ([2c70c44](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2c70c445d5ec601945b842663cfbc81222fdc91c)) - Ku6epXBOCTuK
- update preview styles - ([6c8f7b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c8f7b2d8b1eaca7f0cee708f0f1e81d0d59e25e)) - Ku6epXBOCTuK
- update top bar styles - ([86b1c81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86b1c813e72bc07f7f07e534b54faa4f6d69e142)) - Ku6epXBOCTuK
- resolve design issues from refs audit - ([0d95acb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0d95acba8933280b32e54a3f13d014aa464a2e71)) - Ku6epXBOCTuK
- remove tile label doubling - ([6959abb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6959abb15926edab7817006a42cfabfa436c04e8)) - Ku6epXBOCTuK
- update design with references - ([5093b50](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5093b50f5cfea72f6298e9dd5a2ae934e741b75a)) - Ku6epXBOCTuK
- update search flow - first tool search - ([3c1c5b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c1c5b2b3ae6fe8bdf576a70acb6b449edaf0197)) - Ku6epXBOCTuK
- some small css fixes - ([5e512f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e512f0bdd34f6329b54f111f81950acbebb2569)) - Ku6epXBOCTuK
- get names from categories - ([96b8c00](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96b8c0012988e4586718a11a77419b7752edeb80)) - Ku6epXBOCTuK
- base en - ([6787201](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6787201425b87256369003b97df299704ed75975)) - Ku6epXBOCTuK
- resolve transform core tools, add tests - ([ebc3c33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ebc3c33c12b58be7413378ff6e44677cf76f1a64)) - Ku6epXBOCTuK
- use toolcard as links at list page - ([200e971](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/200e971df503e6ba6a460bdd24f47cd617dbbc7c)) - Ku6epXBOCTuK
- update layout - ([4c5b52a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4c5b52a9ad13aadd425e9c5571120f356bf631be)) - Ku6epXBOCTuK
- update user input flow (keep aspect ratio) - ([da55328](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/da5532849198d3040a56f71a69fe6af6ae5b988f)) - Ku6epXBOCTuK
- resolve loading image error - ([4b70866](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b7086693cfd06ab88d47f22314913aa9326a470)) - Ku6epXBOCTuK
#### Refactoring
- separate components - ([c4eab38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c4eab388662b927aa5a6527be75350686e903865)) - Ku6epXBOCTuK
- add slug to tools - ([931ee27](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/931ee27b056a641b0adec58d0016347cd6afbc18)) - Ku6epXBOCTuK
- separate schema preview logic - ([6ab5989](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ab59897d10dd391c549a3c1e6d9df6fc7623288)) - Ku6epXBOCTuK
- separate schema fields logic - ([cdb23fe](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cdb23fe1c113f5bcc41228b9508e68a65a2746b6)) - Ku6epXBOCTuK
- executor protocol, fallback, debounce - ([0933f1c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0933f1cdeab3998de628eb1a5443399304e088ae)) - Ku6epXBOCTuK
- change v1 to import own copy of i18n - ([939038f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/939038ffc02fc618c5e53a8589a803417149f327)) - Ku6epXBOCTuK
- update path registry-new to registry - ([75f6891](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/75f6891e7c4285b4a63515b426f88f3fe87bb6ab)) - Ku6epXBOCTuK
- set root layout, rename css, fix links in lint rules \ etc - ([e970b47](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e970b477205ea02dc96c67dac19bbc36660aab85)) - Ku6epXBOCTuK
- move old libs and components to old folder - ([3834dad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3834dadd03f2946fa679e4cfb737d7cb486e588b)) - Ku6epXBOCTuK
- update css vars lint rules and fix them - ([e3d50f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e3d50f2128e5f27c834455f5c11de0dd6364d9fe)) - Ku6epXBOCTuK
- fix lint css vars rules - ([2d24f51](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2d24f51fd5bf94408f1ec515575fc700bfd74ae3)) - Ku6epXBOCTuK
- isolate old and preview versions, add lint rule - ([e57fadd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e57faddfdef9e75312b5a81df56e6450fb82bff4)) - Ku6epXBOCTuK
- update field schema types - ([743ae81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/743ae817ee47b8bf2826ee9876e046babd7d811f)) - Ku6epXBOCTuK
- separate schema fields components - ([4a13bfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4a13bfbd1684d095d10f9403bde88359174456c7)) - Ku6epXBOCTuK
- separate registry file - ([58e75f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/58e75f23345b956d4068ff16ef90805031eae1c8)) - Ku6epXBOCTuK
- move form fields to separate folder - ([6ec3e75](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ec3e75df4dc22728a234dab77c4bf69316ce9c1)) - Ku6epXBOCTuK
- move components - ([e9de7a4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9de7a4bf8244a278bb44e8eb84c35db7ee38965)) - Ku6epXBOCTuK
- update panel component - ([7e94205](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7e94205866f8c9d25ebb548f3c3fa87f42557e25)) - Ku6epXBOCTuK
- update panel component - ([2f758a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f758a88af234fc2ceefca024d6abfdd9ee14608)) - Ku6epXBOCTuK
- separate top bar component - ([991fe69](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/991fe69381e7018bb977991fb6ce2ba99c87c53d)) - Ku6epXBOCTuK
- update footer component - ([11c4cce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/11c4ccece436aae2081c426d3e31fb272a464904)) - Ku6epXBOCTuK
- icon and download button use button component - ([8a6f879](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8a6f87938d9179728e55642119ab575ff9891b93)) - Ku6epXBOCTuK
- update pages and refs audit - ([86e7b72](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86e7b7250d5a35eabdd9c9a463ec7999fb7f641a)) - Ku6epXBOCTuK
- move old design to separate layout tree - ([f08af41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f08af41568f9694543703fdcc936118d8f20e342)) - Ku6epXBOCTuK
- separate tool page for A\B testing - ([d70cbad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d70cbad0d2d804386b2d5d53673553bf65ca323c)) - Ku6epXBOCTuK
- add error messages i18n - ([486b99d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/486b99dbb8ec9383de36d2e6cd88c9fec08c7434)) - Ku6epXBOCTuK
- use standalone tool run executor - ([1b3bad4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1b3bad46d1be4a04311f46400c078542026315ff)) - Ku6epXBOCTuK
- change show mask flow - checkbox in params - ([3a5acfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3a5acfbc452a2a2fc350dc4e803b0846473779c2)) - Ku6epXBOCTuK
- separate tool page - ([5560435](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5560435b55cb232b978f4c20a813f3d964e66b79)) - Ku6epXBOCTuK
- dropzone update - ([7afd07e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7afd07e8c148a317456ee864685258e40cab484d)) - Ku6epXBOCTuK
- remove unused styles - ([a9234d2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a9234d27de7d19f3a0489ebe1da1d310371a4287)) - Ku6epXBOCTuK
#### Style
- fix formatting - ([3c10147](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c10147ad85650ed9e9f63850fc9581400e2d427)) - Ku6epXBOCTuK
- fix prettier ignore\config - ([9dccdd9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9dccdd99a0f7b6ef187627cf8ec4811b648bdd22)) - Ku6epXBOCTuK
- format docs markdown - ([64e35f4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/64e35f4fdfedf331e79309c49ddab936b2a263da)) - Ku6epXBOCTuK
- fix formatting via prettier - ([ada96b3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ada96b3534a20a2c5c671cef3ca0146ada743afa)) - Ku6epXBOCTuK
- - -
Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto).
+9 -8
View File
@@ -12,16 +12,16 @@ and open source.
transparency/alpha, color, channel decomposition, geometry, filters, transparency/alpha, color, channel decomposition, geometry, filters,
pixel-property masks, generation, text/watermarks, analysis & verdicts — the pixel-property masks, generation, text/watermarks, analysis & verdicts — the
current list lives in [`docs/tools-map.md`](docs/tools-map.md) current list lives in [`docs/tools-map.md`](docs/tools-map.md)
- **Pipeline system (workspace)** — load image → chain multiple tool steps →
sequential application → download result; pipelines saved to localStorage,
exportable as JSON
- **Web Worker execution** — heavy operations run off the main thread with - **Web Worker execution** — heavy operations run off the main thread with
automatic fallback to direct calls automatic fallback to direct calls
- **i18n** — full Russian and English localization - **i18n** — full Russian and English localization
- **Dark/Light themes** — persisted to localStorage - **Dark/Light themes** — persisted to localStorage
- **Pure TypeScript core** — `lib/core/` operates on - **Pure TypeScript core** — `lib/core/` operates on
`ImageData`/`Uint8ClampedArray` with no DOM dependency `ImageData`/`Uint8ClampedArray` with no DOM dependency
- **Fully static** — no backend, no analytics, no tracking - **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 ## Tech Stack
@@ -65,8 +65,9 @@ pnpm verify # full fast quality gate — the pre-review gate
pnpm build # static production export → web/build/ pnpm build # static production export → web/build/
``` ```
Полный список команд и матрица «какие проверки запускать при каком изменении» — For the full command list and the "which checks to run for which change" matrix,
в `AGENTS.md` и `docs/quality-gates.md`. see [`AGENTS.md`](AGENTS.md) and
[`docs/quality-gates.md`](docs/quality-gates.md).
## Architecture ## Architecture
@@ -74,8 +75,8 @@ Everything is built around the **two-layer registry** (`web/src/lib/registry/`):
a tool is a self-contained implementation `{id, schema, input, result, run}` a tool is a self-contained implementation `{id, schema, input, result, run}`
(`registry/tools/`), and a page is `{slug, title, description, category, steps}` (`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 (`registry/pages/`) that names the address and the tools to run — one tool can
back several pages. Pages, forms, and pipelines are generated from it, and all back several pages. Pages and forms are generated from it, and all execution
execution goes through a single `executor.execute` entry point. goes through a single `executor.execute` entry point.
Core image processing lives in `web/src/lib/core/` and operates directly on Core image processing lives in `web/src/lib/core/` and operates directly on
`ImageData`, with no browser API dependencies. `ImageData`, with no browser API dependencies.
+19
View File
@@ -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 }
+4 -3
View File
@@ -15,6 +15,7 @@
| Документ | Когда читать | Статус | | Документ | Когда читать | Статус |
| ---------------------------------- | -------------------------------------------------- | ------ | | ---------------------------------- | -------------------------------------------------- | ------ |
| `AGENTS.md` | Всегда: контракт агента | active | | `AGENTS.md` | Всегда: контракт агента | active |
| `docs/README.md` | Индекс документов, правила ведения документации | active |
| `docs/architecture.md` | Слои, поток данных, новый инструмент/поле/маршрут | active | | `docs/architecture.md` | Слои, поток данных, новый инструмент/поле/маршрут | active |
| `docs/quality-gates.md` | Какие проверки запускать, пороги, coverage, CI | active | | `docs/quality-gates.md` | Какие проверки запускать, пороги, coverage, CI | active |
| `docs/decisions.md` | Правка самих гейтов; чтение CI после пуша | active | | `docs/decisions.md` | Правка самих гейтов; чтение CI после пуша | active |
@@ -60,9 +61,9 @@
`web/src/lib/registry/tools/`, адрес и имя страницы (`slug`) — в `web/src/lib/registry/tools/`, адрес и имя страницы (`slug`) — в
`web/src/lib/registry/pages/`; исторические названия в старых планах не `web/src/lib/registry/pages/`; исторические названия в старых планах не
действуют (`docs/architecture.md`, раздел 3). действуют (`docs/architecture.md`, раздел 3).
- Поиск по `docs/` задевает `docs/archive/` (объём больше, чем у активных - Поиск по `docs/` задевает `docs/archive/`: там вынесены завершённые планы и
документов вместе). Для правок production-кода архив не нужен: grep по исследования, поэтому grep по `docs/` даёт больше шума, чем пользы. Для правок
`docs/*.md` без `archive/`. production-кода архив не нужен: grep по `docs/*.md` без `archive/`.
- Проверка ссылок в документации не автоматизирована; ожидаемо неразрешимыми - Проверка ссылок в документации не автоматизирована; ожидаемо неразрешимыми
считаются ссылки на `docs/plan-domain.local.md` (файл вне git). Всё остальное считаются ссылки на `docs/plan-domain.local.md` (файл вне git). Всё остальное
неразрешимое — ошибка. неразрешимое — ошибка.
+8 -3
View File
@@ -12,7 +12,10 @@
4. Определить границу изменения: production UI, shared-модуль или инфраструктура 4. Определить границу изменения: production UI, shared-модуль или инфраструктура
— `AGENTS.md`, раздел «Границы изменений». Изменение shared-кода требует — `AGENTS.md`, раздел «Границы изменений». Изменение shared-кода требует
проверки production-потребителей. проверки production-потребителей.
5. Не добавлять исключения линтера ради отдельных модулей без записи в baseline. 5. Не добавлять исключения линтера ради отдельных модулей: осознанные
отступления живут в конфиге правила (`allowPaths`, `allowWords`,
`ignoreMissingPatterns`, `allowDefaultProject` —
`web/eslint-plugins/README.md`), а не в самих проверяемых файлах.
## 2. Порядок изменения ## 2. Порядок изменения
@@ -27,8 +30,10 @@
warnings и чтение CI — в `docs/quality-gates.md`. Здесь только то, что относится warnings и чтение CI — в `docs/quality-gates.md`. Здесь только то, что относится
к работе агента. к работе агента.
`lint:all` может завершиться успешно при warnings. Это не означает чистый `lint:all` роняет gate на любых ESLint warnings (`--max-warnings=0`).
baseline: нужно отдельно показать число errors, warnings и token warnings. Единственное, что он пропускает, — unused-token сообщения (неблокирующие).
Поэтому «команда прошла» не равно «нечего чинить»: нужно отдельно показать число
errors, warnings и token warnings.
E2E запускается отдельно от быстрых проверок, потому что поднимает build и E2E запускается отдельно от быстрых проверок, потому что поднимает build и
статический сервер. Для UI-изменений сначала выбирается минимальный статический сервер. Для UI-изменений сначала выбирается минимальный
+61 -35
View File
@@ -16,10 +16,13 @@ web/src/lib/
catalog.ts # группировка страниц и счётчики для /list-tools catalog.ts # группировка страниц и счётчики для /list-tools
categories.ts # id/названия категорий categories.ts # id/названия категорий
tool-icons.ts # slug страницы → иконка tool-icons.ts # slug страницы → иконка
core/ # чистый TS: операции над ImageData, без DOM core/ # операции над ImageData; без DOM, кроме io.ts/domText.ts
executor/ # единственная точка исполнения инструментов executor/ # единственная точка исполнения инструментов
i18n/ # словари ru/en, t(), списки для i18n-правил линтера i18n/ # словари ru/en, t(), состояние локали
components/ # UI (fields/, layout/, ui/, schema-компоненты) components/ # UI по слоям: ui/ примитивы, layout/ каркас,
# catalog/ каталог, schema/ view инструмента,
# fields/ поля (+ fields/schema/ контролы схемы),
# display/ типографика и карточки, feedback/ состояния
zip.ts # сборка мультифайлового результата (1 → many) zip.ts # сборка мультифайлового результата (1 → many)
theme.svelte.ts # light/dark + localStorage theme.svelte.ts # light/dark + localStorage
web/src/routes/ web/src/routes/
@@ -30,9 +33,15 @@ web/src/routes/
``` ```
Правило зависимостей: `components/**` и `routes/**` знают про `registry/**`, Правило зависимостей: `components/**` и `routes/**` знают про `registry/**`,
`executor/**` и `core/**`; `core/**` не знает ни о ком и не зависит от DOM; `executor/**` и `core/**`; `core/**` не знает ни о ком; `registry/**` описывает
`registry/**` описывает данные и знает только `core/**` и `registry-schema.ts`. данные и знает только `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`.
### Чего в коде нет ### Чего в коде нет
@@ -43,11 +52,14 @@ web/src/routes/
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг, него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
полей двух инструментов — отдельная работа, описанная как фаза 11 полей двух инструментов — отдельная работа, описанная как фаза 11
`docs/roadmap.md`. Под неё зарезервированы `components/StepCard.svelte` `docs/roadmap.md`. Под неё зарезервированы
(сейчас импортируется только витриной `/kit`) и ключи `chain.*`, `components/display/StepCard.svelte` (сейчас импортируется только витриной
`errors.badPipelineShape`, `errors.noSteps`. `/kit`) и ключи `chain.*`, `errors.badPipelineShape`,
- **Разделения страниц, которые делят один инструмент** — сводит 14 `errors.pipelineVersion`, `errors.noSteps`.
convert-страниц на один инструмент; задача в `docs/backlog.md`. - **Разделения страниц, которые делят один инструмент** — сводит все
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
на практике не ссылается ни одна.
Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по
пайплайну — `docs/roadmap.md`. пайплайну — `docs/roadmap.md`.
@@ -67,7 +79,7 @@ registry/{tools,pages}/*.ts
├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол) ├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол)
├─► SchemaSourceTile/SchemaTextSource.svelte вход ├─► SchemaSourceTile/SchemaTextSource.svelte вход
└─► SchemaPreview + schema-preview-model.ts вид превью └─► SchemaPreview + schema-preview-model.ts вид превью
│ buildSchemaPreviewModel(inputMode, resultKind, …) → source/result/hasResult │ buildSchemaPreviewModel(input) → { sourceValue, resultValue, formatValue, hasResult }
▼ ▼
executor.execute(ctx) единственная точка исполнения executor.execute(ctx) единственная точка исполнения
│ сериализация параметров → worker → worker-handler → tool.run(ctx) │ сериализация параметров → worker → worker-handler → tool.run(ctx)
@@ -104,9 +116,9 @@ registry/{tools,pages}/*.ts
| `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker | | `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker |
| `schema` | `toolSchema({ fields, layout?, meta? })` | | `schema` | `toolSchema({ fields, layout?, meta? })` |
| `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе | | `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе |
| `result` | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт | | `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
| `domOnly` | инструмент требует DOM (canvas); превью-executor запускает его напрямую | | `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
| `output` | mime/ext/quality для скачивания (не у всех) | | `output`? | mime/ext/quality для скачивания (не у всех) |
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` | | `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
`Page` (записи в `registry/pages/<group>.ts`): `Page` (записи в `registry/pages/<group>.ts`):
@@ -121,15 +133,22 @@ registry/{tools,pages}/*.ts
Кто по чему адресуется: `slug` — маршрут, имя файла, `TOOL_ICONS`, поиск по Кто по чему адресуется: `slug` — маршрут, имя файла, `TOOL_ICONS`, поиск по
каталогу; `id` — ключ `tools.<id>.*` (подписи полей, `options`, `results`), каталогу; `id` — ключ `tools.<id>.*` (подписи полей, `options`, `results`),
`toolId` worker-протокола и запись пайплайна. Один инструмент может обслуживать `toolId` worker-протокола и запись пайплайна. Модель допускает, что один
несколько страниц, одна страница — состоять из нескольких инструментов. инструмент обслуживает несколько страниц, а страница состоит из нескольких
инструментов; на практике пока каждая страница ссылается на свой инструмент, а
исполняется первый (и единственный) шаг.
Инструмент работает с любым поддержанным форматом, поэтому png-интент живёт Инструмент работает с любым поддержанным форматом, поэтому png-интент живёт
только в `slug`: `id: "crop"` → страница `slug: "crop-png"` → `crop-png.png`. только в `slug`: `id: "crop"` → страница `slug: "crop-png"` → `crop-png.png`.
Правило имени: снять суффикс `-png` и в середине имени (`png-to-base64` → Правило имени `id`: снять суффикс `-png` и в середине имени (`png-to-base64` →
`to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`); следствие — `to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`). Правило
относится только к `id`: у `slug` своя история и SEO-значение, поэтому
переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя
файла, ключ `pages.*`, иконку и запись в `docs/tools-map.md`. файла, ключ `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` для типовых Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых
случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS` случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS`
@@ -154,15 +173,17 @@ registry/{tools,pages}/*.ts
допустимому виду. Правка поведения полей идёт здесь, а не в компоненте. допустимому виду. Правка поведения полей идёт здесь, а не в компоненте.
Новый вид поля требует: спека + фабрика `field.*` → запись в `fieldSpecs` → Новый вид поля требует: спека + фабрика `field.*` → запись в `fieldSpecs` →
контрол в `components/fields/schema/` → ключ в `FIELDS` → дефолт и санитизация → контрол (`components/fields/schema/`) → ключ в `FIELDS` → дефолт и санитизация →
i18n-подписи → тест в `registry-schema.test.ts`. i18n-подписи → тест в `registry-schema.test.ts`.
## 5. Исполнение ## 5. Исполнение
`executor/executor.ts` — фабрика `createExecutor(factories?)` плюс готовый `executor/executor.ts` — фабрика `createExecutor({ workerFactory? })` плюс
экспорт `execute`. Контракт асинхронный (`Promise`), параметры сериализуются, готовый экспорт `execute`. Контракт асинхронный (`Promise`), параметры
исполнение уходит в `executor.worker.ts`; `worker-handler.ts` разбирает сериализуются по `executor/protocol.ts`, исполнение уходит в
сообщение и зовёт `run`. Если worker недоступен — прямой вызов. `executor.worker.ts`; `worker-handler.ts` разбирает сообщение и зовёт `run`.
Если worker недоступен — прямой вызов. UI импортирует `execute` из
`executor/index.ts`.
Отсюда следует: подключение wasm позже означает замену реализации **внутри** Отсюда следует: подключение wasm позже означает замену реализации **внутри**
исполнителя, а не правку компонентов. исполнителя, а не правку компонентов.
@@ -170,13 +191,16 @@ i18n-подписи → тест в `registry-schema.test.ts`.
## 6. i18n ## 6. i18n
Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с
`LOCALES`/`BASE_LOCALE`). Пользовательский текст живёт только в словарях и `LOCALES`/`BASE_LOCALE`, `t.ts` с `t()`, `locale.svelte.ts` с состоянием
читается через `t()`. Секции разделены по слоям: `pages.<slug>.title` и локали). Пользовательский текст живёт только в словарях и читается через `t()`.
`pages.<slug>.description` — тексты страницы (в базовой локали секция пустая, EN Списки ключей и допустимых атрибутов для i18n-правил линтера лежат не здесь, а в
берётся из реестра), `tools.<id>.options/params/results` — подписи полей и `web/eslint-plugins/i18n/lists.js`. Секции разделены по слоям:
тексты вердиктов инструмента, `fields.*`/`groups.*` — общие подписи схемы. `pages.<slug>.title` и `pages.<slug>.description` — тексты страницы (в базовой
Инварианты паритета ключей, плейсхолдеров и запрет хардкода проверяются локали секция пустая, EN берётся из реестра),
линтером, детали — `web/eslint-plugins/README.md`. `tools.<id>.options/params/results` — подписи полей и тексты вердиктов
инструмента, `fields.*`/`groups.*` — общие подписи схемы. Инварианты паритета
ключей, плейсхолдеров и запрет хардкода проверяются линтером, детали —
`web/eslint-plugins/README.md`.
## 7. Точки расширения ## 7. Точки расширения
@@ -204,7 +228,7 @@ i18n-подписи → тест в `registry-schema.test.ts`.
`from-base64`, `is-grayscale`). `from-base64`, `is-grayscale`).
4. **Описать схему** через `field.*`, задать `layout`, если поля нужно 4. **Описать схему** через `field.*`, задать `layout`, если поля нужно
сгруппировать. Качество кодирования (`quality`) — не поле страницы, а сгруппировать. Качество кодирования (`quality`) — не поле страницы, а
настройка скачивания (`docs/plan-seo.md` §1, S1f). настройка скачивания (`docs/plan-seo.md` §4, S1f).
5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в 5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в
`core/`, без DOM. `core/`, без DOM.
6. **Добавить страницу** в `registry/pages/<group>.ts`: `slug` с адресным 6. **Добавить страницу** в `registry/pages/<group>.ts`: `slug` с адресным
@@ -221,8 +245,10 @@ i18n-подписи → тест в `registry-schema.test.ts`.
«Реализовано», формат строки — в шапке раздела. Карта перечисляет **`slug` «Реализовано», формат строки — в шапке раздела. Карта перечисляет **`slug`
страниц** из `registry/pages/`, то есть адреса. Проверяет страниц** из `registry/pages/`, то есть адреса. Проверяет
`web/src/lib/registry/tools-map-doc.test.ts`: slug без страницы и страница `web/src/lib/registry/tools-map-doc.test.ts`: slug без страницы и страница
без записи в карте роняют тест. Обоснование и границы автоматизации — без записи в карте роняют тест; разделы «Можно добавить» и «Идеи»
`docs/decisions.md`, раздел 4. проверяются в обратную сторону — реализованный slug в плане тоже роняет
тест, поэтому при переезде инструмента из плана в реестр его вынимают
оттуда. Обоснование и границы автоматизации — `docs/decisions.md`, раздел 4.
Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для
инструмента, меняющего пользовательский поток, — e2e (см. раздел 3 инструмента, меняющего пользовательский поток, — e2e (см. раздел 3
+71 -68
View File
@@ -1,36 +1,38 @@
# Backlog # Backlog
> Статус: **active**. Продуктовые, UX- и инфраструктурные задачи. Здесь только > Статус: **active**. Открытые задачи. Подробные планы, разбор решений и сырые
> **открытые** задачи: выполненные удаляются, а критичные для проекта факты из > находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
> них переносятся в тематический документ (архитектура, quality gates, lint, > `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
> e2e) по мере закрытия.
>
> «Что сознательно не вводим и почему» живёт в `docs/decisions.md`, архитектура
> — в `docs/architecture.md`. Raw-находки UX-аудита — `docs/tools-audit.md`, их
> разбивка на волны — `docs/plan-fields-audit-fixes.md`.
>
> Ids инструментов сверять с `web/src/lib/registry/`; полный список —
> `docs/tools-map.md`.
## lint и прочие правила ## lint и прочие правила
- [ ] `tseslint.config(...)` deprecated: сигнатура - [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена `(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `typescript-eslint` — устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
`web/eslint.config.js`. core, а не `typescript-eslint`) — `web/eslint.config.js`.
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей; - [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого действует pattern-based policy-исключение до этого действует pattern-based policy-исключение
(`web/eslint-plugins/README.md`, раздел `dict-consistency`) (`web/eslint-plugins/README.md`, раздел `dict-consistency`)
## Мелочи всякие ## Мелочи всякие
Порядок правок полей и раскладки — `docs/plan-fields-audit-fixes.md`, сами - [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
находки («что плохо», с датой аудита) — `docs/tools-audit.md`. Здесь — как это нарезки ассетов
исправить; находка в аудите не отменяет задачу здесь. - [ ] crop, resize (но скорее всего все инструменты) - при загрузке нового
изображения сбрасывает размеры, настроенные пользователем
- [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют - [ ] split-into-parts-png, resize (может еще какие-то) - возможность указывать
количество инструментов, точное совпадение текстов, поиск по размеры из заранее одобренного списка размеров - 16х16, 32х32, 64х64 как
`img[alt="Result image"]`, svelte классам и тому подобные минимум, может еще какие-то размеры добавить, единый список из предыдущей
задачи - (для инди разработчиков, стандартные тайлы часто делаются)
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
(`docs/testing-strategy.md`, раздел 5)
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
ничем не читают. Либо показывать подсказку для инструментов с пустой
схемой (FA-E1), либо удалить ключ из обеих локалей
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
привязанные к разметке или svelte-классам (сейчас остались:
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри - [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор - [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
@@ -63,8 +65,6 @@
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex - строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone) - [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?) - [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] проверить настройки range slider на crop-png - слишком большие значения - [ ] проверить настройки range slider на crop-png - слишком большие значения
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса - [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
@@ -72,48 +72,43 @@
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было - [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
изменено изменено
- [ ] обновить страницу kit или сделать storybook - [ ] обновить страницу kit или сделать storybook
- [ ] Свести convert-страницы на один инструмент `convert` — реализация уже - [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
умеет «скопировать пиксели, декодировать вход, отдать в заданном формате», `png-to-bmp`) на один инструмент `convert` — зависит от S1f
а страницы различаются только форматом вывода. **Зависит от S1f** (`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
(`docs/plan-seo.md` §1): пока `quality` живёт в схеме страницы, у инструмента), а сама модель: `output` с mime/ext — свойство `Tool`, а
`convert-png-to-bmp` и `convert-png-to-jpg` разные наборы полей, и один `PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
инструмент на них не сойдётся. Качество — настройка кнопки Download получат одинаковый формат вывода. Нужно научить шаг переопределять формат
(«Download as JPEG»), а не поле страницы; после его переезда пресеты схемы вывода
не нужны, страницы различаются только `output` и `params` шага. Модель
страницы к этому готова: `Page.steps` — список инструментов, один
инструмент может обслуживать много страниц (`docs/architecture.md`, раздел
3).
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop, - [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
удаление, добавление инструментов. переход один инструмент - pipeline без удаление, добавление инструментов. переход один инструмент - pipeline без
перезагрузки перезагрузки
- [ ] `CatalogToolbar`: фильтры используют сырой `<button>`, а не `ui/Button` — - [ ] `CatalogToolbar`: фильтры используют сырой `<button>`, а не `ui/Button` —
единый контрол для всей библиотеки единый контрол для всей библиотеки
(`web/src/lib/components/CatalogToolbar.svelte`). (`web/src/lib/components/CatalogToolbar.svelte`).
- [ ] Вынести `ToneVariant`/варианты в общий модуль: `const`-объект с тонами - [ ] `SchemaTextResult` вердикт-бейдж рисует руками: свои классы
продублирован в `ui/Badge.svelte` и кнопках `badge`/`badge-tone--{tone}` и свой CSS в `<style>` вместо
(`web/src/lib/components/ui/Badge.svelte`). `ui/Badge.svelte` — тон и разметка бейджа живут в двух местах
- [ ] `PreviewTile`: canvas должен сам выбирать соотношение сторон в диапазоне (`web/src/lib/components/schema/SchemaTextResult.svelte`).
16:9…9:16 вместо фиксированного - [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
(`web/src/lib/components/layout/PreviewTile.svelte`). поэтому она не следует за пропорциями результата — широкий результат
- [ ] `ThemeToggle`: свои анимации иконок и своя кнопка вместо `IconButton` уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
(`web/src/lib/components/ui/ThemeToggle.svelte`). чтобы соотношение сторон выбиралось по результату в диапазоне 16:9…9:16
- [ ] Радиусы бордеров не совпадают с референсом: в `refs` радиусы были 0, а мы (`web/src/lib/components/schema/PreviewTile.svelte`).
взяли ненулевой `--radius-m` для всех бордеров. Проверить токен против - [ ] Радиусы бордеров: `--radius-m` ненулевой и применён ко всем бордерам
референсов; возможно он должен быть 0, а скругления оставить только там, (`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
где они реально есть в дизайне. в дизайне, а где должен быть 0
- [ ] `Footer`: версию брать из `package.json`, а не писать `v0.1.0` вручную
(`web/src/lib/components/layout/Footer.svelte`).
## Переезд ## Переезд
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) — - [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
`tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
plurals есть все формы, для которых нужен перевод, `other` никогда не
пустой, число подставляется в текст без склейки
## SEO/GEO ## SEO/GEO
Общий план: `docs/plan-seo.md` (S1–S4). Старт возможен — пока только domain-free
часть S1. Полное содержание этапов не дублируется здесь, только идентификаторы.
- [ ] переименование бренда + домен: чек-лист в локальном файле - [ ] переименование бренда + домен: чек-лист в локальном файле
`plan-domain.local.md` (в git не входит, в клоне отсутствует; подробности `plan-domain.local.md` (в git не входит, в клоне отсутствует; подробности
и порядок — `docs/plan-seo.md` §6, R1/R2) и порядок — `docs/plan-seo.md` §6, R1/R2)
@@ -127,16 +122,16 @@
(`docs/plan-seo.md` §5) (`docs/plan-seo.md` §5)
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c) - [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
- [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e) - [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e)
- [ ] контент страницы инструмента: seo-блок в словарях, H1/how-to/FAQ/related - [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
(S2)
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3) - [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
- [ ] GSC + приватная аналитика (Plausible/Umami/CF), дистрибуция (S4) - [ ] дистрибуция: закрепить репо, 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) ## Платформа (API/PWA)
Общий план: `docs/plan-platform.md`; WASM-ядро, эталоны и CLI —
`docs/roadmap.md` (фазы 2–8). Здесь только треки, которых нет в roadmap.
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1) - [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier - [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
(после wasm-фаз 5–7) (после wasm-фаз 5–7)
@@ -145,8 +140,15 @@
## Качество production UI ## Качество production UI
Политика проверок — `docs/quality-gates.md`, e2e — `docs/testing-strategy.md`. - [ ] `/` и `/list-tools` выходят без `<title>`: `svelte:head` есть только на
странице инструмента, а готовые ключи `home.defaultTitle`,
`catalog.pageTitle`, `catalog.metaDescription` не читает ни один компонент
(S1d)
- [ ] **Баг e2e:** `tool-flows.spec.ts:179` создаёт контекст через
`browser.newContext()`, который не наследует `use.baseURL` из
`playwright.config.ts`, — относительный `goto('/tools/flip-png')` внутри
`openTool` не резолвится, и тест «работает без Web Worker (fallback)»
падает на всех движках. В CI это не видно: job `e2e` неблокирующий
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного - [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение. решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается - [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
@@ -154,9 +156,8 @@
## Идеи ## Идеи
Идеи 1–5 и 15 относятся к **пайплайну (цепочкам шагов)**, которого в коде пока Идеи 1–5 и 15 — про пайплайн (фаза 11 `docs/roadmap.md`). Идеи 6–14 и 16 — про
нет: это фаза 11 `docs/roadmap.md`. Идеи 6–14 — к существующим инструментам и существующие инструменты и UI. Оценки (S/M/L) — оценка труда, не приоритет.
UI. Оценки (S/M/L) — оценка труда, не приоритет.
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент 1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
зафиксирован при открытии страницы; хочется заменить его на другой без зафиксирован при открытии страницы; хочется заменить его на другой без
@@ -171,8 +172,8 @@ UI. Оценки (S/M/L) — оценка труда, не приоритет.
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки 3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
в localStorage (список, создать/переименовать/удалить), быстрое переключение. в localStorage (список, создать/переименовать/удалить), быстрое переключение.
Формат экспортного JSON расширить опциональным полем имени; обратной Формат экспортного JSON расширить опциональным полем имени; обратной
совместимости со старыми выгрузками не делаем (см. задачу про `id`/`slug` совместимости со старыми выгрузками не делаем — формат не устоялся: он
выше — формат ещё не устоялся). появится вместе с фазой 11 `docs/roadmap.md` и до её старта может измениться.
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив 4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
@@ -192,9 +193,11 @@ UI. Оценки (S/M/L) — оценка труда, не приоритет.
(`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в (`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в
`SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось `SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось
сверх S1f: сверх S1f:
- экспорт с лимитом размера → закрывает отложенную страницу - экспорт с лимитом размера → «сжатие до целевого КБ»: для lossy это quality
`reduce-to-size-png` (целевой KB, бинарный поиск по k из квантования). в селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
Проработать вместе с достройкой download-флоу. отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
download-флоу.
7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна 7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна
overlay-механика: второй источник изображения (getOverlay/store), параметры overlay-механика: второй источник изображения (getOverlay/store), параметры
@@ -259,7 +262,7 @@ UI. Оценки (S/M/L) — оценка труда, не приоритет.
- частичные ошибки: один битый файл не должен ронять весь шаг — нужен - частичные ошибки: один битый файл не должен ронять весь шаг — нужен
per-file отчёт, а не один `ToolError` на шаг; per-file отчёт, а не один `ToolError` на шаг;
- имена частей на каждом шаге и Download для набора (сейчас zip по - имена частей на каждом шаге и Download для набора (сейчас zip по
`tool.id`, `SchemaToolView.download()`); `page.slug`, `SchemaToolView.download()`);
- обратная сторона: распаковка zip в браузере, порядок файлов, поведение - обратная сторона: распаковка zip в браузере, порядок файлов, поведение
many → 1 → many в одной цепочке. Зависит от идеи 10 (остальные потребители many → 1 → many в одной цепочке. Зависит от идеи 10 (остальные потребители
1 → many) и идеи 4 (batch по архиву), `docs/roadmap.md` фаза 8 1 → many) и идеи 4 (batch по архиву), `docs/roadmap.md` фаза 8
+11 -9
View File
@@ -1,8 +1,9 @@
# Чек-лист ручного тестирования production UI # Чек-лист ручного тестирования production UI
> Вспомогательный документ к e2e-тестам (`web/e2e/`, `pnpm --dir web test:e2e`) > Статус: **active**. Вспомогательный документ к e2e-тестам (`web/e2e/`,
> и стратегии `docs/testing-strategy.md`. Автоматизированная часть закрывает > `pnpm --dir web test:e2e`) и стратегии `docs/testing-strategy.md`.
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает. > Автоматизированная часть закрывает «нет падений/ошибок»; здесь — то, что
> руками, и что Playwright не покрывает.
> >
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build > Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
> → `node scripts/serve-static.mjs`). > → `node scripts/serve-static.mjs`).
@@ -23,7 +24,7 @@
(авто-покрытие: `web/e2e/png-fixtures.spec.ts`). (авто-покрытие: `web/e2e/png-fixtures.spec.ts`).
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер. - [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в - [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
`pipeline.spec.ts` и `png-fixtures.spec.ts`). `tool-flows.spec.ts` и `png-fixtures.spec.ts`).
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер, - [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`. прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`.
@@ -72,8 +73,9 @@
## D. Производительность и отзывчивость ## D. Производительность и отзывчивость
- [ ] Долгие авто-рераны на больших картинках (jpeg-artifacts, dithering, - [ ] Долгие авто-рераны на больших картинках (`jpeg-artifacts-png`,
quantize на 4К) — UI не фризит, канселяция/дебаунс срабатывает. `dithering-png`, `quantize-png` на 4К) — UI не фризит, канселяция/дебаунс
срабатывает.
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет - [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
залипаний, панели не прыгают. залипаний, панели не прыгают.
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе - [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
@@ -82,9 +84,9 @@
## E. Визуальный слой (доделываем по ходу) ## E. Визуальный слой (доделываем по ходу)
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/ - [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
desktop) на всех 4 маршрутах. desktop) на всех маршрутах: `/`, `/list-tools`, `/kit`, любая `/tools/*`.
- [ ] Тема light/dark: контраст вердиктов (Yes/No), статус «LIVE PREVIEW», - [ ] Тема light/dark: контраст вердиктов (Yes/No), подпись панели превью
мета-инфо бликов не даёт. («Предпросмотр»), мета-инфо бликов не даёт.
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon). - [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера. - [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
+42 -5
View File
@@ -47,11 +47,11 @@
которых 14 — ложные («30 дней» политики архива, «9 опций» описания поля, «4 которых 14 — ложные («30 дней» политики архива, «9 опций» описания поля, «4
проекта» факт из `playwright.config.ts», цитата самого правила). проекта» факт из `playwright.config.ts», цитата самого правила).
- Правило всё равно неполно: ловит «122 инструмента», но не «инструментов — - Правило всё равно неполно: ловит «M инструментов», но не «инструментов — M»,
122», не «более 100», не «≈120»; на каждое обходное движение нужна правка не «более 100», не «≈120»; на каждое обходное движение нужна правка правила, а
правила, а allowlist легитимных чисел становится вторым источником долга. allowlist легитимных чисел становится вторым источником долга. Худший исход —
Худший исход — не «шумно», а «вроде гейт, а толку нет»: срабатывания на пустом не «шумно», а «вроде гейт, а толку нет»: срабатывания на пустом месте
месте забейзелят, и через месяц правило перестанет ловить настоящие нарушения. забейзелят, и через месяц правило перестанет ловить настоящие нарушения.
Видимое блокирующее правило, которое на деле ничего не проверяет, хуже Видимое блокирующее правило, которое на деле ничего не проверяет, хуже
отсутствия правила. отсутствия правила.
- Соблюдение правила дешевле обхода: «список — в `docs/tools-map.md`» стоит - Соблюдение правила дешевле обхода: «список — в `docs/tools-map.md`» стоит
@@ -82,6 +82,19 @@
про парсер, а не молча перестанет что-либо проверять. Входит в обычный про парсер, а не молча перестанет что-либо проверять. Входит в обычный
`pnpm --dir web test`; новых гейтов и конфигов не добавляет. `pnpm --dir web test`; новых гейтов и конфигов не добавляет.
Граница автоматизации: парсер читает разделы «Реализовано», «Можно добавить» и
«Идеи», но проверяет их в разных направлениях. Раздел 1 сверяется с реестром
обеими сторонами (slug без страницы и страница без записи). Разделы 2 и 3
сверяются только в одну сторону: там slug'и намеренно несуществующие, поэтому
проверяется, что **ни один из них не является реальной страницей** —
реализованный инструмент, оставшийся в плане, роняет тест. Проверять «у плана
есть запись в реестре» нельзя по построению: список плановый, а не фактический.
Плановые разделы устроены прозой, поэтому их разбор другой: берутся все токены
нужной формы из всего буллита, а не только из «головы» до тире. Следствие для
авторов карты: реализованные страницы в планах называются прозой между
буллитами, а не внутри них.
## 5. Visual snapshots — не вводим ## 5. Visual snapshots — не вводим
`toHaveScreenshot` сравнивает страницу с PNG-baseline из прошлого коммита, а не `toHaveScreenshot` сравнивает страницу с PNG-baseline из прошлого коммита, а не
@@ -170,3 +183,27 @@ PR. Актуальные длительности — в логе прогона
Альтернатива — `allowPaths: ["pages"]` — была бы слабее: она разрешила бы пустую Альтернатива — `allowPaths: ["pages"]` — была бы слабее: она разрешила бы пустую
секцию `pages` в любой локали, то есть пропущенный перевод страницы прошёл бы секцию `pages` в любой локали, то есть пропущенный перевод страницы прошёл бы
линтер молча. Пустая секция в EN и есть то, что делает правило точным. линтер молча. Пустая секция в 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.
+118 -97
View File
@@ -16,13 +16,20 @@
> которые в бэклоге, здесь не дублируются: этот документ задаёт **порядок**, > которые в бэклоге, здесь не дублируются: этот документ задаёт **порядок**,
> backlog — **учёт** (`docs/backlog.md`). > backlog — **учёт** (`docs/backlog.md`).
> >
> **Инструменты в этом документе названы по адресам страниц (slug).** В коде у > **В этом документе инструменты названы по `id`** (`registry/tools/`), а не по
> страницы `slug` (`registry/pages/`), у вызываемого инструмента — `id` без > адресу страницы: поля и раскладка принадлежат схеме инструмента, а страница
> png-интента (`crop-png` → `id: "crop"`, `png-to-hsl` → `to-hsl`), модель — > только ссылается на список шагов (`Page.steps`), где сейчас ровно один
> `docs/architecture.md`, раздел 3. Названия из `docs/tools-audit.md` вида > инструмент, а в будущем может быть несколько. Поэтому «у страницы `crop-png`
> `color-wheel-generator`, `webp-to-png` в реестре отсутствуют (реальная > есть поля x/y» неверно — поля есть у инструмента `crop`, а страница лишь
> страница — `color-wheel-png`): при переносе пункта аудита в шаг сверять с > показывает его. `id` не несёт png-интента: страница `crop-png` вызывает
> `PAGES`. > `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 ## 0. Ключевое решение: select → buttons
@@ -124,9 +131,9 @@ buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
- `web/src/lib/registry-schema.ts` — `NumberSpec.variant?: "seed"` - `web/src/lib/registry-schema.ts` — `NumberSpec.variant?: "seed"`
- `web/src/lib/components/fields/schema/RangeControl.svelte` — условный рендер - `web/src/lib/components/fields/schema/RangeControl.svelte` — условный рендер
- `web/src/lib/registry/filters.ts` — `randomizePixels`, `addNoise`: поле seed → - `web/src/lib/registry/tools/filters.ts` — `randomizePixels`, `addNoiseTool`:
`{ ...field.number(...), spec: { ...spec, variant: "seed" } }` (или поле seed → `{ ...field.number(...), spec: { ...spec, variant: "seed" } }`
пересоздать через `field.seed()`) (или пересоздать через `field.seed()`)
--- ---
@@ -138,63 +145,67 @@ buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
### FA-B1. geometry.ts ### FA-B1. geometry.ts
| Инструмент | Поле | Опции | | Инструмент | Поле | Опции |
| ----------------------- | -------- | ----------------------------------------- | | ------------------- | -------- | ----------------------------------------- |
| rotate-png | angle | 90° / 180° / 270° | | rotate | angle | 90° / 180° / 270° |
| flip-png | axis | horizontal / vertical | | flip | axis | horizontal / vertical |
| swap-orientation-png | target | portrait / landscape | | swap-orientation | target | portrait / landscape |
| symmetric-copy-png | axis | vertical / horizontal | | symmetric-copy | axis | vertical / horizontal |
| symmetric-copy-png | keepSide | left / right / top / bottom | | symmetric-copy | keepSide | left / right / top / bottom |
| change-aspect-ratio-png | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 | | change-aspect-ratio | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 |
| change-aspect-ratio-png | mode | crop / pad | | change-aspect-ratio | mode | crop / pad |
**Исключение:** `change-canvas-size-png` (anchor, 9 опций) — оставить **Исключение:** `change-canvas-size` (anchor, 9 опций) — оставить
`field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже `field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже
отдельный компонент `PositionControl`). отдельный компонент `PositionControl`).
### FA-B2. color.ts ### FA-B2. color.ts
| Инструмент | Поле | Опции | | Инструмент | Поле | Опции |
| ------------------------ | --------- | ------------------------------------ | | -------------------- | --------- | ---------------------------------------------------- |
| dithering-png | pattern | floyd-steinberg / bayer | | dithering | pattern | floyd-steinberg / bayer |
| extract-channel-png | channel | red / green / blue | | extract-channel | channel | red / green / blue |
| swap-channels-png | pair | r-g / r-b / g-b | | swap-channels | pair | r-g / r-b / g-b |
| decrease-color-count-png | maxColors | 2 / 4 / 8 / 16 / 32 / 64 / 128 / 256 | | decrease-color-count | maxColors | 2 / 4 / 8 / 16 / 32 / 44 / 64 / 96 / 128 / 192 / 256 |
**Исключение:** channel spaces (hsl/hsv/... component) — 3-6 опций, но это **Исключение:** инструменты разложения каналов (`to-hsl`, `to-hsv`, `to-hsi`,
`to-cmyk`, `to-ycbcr`, `to-lab`, поле `component`) — 3-6 опций, но это
динамические инструменты, оставить `select`. динамические инструменты, оставить `select`.
### FA-B3. analyze.ts ### FA-B3. analyze.ts
| Инструмент | Поле | Опции | | Инструмент | Поле | Опции |
| ------------------------- | ---- | ------------------ | | --------------------- | ---- | ------------------ |
| show-transparent-png | mode | binary / highlight | | show-transparent | mode | binary / highlight |
| show-grayscale-pixels-png | mode | (тот же) | | show-grayscale-pixels | mode | (тот же) |
| show-color-pixels-png | mode | (тот же) | | show-color-pixels | mode | (тот же) |
| light-pixel-mask-png | mode | (тот же) | | light-pixel-mask | mode | (тот же) |
| dark-pixel-mask-png | mode | (тот же) | | dark-pixel-mask | mode | (тот же) |
| unique-color-mask | mode | (тот же) |
Все используют общий `maskBaseFields` — правка в одном месте. Пять инструментов из шести берут `mode` из общего `maskBaseFields` — правка в
одном месте. Исключение — `show-transparent`: он переопределяет `mode` своими
опциями и дефолтом `highlight`, поэтому его `mode` правится отдельно.
### FA-B4. filters.ts ### FA-B4. filters.ts
| Инструмент | Поле | Опции | | Инструмент | Поле | Опции |
| ------------- | ---- | ------------ | | ---------- | ---- | ------------ |
| add-noise-png | mode | mono / color | | add-noise | mode | mono / color |
### FA-B5. generate.ts ### FA-B5. generate.ts
| Инструмент | Поле | Опции | | Инструмент | Поле | Опции |
| ------------------ | ------------ | --------------------- | | -------------- | ------------ | --------------------- |
| color-spectrum-png | direction | horizontal / vertical | | color-spectrum | direction | horizontal / vertical |
| step-colors-png | layout | grid / strip | | step-colors | layout | grid / strip |
| complementary-png | layout | grid / strip | | complementary | layout | grid / strip |
| triadic-png | layout | grid / strip | | triadic | layout | grid / strip |
| tetradic-png | layout | grid / strip | | tetradic | layout | grid / strip |
| analogous-png | layout | grid / strip | | analogous | layout | grid / strip |
| monochromatic-png | layout | grid / strip | | monochromatic | layout | grid / strip |
| shades-png | layout | grid / strip | | shades | layout | grid / strip |
| sort-colors-png | layout | grid / strip | | sort-colors | layout | grid / strip |
| mix-colors-png | (нет select) | — | | mix-colors | (нет select) | — |
--- ---
@@ -203,12 +214,12 @@ buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
### FA-C1. Source-aware defaults для dimension-полей — реализовано ### FA-C1. Source-aware defaults для dimension-полей — реализовано
Source-aware defaults реализованы: общий resolver в `registry-schema` Source-aware defaults реализованы: общий resolver в `registry-schema`
(`applySourceDefaults`). Для `resize-png` и `crop-png` дефолт после загрузки (`applySourceDefaults`). Для `resize` и `crop` дефолт после загрузки равен
равен размерам текущего source; до загрузки используется положительный fallback размерам текущего source; до загрузки используется положительный fallback `1×1`.
`1×1`. Один-sided `0` у resize сохраняет режим auto. Проверено тестом Один-sided `0` у resize сохраняет режим auto. Проверено тестом
`registry.test.ts` («resize и crop получают размеры текущего source»). `registry.test.ts` («resize и crop получают размеры текущего source»).
### FA-C2. symmetric-copy-png: "keep side" не работает ### FA-C2. symmetric-copy: "keep side" не работает
Баг в `run()`: `keepSide` не учитывает `axis`. При `axis: "vertical"` (удвоение Баг в `run()`: `keepSide` не учитывает `axis`. При `axis: "vertical"` (удвоение
ширины) работают только `left`/`right`, `top`/`bottom` не имеют смысла. Нужно: ширины) работают только `left`/`right`, `top`/`bottom` не имеют смысла. Нужно:
@@ -257,55 +268,63 @@ resolver.
### FA-C4. verify-is-png: неверное название — решение принято, шаг не выполнен ### FA-C4. verify-is-png: неверное название — решение принято, шаг не выполнен
Проверяет текст, не 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` → уточнить; `id` оставить — он же Варианты:
адрес страницы, переименование ломает ссылку
- либо полное переименование `id` → `verify-is-png-data` с редиректом со старого
URL (нужен вордстат, иначе страница выпадает из индекса)
**Решение:** поменять title и description, id не трогать. Переименование `id` - `title` → «Verify PNG data», `description` → уточнить; `slug` и `id` не
затрагивает адрес — с ним связана задача разведения `id`/`slug` трогать
(`docs/backlog.md`), поэтому в одиночку не делается. - либо переименовать `slug` → `verify-png-data` с редиректом со старого URL
(нужен вордстат, иначе страница выпадает из индекса)
**Решение:** поменять title и description, имя страницы не трогать.
Переименование `slug` меняет адрес, имя файла, ключи `pages.*` и иконку, то есть
это операция с SEO-последствиями, а не строчка в реестре — она не делается в
одиночку, вместе с решением по `docs/plan-seo.md` §5.
--- ---
## 4. Волна D — обязательные фиксы (новые параметры) ## 4. Волна D — обязательные фиксы (новые параметры)
### FA-D1. remove-alpha-channel-png: выбор цвета фона ### FA-D1. remove-alpha-channel: выбор цвета фона
Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`. Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`.
Файл: `alpha.ts`, схема `removeAlphaChannelSchema`. Файл: `alpha.ts`, схема `removeAlphaChannelSchema`.
### FA-D2. extract-alpha-mask-png: галочка «инвертировать» ### FA-D2. extract-alpha-mask: галочка «инвертировать»
Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В
`run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`. `run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`.
Файл: `alpha.ts`. Файл: `alpha.ts`.
### FA-D3. change-canvas-size-png: position9 вместо select ### FA-D3. change-canvas-size: position9 вместо select
`anchor` сейчас `field.select()` с 9 опциями → `field.position9()`. `anchor` сейчас `field.select()` с 9 опциями → `field.position9()`.
Файл: `geometry.ts`. Уже есть `PositionControl`. Файл: `geometry.ts`. Уже есть `PositionControl`.
### FA-D4. Generators: добавить height ### FA-D4. Generate: добавить height
Инструменты без `size`/`height`: Инструменты без `size`/`height`:
- blend-two-png — добавить - blend-two — добавить
`height: field.slider({ min: 1, max: 1024, default: 512 })` `height: field.slider({ min: 1, max: 1024, default: 512 })`
- step-colors-png — аналогично - step-colors — аналогично
- complementary, triadic, tetradic, analogous, monochromatic, shades, - complementary, triadic, tetradic, analogous, monochromatic, shades — все
sort-colors — все используют `paletteBaseSchema` (общий объект), добавить используют `paletteBaseSchema` (общий объект), добавить `height` туда
`height` туда - `sort-colors` — своя схема, `paletteBaseSchema` не использует, `height`
- mix-colors-png — добавить height добавлять вручную
- mix-colors — добавить height
Файл: `generate.ts`. Файл: `generate.ts`.
### FA-D5. add-border-png: прозрачность цвета ### FA-D5. add-border: прозрачность цвета
Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть
(прозрачность не поддерживается нативным color picker). Отметить в аудите как (прозрачность не поддерживается нативным color picker). Отметить в аудите как
@@ -322,24 +341,24 @@ blocked.
Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст. Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст.
### FA-E2. quantize-png: пресеты ### FA-E2. quantize: пресеты
`field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под `field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под
слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера. слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера.
### FA-E3. trim-empty-space-png: 0-254 → проценты ### FA-E3. trim-empty-space: 0-254 → проценты
Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог
шага FA-A1 — `variant: "alpha-threshold"` в NumberSpec). шага FA-A1 — `variant: "alpha-threshold"` в NumberSpec).
### FA-E4. add-text-png: plate offset ### FA-E4. add-text: plate offset
Plate снизу имеет большой отступ — добавить галочку "compact plate" или Plate снизу имеет большой отступ — добавить галочку "compact plate" или
уменьшить дефолтный padding. уменьшить дефолтный padding.
### FA-E5. emoji-to-png: выбор эмодзи ### FA-E5. from-emoji: выбор эмодзи
Поле `text` → добавить группу часто используемых эмодзи как кнопки- пресеты над Поле `emoji` → добавить группу часто используемых эмодзи как кнопки-пресеты над
полем ввода. полем ввода.
--- ---
@@ -365,44 +384,46 @@ Plate снизу имеет большой отступ — добавить г
round-corners — px→%): нужен компонент переключателя единиц в слайдере. round-corners — px→%): нужен компонент переключателя единиц в слайдере.
Требует правки `RangeControl` + всех схем — в волны A–E не входит, нужен Требует правки `RangeControl` + всех схем — в волны A–E не входит, нужен
отдельный этап. отдельный этап.
- **Smoothing checkbox** для масок (circle, square, star, wavy, round-corners, - **Smoothing checkbox** для масок (circle-mask, square-mask, star-mask,
invert-alpha): нужен `field.checkbox("smoothing")` + правка core (shapes.ts, wavy-mask, round-corners, invert-alpha): нужен `field.checkbox("smoothing")` +
alpha). Отложен — требует визуального тестирования. правка core (shapes.ts, alpha). Отложен — требует визуального тестирования.
- **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует - **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует
normative решения по UI. normative решения по UI.
- **Remove-background**: выбор точки удаления — либо параметр, либо отдельный - **Remove-background**: выбор точки удаления — либо параметр, либо отдельный
инструмент. инструмент.
- **Precision/quality**: select в resize (bilinear/bicubic/nearest) — нужен ли - **Метод масштабирования в `resize`**: поля в схеме нет (только `size` и
выбор алгоритма? `keepAspect`), поэтому это уже не правка селекта, а новое поле — нужен ли
выбор алгоритма (bilinear/bicubic/nearest)?
- **Placeholders**: "нет параметров" — какой текст/компонент показывать. - **Placeholders**: "нет параметров" — какой текст/компонент показывать.
### 7.2 Операционные (тест-кейсы, описания) ### 7.2 Операционные (тест-кейсы, описания)
- find-contour-png: расширять на толщину линии (требует правки core) - find-contour: расширять на толщину линии (требует правки core)
- make-thicker-png: расширять картинку на толщину линии - make-thicker: расширять картинку на толщину линии
- make-thinner-png: уменьшать от краев, fix выступа - make-thinner: уменьшать от краев, fix выступа
- feather-edges-png: расширять картинку на толщину линии - feather-edges: расширять картинку на толщину линии
- clean-edges-png: найти тест-кейс, возможно увеличить максимум - clean-edges: найти тест-кейс, возможно увеличить максимум
- despeckle-alpha-png: найти более заметный тест-кейс - despeckle-alpha: найти более заметный тест-кейс
- close-holes-png: проверить поведение на больших полупрозрачных областях - close-holes: проверить поведение на больших полупрозрачных областях
- auto-contrast-png: найти тестовый кейс - auto-contrast: найти тестовый кейс
- sharpen-png: добавить тест-кейс - sharpen: добавить тест-кейс
- center-by-alpha-png: найти тест-кейс - center-by-alpha: найти тест-кейс
- png-is-grayscale: улучшить видимость результата - is-grayscale: улучшить видимость результата
### 7.3 Дизайн-вопросы ### 7.3 Дизайн-вопросы
- add-padding-png vs add-border-png: разница неочевидна, нужен review - add-padding vs add-border: разница неочевидна, нужен review
- change-aspect-ratio-png: не видно что меняется — рамка результата - change-aspect-ratio: не видно что меняется — рамка результата
- watermark-tile-png: заполняет не полностью - watermark-tile: заполняет не полностью
- sepia-png: нужна ли настройка силы эффекта? - sepia: нужна ли настройка силы эффекта?
- posterize-png: настройка для каждого канала отдельно? - posterize: настройка для каждого канала отдельно?
- pixelate-png: разные алгоритмы? - pixelate: разные алгоритмы?
- swap-channels-png: обдумать flow через более простые инструменты - swap-channels: обдумать flow через более простые инструменты
### 7.4 Аудит полей (компоненты) ### 7.4 Аудит полей (компоненты)
- Range slider: поле ввода + кнопки +/- + кнопка сброса - Range slider: поле ввода + кнопки +/- + кнопка сброса
- Color picker: пипетка с картинки, прозрачность - Color picker: пипетка с картинки, прозрачность
- Width/height с keep-aspect-ratio: единый компонент - Width/height с keep-aspect-ratio: единый компонент
- Position offset: отдельные слайдеры x/y (уже сделано в `OffsetControl`) - Position offset: отдельные числовые поля X/Y (уже сделано в
`fields/schema/OffsetControl.svelte`)
+23 -19
View File
@@ -3,7 +3,7 @@
> **Статус: active.** Этот план добавляет к `docs/roadmap.md` то, чего там нет: > **Статус: active.** Этот план добавляет к `docs/roadmap.md` то, чего там нет:
> **API** (фаза 9, возможная монетизация) и **PWA** (фаза 10, оффлайн + > **API** (фаза 9, возможная монетизация) и **PWA** (фаза 10, оффлайн +
> установка), плюс шрифты (раздел 4 — частично относится к фазе 8, поэтому > установка), плюс шрифты (раздел 4 — частично относится к фазе 8, поэтому
> решения-phase 8 здесь дублируются). WASM-ядро, эталоны и CLI — фазы 2–8 > решения фазы 8 здесь дублируются). WASM-ядро, эталоны и CLI — фазы 2–8
> `docs/roadmap.md`, они здесь не пересказываются. Оба трека стартуют после > `docs/roadmap.md`, они здесь не пересказываются. Оба трека стартуют после
> wasm-фаз; PWA не ждёт API и может идти раньше. > wasm-фаз; PWA не ждёт API и может идти раньше.
@@ -11,19 +11,20 @@
- **Одно ядро — три потребителя.** Rust-крейт `core/` (roadmap фаза 4) даёт: - **Одно ядро — три потребителя.** Rust-крейт `core/` (roadmap фаза 4) даёт:
wasm-пакет для сайта (фаза 5), нативный CLI (фаза 6), серверный слой API (фаза wasm-пакет для сайта (фаза 5), нативный CLI (фаза 6), серверный слой API (фаза
9). Единый контракт — JSON-пайплайн `{tool, params}` (заложен в roadmap §0, 9). Единый контракт — JSON-пайплайн `{id, params}` (заложен в roadmap §0 и
реализуется в фазе 11); эталонные файлы и harness (фазы 2, 3, 7) гарантируют совпадает с `Page.steps`, реализуется в фазе 11); эталонные файлы и harness
одинаковый результат во всех рантаймах. (фазы 2, 3, 7) гарантируют одинаковый результат во всех рантаймах.
- **API — нативный Rust на сервере, не wasm.** В браузере wasm нужен ради - **API — нативный Rust на сервере, не wasm.** В браузере wasm нужен ради
доставки и песочницы; на сервере крейт линкуется напрямую — быстрее и проще. доставки и песочницы; на сервере крейт линкуется напрямую — быстрее и проще.
Исключение — edge-воркеры (см. спайк ниже): там исполняется тот же Исключение — edge-воркеры (см. спайк ниже): там исполняется тот же
wasm-артефакт фазы 5. wasm-артефакт фазы 5.
- **PWA не трогает SEO-схему.** Сайт остаётся полностью prerendered-статикой; - **PWA не трогает SEO-схему.** Сайт остаётся полностью prerendered-статикой;
service worker добавляет оффлайн-слой поверх и не отвечает за HTML ботовам. service worker добавляет оффлайн-слой поверх и не отвечает за HTML ботовам.
- **Монетизация не ломает позиционирование.** Браузерные инструменты — бесплатны - **Монетизация не ломает позиционирование.** Браузерные инструменты —
и без трекинга; API — отдельный продукт для автоматизации/бизнеса. Файлы в API бесплатные, картинка обрабатывается в браузере и никуда не отправляется.
обрабатываются в памяти: без хранения и логирования содержимого — продолжение Счётчик посещений GA4 этому не противоречит (границы — `docs/plan-seo.md` §0).
«privacy»-истории. API — отдельный продукт для автоматизации/бизнеса. Файлы в API обрабатываются
в памяти: без хранения и логирования содержимого.
## 1. Фаза 9 — API (после фаз 5–7) ## 1. Фаза 9 — API (после фаз 5–7)
@@ -40,7 +41,7 @@
запросам/размеру файла. Только после стабильного free tier. запросам/размеру файла. Только после стабильного free tier.
- [ ] **[A5]** Документация как лендинг: `/api` — индексируемая страница - [ ] **[A5]** Документация как лендинг: `/api` — индексируемая страница
(туториал, примеры curl/JS), OpenAPI, ссылка из `llms.txt`. Dev-аудитория (туториал, примеры curl/JS), OpenAPI, ссылка из `llms.txt`. Dev-аудитория
— источник бэклинков (план-seo S4c). — источник бэклинков (`docs/plan-seo.md`, S4c).
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере, - Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
CLI и API (harness фазы 7 расширяется третьим рантаймом). CLI и API (harness фазы 7 расширяется третьим рантаймом).
@@ -68,17 +69,17 @@
фича — фаза 11 того же документа. фича — фаза 11 того же документа.
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7. - PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
- Синергия с дистрибуцией: «works offline, installable» и публичный API — - Синергия с дистрибуцией: «works offline, installable» и публичный API —
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для аргументы для Product Hunt / alternative.to / GitHub (`docs/plan-seo.md`, S4c)
GEO-текстов (`llms.txt`, описания категорий). и для GEO-текстов (`llms.txt`, описания категорий).
## 4. Шрифты: лицензирование и DOM-free рендер текста ## 4. Шрифты: лицензирование и DOM-free рендер текста
Текст — один из canvas-зависимых классов, которые roadmap (фаза 8) держит в TS Текст — один из canvas-зависимых классов, которые roadmap (фаза 8) держит в TS
(там их четыре: текст, градиенты, штампы, `ctx.filter`); для текста выбран курс (перечень — там же); для текста выбран курс на реимплементацию, остальные классы
на реимплементацию, остальные классы не переносятся. Для CLI/API текст всё равно не переносятся. Для CLI/API текст всё равно придётся рендерить в Rust, поэтому
придётся рендерить в Rust, поэтому решение: **перевести text-инструменты в решение: **перевести text-инструменты в ядро** (в браузере canvas-рендер
ядро** (в браузере canvas-рендер остаётся TS-фолбэком для A/B). Два вопроса — остаётся TS-фолбэком для A/B). Два вопроса — легальность шрифтов в дистрибутиве
легальность шрифтов в дистрибутиве и рендер без DOM. и рендер без DOM.
**Когда начинать:** блока F1–F4 нет — они не привязаны к фазе roadmap, но F5/F6 **Когда начинать:** блока F1–F4 нет — они не привязаны к фазе roadmap, но F5/F6
без них бессмысленны (нечего рендерить). Реализуется всё это в фазе 8, поэтому без них бессмысленны (нечего рендерить). Реализуется всё это в фазе 8, поэтому
@@ -102,15 +103,18 @@
`pnpm verify` по правилам `docs/quality-gates.md` (решение о fail-режиме — `pnpm verify` по правилам `docs/quality-gates.md` (решение о fail-режиме —
`docs/decisions.md`). Браузер грузит шрифты из бандла, не из ОС и не с `docs/decisions.md`). Браузер грузит шрифты из бандла, не из ОС и не с
`fonts.googleapis.com`-CDN (сторонний запрос = утечка IP пользователя, `fonts.googleapis.com`-CDN (сторонний запрос = утечка IP пользователя,
противоречит «no tracking»), — одинаковый результат у всех пользователей и лишняя зависимость от чужой инфраструктуры), — одинаковый результат у всех
во всех рантаймах. пользователей и во всех рантаймах.
- [ ] **[F4]** Кириллица: приоритет семействам с полным покрытием — Liberation - [ ] **[F4]** Кириллица: приоритет семействам с полным покрытием — Liberation
Sans, PT Sans/PT Serif, Noto Sans, Inter, Roboto (OFL/Apache), DejaVu Sans Sans, PT Sans/PT Serif, Noto Sans, Inter, Roboto (OFL/Apache), DejaVu Sans
(пермиссивная лицензия Bitstream Vera: редистрибуция и коммерческое (пермиссивная лицензия Bitstream Vera: редистрибуция и коммерческое
использование разрешены) — закрывает и backlog-вопрос про «только с использование разрешены) — закрывает и backlog-вопрос про «только с
кириллицей». кириллицей».
- **Стартовый набор** (все OFL/Apache, с кириллицей; состав не догма — - **Стартовый набор** (все OFL/Apache, с кириллицей; состав не догма —
расширяется по запросам, каждый проходит паспорт F3): расширяется по запросам, каждый проходит паспорт F3). Сейчас в бандле сайта
лежат `@fontsource/ibm-plex-sans` и `@fontsource/ibm-plex-mono` (тоже OFL) — в
наборе ниже их нет, и при введении паспорта F3 их судьбу нужно решить явно:
оставить или заменить.
- базовый sans: **Inter** (дефолт add-text), **PT Sans** / **Golos Text** - базовый sans: **Inter** (дефолт add-text), **PT Sans** / **Golos Text**
(профессиональная русская типографика), **Noto Sans** (фолбэк покрытия); (профессиональная русская типографика), **Noto Sans** (фолбэк покрытия);
- serif: **PT Serif**, **Lora**, **Playfair Display** (display); - serif: **PT Serif**, **Lora**, **Playfair Display** (display);
+59 -37
View File
@@ -24,8 +24,9 @@
> схему сайта они не меняют. > схему сайта они не меняют.
> >
> **Слаги в этом документе:** `jpg-to-png`, `crop-jpeg` и подобное — интенты и > **Слаги в этом документе:** `jpg-to-png`, `crop-jpeg` и подобное — интенты и
> URL конкурента, а не id наших инструментов. Наши id — в `docs/tools-map.md`; в > URL конкурента, а не наши страницы. Наши страницы — в `docs/tools-map.md`
> тексте ниже реальные id инструментов приводятся в бэктиках. > (карта перечисляет `slug`, не `id`); в тексте ниже названия инструментов
> приводятся по этим слагам, потому что для SEO важны адреса.
## 0. Решения ## 0. Решения
@@ -72,9 +73,14 @@
`registry/pages/`. Схему не менять без отдельного SEO-плана: после индексации `registry/pages/`. Схему не менять без отдельного SEO-плана: после индексации
переименование URL означает 301 и потерю позиций. Разделение инструмента и переименование URL означает 301 и потерю позиций. Разделение инструмента и
страницы (решение выше) адресов не меняет. страницы (решение выше) адресов не меняет.
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или - **Аналитика — GA4, в режиме «только агрегаты».** GA4 уже стоит в проде
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя (`web/src/app.html`, prerender-оболочка, значит на каждой странице). Решение —
подрывать собственными скриптами. не выпиливать, а ограничить: cookieless-конфигурация, без рекламных audiences,
signals и ремаркетинга. Граница приватности проходит по данным изображения: GA
не видит ни файла, ни его содержимого, ни результата инструмента — только факт
открытия страницы. **Рекламу и ремаркетинг не ставим** — они противоречат
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по - **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
каждому инструменту, с ручной вычиткой. каждому инструменту, с ручной вычиткой.
@@ -105,10 +111,10 @@
| `rel=canonical` | нет | S1 | | `rel=canonical` | нет | S1 |
| OG/Twitter-теги, OG-картинки | нет | S1 | | OG/Twitter-теги, OG-картинки | нет | S1 |
| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 | | Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
| Контент страницы (H1-текст, how-to, FAQ, related) | только title/description | S2 | | Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 | | JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| `llms.txt`, хабы категорий с текстом | нет | S3 | | `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + приватная аналитика | нет | S4 | | GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 |
Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она
блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают
@@ -150,7 +156,7 @@
ссылок на инструменты. Но это этап S3e и только после того, как написано ядро ссылок на инструменты. Но это этап S3e и только после того, как написано ядро
S2: транзакционная страница приносит конверсию, информационная — только S2: транзакционная страница приносит конверсию, информационная — только
трафик. Тонкие FAQ-страницы «про всё» не заводить — несколько сильных трафик. Тонкие FAQ-страницы «про всё» не заводить — несколько сильных
материалов с ссылками на 3–5 инструментов каждый. материалов с ссылками на несколько связанных инструментов.
6. **Дистрибуция.** GitHub-репо (звёзды/форки = сигналы), alternative.to, 6. **Дистрибуция.** GitHub-репо (звёзды/форки = сигналы), alternative.to,
Product Hunt, Reddit по интенту — реальный путь набрать первые бэклинки без Product Hunt, Reddit по интенту — реальный путь набрать первые бэклинки без
бюджетов. бюджетов.
@@ -175,16 +181,20 @@
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на - [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд, существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
файл `static/og/[slug].png`, ссылка из `Seo.svelte`. Относительные пути файл `static/og/[slug].png`, ссылка из `Seo.svelte`. Путь к картинке
(`/og/crop-png.png`) работают без домена; при деплое на собственном домене строить через `resolve()` из `$app/paths` (как favicon в
`+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь
ломается. Домен для этого не нужен; при деплое на собственном домене
OG-картинки будут абсолютными и корректными. OG-картинки будут абсолютными и корректными.
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality - [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
для lossy) для всех инструментов — механика `Tool.output` уже есть для lossy) для всех инструментов — механика `Tool.output` уже есть
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа (`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести «image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
параллельно с текущими quality-задачами. Побочно снимает `quality` из схем параллельно с текущими quality-задачами. Побочно снимает `quality` из схем
страниц — после этого 14 convert-страниц сводятся на один инструмент **конвертеров** — после этого convert-страницы сводятся на один инструмент
(`docs/backlog.md`). (`docs/backlog.md`). Не из всех схем: у `jpeg-artifacts` `quality` —
функциональное поле (сила артефактов), а `output` у инструмента нет, так
что селектор формата его не покрывает.
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов - [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в
png. Существующие страницы не трогать: `convert-png-to-jpg`, png. Существующие страницы не трогать: `convert-png-to-jpg`,
@@ -198,10 +208,12 @@
`https://<юзер>/<repo>/…` на корневые URL (301). Делается одновременно с `https://<юзер>/<repo>/…` на корневые URL (301). Делается одновременно с
S1d (canonical), чтобы Google проиндексировал сразу с правильным доменом. S1d (canonical), чтобы Google проиндексировал сразу с правильным доменом.
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в - [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
`build/` из `TOOLS` реестра (`web/src/lib/registry`) + статических `build/` из `PAGES` реестра (`web/src/lib/registry`) + статических
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в страниц. Взять адреса у страниц, а не у инструментов: страниц может быть
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), несколько на один инструмент, у инструмента адреса нет. Вызов из
учёт опционального `BASE_PATH`. `pnpm build` после `vite build` (adapter-static пишет в `build/`,
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
опционального `BASE_PATH`.
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после - [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
cleanup — вернуть минимальный allow-all. cleanup — вернуть минимальный allow-all.
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по - [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
@@ -211,9 +223,9 @@
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h** > **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в > (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
> `build/` лежит корректный `sitemap.xml` со всеми инструментами; canonical/OG > `build/` лежит корректный `sitemap.xml` со всеми страницами инструментов;
> присутствуют на всех страницах; `noindex` отсутствует в собранном HTML > canonical/OG присутствуют на всех страницах; `noindex` отсутствует в собранном
> (`pnpm --dir web build` и проверка `build/tools/*/index.html`); > HTML (`pnpm --dir web build` и проверка `build/tools/*/index.html`);
> `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job > `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job
> `e2e` non-blocking, поэтому его статус читается по отдельному job > `e2e` non-blocking, поэтому его статус читается по отдельному job
> (`docs/decisions.md`, раздел 10). > (`docs/decisions.md`, раздел 10).
@@ -229,9 +241,9 @@
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка (`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
внутри категории + 2–3 «соседних» интента). внутри категории + 2–3 «соседних» интента).
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI. - [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI.
- [ ] **[S2d]** Первая волна контента — топ-30 инструментов ядра (приоритет по - [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
вордстату: transparent png, jpg→png, compress, resize, crop, rotate, (приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
circle crop, add text, pixelate, grayscale…). rotate, circle crop, add text, pixelate, grayscale…); отбор — §5.
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1 - Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
рантайме. рантайме.
@@ -266,8 +278,12 @@
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap, - [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
мониторинг индексации и запросов. Бесплатно и обязательно. мониторинг индексации и запросов. Бесплатно и обязательно.
- [ ] **[S4b]** Аналитика без трекинга: self-hosted Plausible/Umami либо - [ ] **[S4b]** Довести GA4 до решения §0: cookieless-конфигурация, отключённые
Cloudflare Web Analytics. GA/ЯМ не ставим (позиционирование). рекламные audiences и signals, `anonymize_ip`, запрет на передачу
параметров с данными картинки. Мусорные попадания уже отсечены: тег
подключается только при `PUBLIC_GA_ENABLED=true` (выставлен в
deploy-workflow, `+layout.svelte`). GA/ЯМ как замена счётчику не
рассматриваем.
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами), - [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
подача на alternative.to (страница против конкурента), Product Hunt, подача на alternative.to (страница против конкурента), Product Hunt,
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции. тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
@@ -279,19 +295,23 @@
## 5. Приоритизация инструментов (первая волна контента) ## 5. Приоритизация инструментов (первая волна контента)
Источник — `docs/tools-map.md` (наш состав и замер по конкуренту — §2). Ids ниже Источник — `docs/tools-map.md` (наш состав и замер по конкуренту — §2). Слаги
— **фактические id реестра**; первая волна — топ-интенты, где конкуренты ниже — **фактические `slug` страниц** из `docs/tools-map.md` §1; первая волна —
ранжируются, а у нас уже есть инструмент и он не хуже: топ-интенты, где конкуренты ранжируются, а у нас уже есть инструмент и он не
хуже:
- прозрачность/фон: create-empty-png, remove-background-png, change-png-opacity, - прозрачность/фон: create-empty-png, remove-background-png, change-png-opacity,
set-alpha-channel-png set-alpha-channel-png, round-corners-png
- конвертация: convert-png-to-jpg, convert-png-to-webp, png-to-bmp, svg-to-png, - конвертация: convert-png-to-jpg, convert-png-to-webp, png-to-bmp, svg-to-png,
png-to-base64 png-to-base64
- геометрия: crop-png, resize-png, rotate-png, round-corners-png, flip-png, - геометрия: crop-png, resize-png, rotate-png, flip-png, change-aspect-ratio-png
change-aspect-ratio-png - цвет/фильтры: grayscale-png, invert-colors-png, pixelate-png
- цвет/фильтры: grayscale-png, invert-colors-png, pixelate-png, add-text-png, - текст: add-text-png, watermark-tile-png
watermark-tile-png - генерация: linear-gradient-png
- генерация: linear-gradient-png, create-empty-png
> Группы здесь — по категории страницы в реестре (`registry/pages/`), а не по
> смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и
> `watermark-tile-png` — в `text`.
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть > **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких > `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
@@ -380,9 +400,10 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
- [ ] **[R1]** Механический переезд бренда на новое имя. Список файлов и строк с - [ ] **[R1]** Механический переезд бренда на новое имя. Список файлов и строк с
хардкодом — в локальном плане (раздел «Где живёт бренд в коде»); здесь хардкодом — в локальном плане (раздел «Где живёт бренд в коде»); здесь
только SEO-ограничения. **НЕ трогать:** адреса страниц инструментов только SEO-ограничения. **НЕ трогать:** адреса страниц инструментов
(`/tools/crop-png`), ключи localStorage (`easy-png-tools:*` — или (`/tools/crop-png`) и localStorage-ключи — `theme` и `locale` лежат без
мигрировать отдельной мелочью), историю `docs/archive`. Бренд должен стать префикса бренда, поэтому переименование с ними не столкнётся (проверено в
i18n-константой, а не тремя независимыми хардкодами. `theme.svelte.ts` и `i18n/locale.svelte.ts`); историю `docs/archive`.
Бренд должен стать i18n-константой, а не тремя независимыми хардкодами.
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и - [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
перевести прод на него без `BASE_PATH` (§0). Домен нужен до S1-domain: перевести прод на него без `BASE_PATH` (§0). Домен нужен до S1-domain:
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`. canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
@@ -396,7 +417,8 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
- Клонирование всего каталога конкурента (низкокачественные дубли + нет ресурсов - Клонирование всего каталога конкурента (низкокачественные дубли + нет ресурсов
на контент) — вместо этого ядро + обоснованные хвосты. на контент) — вместо этого ядро + обоснованные хвосты.
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang. - RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
- GA/реклама/трекинг — противоречит позиционированию. - Реклама, ремаркетинг, pixels сторонних сетей — противоречит позиционированию.
Счётчик посещений GA4 — не противоречит, условия ограничений — §0 и S4b.
- Дорвеи, программные зеркала, покупные ссылки. - Дорвеи, программные зеркала, покупные ссылки.
- Массово-сгенерированные форматные клоны (`crop-jpeg`/`crop-webp` как копии - Массово-сгенерированные форматные клоны (`crop-jpeg`/`crop-webp` как копии
`crop-png` с подстановкой формата в шаблонный текст) — doorway-паттерн, риск `crop-png` с подстановкой формата в шаблонный текст) — doorway-паттерн, риск
+4 -2
View File
@@ -98,8 +98,10 @@ download, темы или локализации. Изменения runtime fix
pattern-based policy для временно неиспользуемых `tools.*.params`; это не pattern-based policy для временно неиспользуемых `tools.*.params`; это не
молчаливый `allowPaths`-список. молчаливый `allowPaths`-список.
`lint:all` может завершиться успешно при warnings — это не означает чистый `lint:all` может завершиться успешно, напечатав unused-token сообщения: это
baseline. Агент показывает число errors, warnings и token warnings отдельно. единственный неблокирующий сигнал в нём. ESLint warnings, наоборот, роняют gate,
поэтому «прошёл» ≠ «нечего чинить». Агент показывает число errors, warnings и
token warnings раздельно.
## CI ## CI
+6 -5
View File
@@ -1,6 +1,6 @@
# План развития: easy-png-tools # План развития: easy-png-tools
> **Статус:** фаза 1 выполнена — сайт живёт на типизированном реестре, > **Статус: active.** Фаза 1 выполнена — сайт живёт на типизированном реестре,
> production UI — корневые маршруты. Текущий фокус: SEO S1-free > production UI — корневые маршруты. Текущий фокус: SEO S1-free
> (`docs/plan-seo.md`) + открытые задачи (`docs/backlog.md`). Домен нужен для > (`docs/plan-seo.md`) + открытые задачи (`docs/backlog.md`). Домен нужен для
> SEO (S1-domain). Фазы 2–8 — будущие; 9–10 спланированы в > SEO (S1-domain). Фазы 2–8 — будущие; 9–10 спланированы в
@@ -19,16 +19,17 @@
слои) — `docs/architecture.md`, разделы 2–3. слои) — `docs/architecture.md`, разделы 2–3.
- **Набор утилит для изображений, всё в браузере** (SvelteKit + - **Набор утилит для изображений, всё в браузере** (SvelteKit +
`adapter-static`), данные не покидают машину. `adapter-static`): файл картинки не покидает машину. Счётчик посещений GA4
этому не противоречит — границы `docs/plan-seo.md`, §0.
- **Первый и главный приоритет — полностью рабочий сайт**: максимум простых - **Первый и главный приоритет — полностью рабочий сайт**: максимум простых
инструментов, UX/UI, пайплайны (несколько инструментов подряд), описания инструментов, UX/UI, пайплайны (несколько инструментов подряд), описания
инструментов. инструментов.
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` — - **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` —
эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки
Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт. Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат зафиксирован этим - **Пайплайн = JSON-список шагов** `{id, params}` — формат шага совпадает с
решением и станет общим для браузера, wasm и CLI; реализуется в фазе 11, не в полем `Page.steps`. Формат зафиксирован этим решением и станет общим для
фазе 1. браузера, wasm и CLI; реализуется в фазе 11, не в фазе 1.
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из - Валидация будущего Rust-порта — эталонные файлы, сгенерированные из
TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций, TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций,
**perceptual diff** для canvas-зависимых (правила сравнения — раздел 3). **perceptual diff** для canvas-зависимых (правила сравнения — раздел 3).
+16 -4
View File
@@ -106,12 +106,24 @@ non-blocking E2E не считается заменой локальному smo
`web/playwright.config.ts` запускает четыре проекта: `web/playwright.config.ts` запускает четыре проекта:
| Проект | Движок | Спецификации | | Проект | Движок | Спецификации |
| ----------------- | -------- | ---------------------------------------------------------------------- | | ----------------- | -------- | ------------------------------------------------------------------------ |
| `chromium` | Chromium | все e2e | | `chromium` | Chromium | все e2e |
| `firefox` | Firefox | `pipeline`, `png-fixtures`, `text-and-verdicts` | | `firefox` | Firefox | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
| `webkit` | WebKit | `pipeline`, `png-fixtures`, `text-and-verdicts` | | `webkit` | WebKit | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
| `mobile-chromium` | Chromium | `navigation`, `catalog`, `generators`, `pipeline`, 390×844, `hasTouch` | | `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`, потом - Новые engine-специфичные фичи сначала добавляются в `chromium`, потом
решается, попадает ли spec в `browserContractTests`. решается, попадает ли spec в `browserContractTests`.
- Расхождения декодеров не ужесточаются и не скрываются skip-ом: они описаны в - Расхождения декодеров не ужесточаются и не скрываются skip-ом: они описаны в
+29 -16
View File
@@ -3,19 +3,24 @@
> Статус: **active**. **Дата прохода: 2026-09-13** (инструменты и поля). Здесь > Статус: **active**. **Дата прохода: 2026-09-13** (инструменты и поля). Здесь
> только «что плохо» — наблюдения, без порядка работ и без критериев приёмки. > только «что плохо» — наблюдения, без порядка работ и без критериев приёмки.
> Новый проход — новая дата сверху; закрытая находка не удаляется, а помечается > Новый проход — новая дата сверху; закрытая находка не удаляется, а помечается
> ссылкой на задачу в `docs/backlog.md`. > ссылкой на задачу в `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/backlog.md` (задачи) и
> `docs/plan-fields-audit-fixes.md` (порядок правок полей). Находка в аудите и > `docs/plan-fields-audit-fixes.md` (порядок правок полей). Находка в аудите и
> задача в бэклоге — не дубль: пока задачи нет, находка просто висит. > задача в бэклоге — не дубль: пока задачи нет, находка просто висит.
>
> Ids ниже **не сверены с реестром** и часть из них в коде отсутствует
> (`webp-to-png`, `color-wheel-generator`, `png-info`, `png-to-hsl`). Перед
> работой по любому пункту — найти соответствующий инструмент в
> `web/src/lib/registry/` или `docs/tools-map.md`; несуществующий id не
> resurrect'ить, а переименовать пункт. Чтобы не сверять вручную каждый раз, ищи
> по смыслу: категория в заголовке раздела + ключевое слово в пункте →
> `docs/tools-map.md`, где id уже фактические.
## везде ## везде
@@ -23,7 +28,7 @@
- инструменты без параметров выглядят странно, пересмотреть, может добавить - инструменты без параметров выглядят странно, пересмотреть, может добавить
какую-то надпись "нет параметров" какую-то надпись "нет параметров"
- удаление\маски и т.д. - надо добавить галочку сглаживание - удаление\маски и т.д. - надо добавить галочку сглаживание
- производительность: почему flip заметно тормозит, но нет индикации работы - производительность: почему flip-png заметно тормозит, но нет индикации работы
инструмента (также sharpen-png) инструмента (также sharpen-png)
- производительность: исследовать, есть ли смысл на инструментах геометрии - производительность: исследовать, есть ли смысл на инструментах геометрии
выводить рамку результата до полноценной работы (например на skew \ rotate выводить рамку результата до полноценной работы (например на skew \ rotate
@@ -31,7 +36,9 @@
## аудит инструментов ## аудит инструментов
### transparency ### alpha
(в UI раздел называется «Transparency» / «Прозрачность»)
- add-stroke-png: добавить галочку "расширить на толщину линии" - add-stroke-png: добавить галочку "расширить на толщину линии"
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо - find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо
@@ -64,7 +71,7 @@
полупрозрачной областью - закрывает всё, по описанию должен закрывать только полупрозрачной областью - закрывает всё, по описанию должен закрывать только
небольшие дырки небольшие дырки
### colors ### color
- two-colors-png: в range slider добавить описание что это luminance threshold - two-colors-png: в range slider добавить описание что это luminance threshold
- gamma-png: проверить правильность работы - яркое изображение выглядит не - gamma-png: проверить правильность работы - яркое изображение выглядит не
@@ -95,8 +102,10 @@
- add-border-png: прозрачность цвета (аудит полей) - add-border-png: прозрачность цвета (аудит полей)
- fit-on-background-png: цвет - fit-on-background-png: цвет
- change-canvas-size-png: использовать position9 вместо селекта - change-canvas-size-png: использовать position9 вместо селекта
- resize-png: по умолчанию размеры 0х0, надо поменять; выбор метода - resize-png: по умолчанию размеры 0х0 — сделано, дефолт берётся из source
масштабирования (`defaultFromSource`), до загрузки 1×1. Выбор метода масштабирования — поля в
схеме нет вовсе, это не правка селекта, а новое поле
(`docs/plan-fields-audit-fixes.md` §7.1)
- crop-png: offset - слишком большие значения - crop-png: offset - слишком большие значения
- rotate-png: кнопки вместо select - rotate-png: кнопки вместо select
- flip-png: кнопки вместо select - flip-png: кнопки вместо select
@@ -143,7 +152,9 @@
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный - png-is-grayscale: результат плохо виден, нужно ли копирование на полный
вердикт? может отдельные поля - 0\1, yes\no? вердикт? может отдельные поля - 0\1, yes\no?
### generators ### generate
(в UI раздел называется «Generate» / «Генерация»)
- color-spectrum-png: кнопки вместо select - color-spectrum-png: кнопки вместо select
- draw-grid-png: выбор цвета фона - draw-grid-png: выбор цвета фона
@@ -166,7 +177,9 @@
## аудит полей ## аудит полей
- у полей plate, position9 и font-style вторая подпись дублирует первую - у полей plate, position9 и font-style вторая подпись дублирует первую
- position offset: должны быть отдельные range slider на x и y - position offset: сделано — отдельные числовые поля X и Y в
`fields/schema/OffsetControl.svelte` (не range slider); задача на px/% вместо
них открыта, `docs/plan-fields-audit-fixes.md` §7.1
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание - range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами - range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
- color picker: пикер с картинки (как исходника, так и результата), прозрачность - color picker: пикер с картинки (как исходника, так и результата), прозрачность
+167 -152
View File
@@ -1,187 +1,190 @@
# Карта инструментов: реализовано · добавить · идеи # Карта инструментов: реализовано · добавить · идеи
> Статус: **active**. Живой документ для сверки с каталогом. > Статус: **active**. Навигационный список: что уже есть по адресам
> > `/tools/<slug>`, что можно добавить, что отложено. Источник правды — реестр
> **Карта перечисляет `slug` страниц (`web/src/lib/registry/pages/`) — то есть > (`web/src/lib/registry/`), модель страницы и инструмента —
> адреса вида `/tools/crop-png`:** это `slug: "crop-png"` у страницы, которая > `docs/architecture.md`, раздел 3; сверяться с реестром перед использованием.
> вызывает инструмент `id: "crop"`. Внутренние имена инструментов png-интента не
> держат, поэтому они от карты отделены; модель — `docs/architecture.md`,
> раздел 3. Источник правды — реестр. Этот список навигационный: сверять перед
> использованием. Формат записи: `slug — параметры через запятую`. Сравнение с
> каталогом источника (объём, нишевые клоны серий Logo*/Icon*/Stamp*Signature*)
> — замер в `docs/plan-seo.md`, §2; здесь он не дублируется.
>
> Количество инструментов здесь не фиксируется — оно выводится из раздела 1 и из > Количество инструментов здесь не фиксируется — оно выводится из раздела 1 и из
> `TOOLS` в `registry/index.ts`, и дублировать его цифрой значит заставлять себя > `PAGES` (`AGENTS.md`).
> обновлять документ при каждом добавлении (`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. Реализовано ## 1. Реализовано
Ids приведены к фактическим в `web/src/lib/registry/pages/`. Slugs приведены к фактическим в `web/src/lib/registry/pages/`. Разделы идут по
файлу реестра; поля — по схеме инструмента, который страница вызывает.
**Формат строки (проверяется тестом ### Конвертация (`convert`)
`web/src/lib/registry/tools-map-doc.test.ts`):** в буллите всё **до первого
тире** `—` — это slug страниц через `/` или `,`; всё после тире — свободное
описание параметров и не разбирается. Второй slug нельзя оставлять после тире,
иначе он не попадёт в разбор и страница будет считаться неописанной.
### Конвертация (`convert.ts`)
Отдельных «из формата в PNG» инструментов **нет**: входной формат выбирается Отдельных «из формата в PNG» инструментов **нет**: входной формат выбирается
загрузкой файла, любой инструмент принимает PNG/JPG/WebP/GIF/BMP. Все конвертеры загрузкой файла, любой инструмент принимает PNG/JPG/WebP/GIF/BMP. Все конвертеры
исходят из PNG. исходят из PNG.
- svg-to-png — width результата (текстовый вход: SVG-код) - `convert-png-to-jpg` — background, quality
- png-to-bmp — 24-бит, фон вместо альфы - `convert-png-to-webp` — quality
- convert-png-to-jpg — background, quality - `png-to-bmp` — без параметров (24-бит, фон вместо альфы)
- convert-png-to-webp — quality - `svg-to-png` — width (ширина результата; вход — SVG-код)
- png-to-base64 / base64-to-png — строка - `png-to-base64`, `base64-to-png` — без параметров (строка)
- png-to-data-uri / data-uri-to-png — строка - `png-to-data-uri`, `data-uri-to-png` — без параметров (строка)
- png-to-hex / hex-to-png — rrggbbaa по строкам / tokens + width - `png-to-hex`, `hex-to-png` — вход `width`; rrggbbaa по строкам / tokens
- png-to-bytes / bytes-to-png — десятичные RGBA-байты по строкам / tokens + - `png-to-bytes`, `bytes-to-png` — вход `width`; десятичные RGBA-байты по
width строкам / tokens
- png-to-rgb-values / rgb-values-to-png — rgba(r,g,b,a) по пикселям / числа + - `png-to-rgb-values`, `rgb-values-to-png` — вход `width`; rgba(r,g,b,a) по
width пикселям / числа
### Прозрачность (`alpha.ts`) ### Прозрачность (`alpha`)
- change-png-opacity — percent - `add-stroke-png` — color, thickness
- set-alpha-channel-png — percent - `find-contour-png` — color, thickness
- remove-alpha-channel-png — без параметров (фон `#ffffff` захардкожен, см. - `remove-color-from-png` — targetColor, tolerance
`docs/plan-fields-audit-fixes.md` D1) - `remove-background-png` — color, tolerance, outerOnly, smooth
- extract-alpha-mask-png — без параметров - `make-thicker-png`, `make-thinner-png` — radius
- invert-alpha-png — без параметров - `harden-alpha-png` — threshold
- remove-background-png — color, tolerance, outerOnly, smooth - `feather-edges-png` — radius (размытие только альфы)
- remove-color-from-png — targetColor, tolerance - `clean-edges-png` — radius (defringe: RGB от ближайшего непрозрачного)
- add-stroke-png — color, thickness - `despeckle-alpha-png`, `close-holes-png` — radius
- find-contour-png — color, thickness - `round-corners-png` — radius (% от половины меньшей стороны)
- make-thicker-png / make-thinner-png — radius - `invert-alpha-png` — без параметров
- harden-alpha-png — threshold - `extract-alpha-mask-png` — без параметров
- feather-edges-png — radius (размытие только альфы) - `set-alpha-channel-png` — percent
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного) - `remove-alpha-channel-png` — без параметров (фон `#ffffff` захардкожен,
- despeckle-alpha-png / close-holes-png — radius `docs/plan-fields-audit-fixes.md` FA-D1)
- center-by-alpha-png — без параметров - `circle-mask-png` — size (диаметр, % меньшей стороны), offset (x/y)
- round-corners-png — radius - `square-mask-png` — widthPct, heightPct, offset (x/y)
- circle-mask-png — size (диаметр, % меньшей стороны), offsetX, offsetY - `star-mask-png` — points, innerRadius, size, rotation, offset (x/y)
- square-mask-png — widthPct, heightPct, offsetX, offsetY - `wavy-mask-png` — size, amplitude, waves, phase, offset (x/y)
- star-mask-png — points, innerRadius, size, rotation, offsetX, offsetY
- wavy-mask-png — size, amplitude, waves, phase, offsetX, offsetY
### Цвет (`color.ts`) ### Цвет (`color`)
- grayscale-png / invert-colors-png / sepia-png / auto-contrast-png — без - `grayscale-png`, `invert-colors-png`, `sepia-png`, `auto-contrast-png` — без
параметров параметров
- adjust-brightness-contrast-png — brightness, contrast - `adjust-brightness-contrast-png` — brightness, contrast
- change-png-hue — degrees - `change-png-opacity` — percent
- extract-channel-png — channel (r/g/b) - `change-png-hue` — degrees
- swap-channels-png — pair (r-g/r-b/g-b) - `temperature-png` — percent
- black-and-white-png — threshold - `gamma-png` — value
- posterize-png — levels - `tint-png` — color, strength
- two-colors-png — lightColor, darkColor, threshold - `two-colors-png` — pair (светлый/тёмный), threshold
- temperature-png — percent - `black-and-white-png` — threshold
- gamma-png — value - `posterize-png` — levels
- tint-png — color, strength - `quantize-png` — colors (k, median-cut)
- quantize-png — colors (k, median-cut) - `decrease-color-count-png` — maxColors (2…256)
- decrease-color-count-png — maxColors (пресеты 2…256) - `custom-palette-png` — colors (hex через запятую, ближайший цвет)
- custom-palette-png — colors (hex через запятую, ближайший цвет) - `dithering-png` — colors (k), pattern (Floyd–Steinberg / Bayer 4×4)
- 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.ts`) ### Разложение каналов (`color`)
- png-to-hsl, png-to-hsv, png-to-hsi, png-to-cmyk, png-to-ycbcr, png-to-lab — - `png-to-hsl`, `png-to-hsv`, `png-to-hsi`, `png-to-cmyk`, `png-to-ycbcr`,
component (h/s/l и т.п.), display (`gray` | `space-as-rgb`) `png-to-lab` — component (h/s/l и т.п.), display (`gray` | `space-as-rgb`)
### Геометрия (`geometry.ts`) ### Геометрия (`geometry`)
- resize-png — width/height (размеры source по умолчанию; 0=авто для одной - `resize-png` — size (по умолчанию размер source, 0 по одной стороне = авто),
стороны), keepAspect keepAspect
- crop-png — x, y, width/height (полная область source по умолчанию) - `crop-png` — x, y, size (по умолчанию вся область source)
- rotate-png — angle (90/180/270) - `rotate-png` — angle (90/180/270)
- flip-png — axis (h/v) - `rotate-free-png` — angle
- skew-png — degX, degY - `flip-png` — axis (h/v)
- rotate-free-png — angle - `skew-png` — degX, degY
- zoom-png — scale - `zoom-png` — scale
- shift-png — offsetX, offsetY, color фона - `shift-png` — offsetX, offsetY, color
- add-padding-png — padding, transparent, color - `add-padding-png` — padding, transparent, color
- add-border-png — thickness, color - `add-border-png` — thickness, color
- fit-on-background-png — width, height, transparent, color - `fit-on-background-png` — size, transparent, color
- tile-png — columns, rows - `tile-png` — columns, rows
- split-into-parts-png — columns, rows (мультифайловый вывод, zip) - `split-into-parts-png` — columns, rows (мультифайловый вывод, zip)
- trim-empty-space-png — threshold (альфа) - `trim-empty-space-png` — threshold (по альфе)
- change-canvas-size-png — width, height, anchor (3×3) - `change-canvas-size-png` — size, anchor (3×3)
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad) - `change-aspect-ratio-png` — ratio (пресеты), mode (crop/pad)
- swap-orientation-png — target (portrait/landscape) - `swap-orientation-png` — target (portrait/landscape)
- symmetric-copy-png — axis, keepSide - `symmetric-copy-png` — axis, keepSide
- `center-by-alpha-png` — без параметров
### Фильтры (`filters.ts`) ### Фильтры (`filters`)
- blur-png — radius - `blur-png` — radius
- sharpen-png — strength - `sharpen-png` — strength
- vignette-png — strength - `vignette-png` — strength
- jpeg-artifacts-png — quality (имитация пережатия jpg/webp) - `jpeg-artifacts-png` — quality (имитация пережатия jpg/webp; поле
- pixelate-png — blockSize (закрывает и их Color Blocks) функциональное, у инструмента нет `output` — `docs/plan-seo.md` S1f)
- randomize-pixels-png — blockSize, seed - `pixelate-png` — blockSize
- add-noise-png — amount, mode (mono/color), seed - `randomize-pixels-png` — blockSize, seed
- silhouette-png — color, threshold - `add-noise-png` — amount, mode (mono/color), seed
- `silhouette-png` — color, threshold
### Анализ и вердикты (`analyze.ts`) ### Анализ и вердикты (`analyze`)
- png-file-size — без параметров (DOM-инструмент: кодирует в PNG и отдаёт - `png-file-size` — без параметров (DOM-инструмент: кодирует в PNG и отдаёт
размер) размер)
- png-is-transparent / png-is-grayscale / png-orientation — текстовый вердикт - `png-is-transparent`, `png-is-grayscale`, `png-orientation` — без параметров,
- verify-is-png — текстовый источник (base64/data-uri), вердикт по сигнатуре текстовый вердикт
- `verify-is-png` — без параметров (вход — текст: base64/data-uri), вердикт по
сигнатуре
### Маски по свойствам пикселей (`analyze.ts`) ### Маски по свойствам пикселей (`analyze`)
- show-transparent-png — color, opacity (подсветка прозрачных/полупрозрачных) - `show-transparent-png` — mode, color, opacity
- show-grayscale-pixels-png — tolerance, mode (binary/highlight), - `show-grayscale-pixels-png` — mode, tolerance, color, opacity
highlightColor, highlightOpacity - `show-color-pixels-png` — mode, tolerance, color, opacity
- show-color-pixels-png — tolerance, mode, highlightColor, highlightOpacity - `light-pixel-mask-png` — mode, threshold, color, opacity
- light-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity - `dark-pixel-mask-png` — mode, threshold, color, opacity
- dark-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity - `unique-color-mask-png` — mode, rarity (макс. повторов), color, opacity
- unique-color-mask-png — rarity (макс. повторов), mode, highlightColor, - `extract-color-from-png` — color, tolerance
highlightOpacity
- extract-color-from-png — color, tolerance (оставить близкие, остальное
прозрачным)
### Генерация (`generate.ts`) ### Генерация (`generate`)
- create-empty-png — width, height, transparent, color - `create-empty-png` — size, transparent, color
- single-color-png — width, height, color - `single-color-png` — size, color
- random-noise-png — width, height, seed - `random-noise-png` — size, seed
- linear-gradient-png — width, height, fromColor, toColor, direction - `linear-gradient-png` — size, gradient (от/к, направление)
- color-spectrum-png — width, height, direction, saturation, lightness - `color-spectrum-png` — size, direction, saturation, lightness
- random-colors-png — width, height, blockSize, seed - `random-colors-png` — size, blockSize, seed
- draw-grid-png — width, height, cols, rows, lineWidth, color, transparentBg - `draw-grid-png` — size, cols, rows, lineWidth, color, transparentBg
- placeholder-png — width, height, backgroundColor, color, showText - `placeholder-png` — size, backgroundColor, color, showText
- blend-two-png — pair (from/to), width - `blend-two-png` — pair (от/к), width
- step-colors-png — pair (from/to), steps, width - `step-colors-png` — pair (от/к), steps, width, layout (grid/strip)
- emoji-to-png — emoji, size - `color-wheel-png` — size, lightness
- text-to-png — text, fontSize, font, bold, color, transparentBg, - `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 backgroundColor, padding
- color-wheel-png — size, lightness - `complementary-png`, `triadic-png`, `tetradic-png`, `analogous-png`,
- complementary-png / triadic-png / tetradic-png / analogous-png / `monochromatic-png`, `shades-png` — baseColor, width, layout; у `analogous`
monochromatic-png / shades-png — общая база палитры: baseColor, width, layout; ещё spread и count, у `monochromatic` — count и range, у `shades` — count и
`shades-png` дополнительно count, depth depth
- mix-colors-png — colors, width
- sort-colors-png — colors, порядок (hue/brightness/saturation), width
### Текст (`text.ts`) ### Текст (`text`)
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin, - `add-text-png` — text, style (шрифт/размер/цвет/жирность), position (3×3),
plate, plateColor, plateOpacity margin, plate (плашка, её цвет и прозрачность)
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate, - `date-stamp-png` — format, style, position, margin, plate
plateColor, plateOpacity - `watermark-tile-png` — text, style, opacity, angle, stepX, stepY
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY,
font, bold
--- ---
## 2. Можно добавить — из onlinepngtools ## 2. Можно добавить — из onlinepngtools
Всё из этого списка **в реестре отсутствует**. Всё из этого списка **в реестре отсутствует** — это план, а не факт. Список
проверяется `web/src/lib/registry/tools-map-doc.test.ts` в обратную сторону:
реализованный slug, оставшийся здесь, роняет тест.
### Разложение каналов — остаток ### Разложение каналов — остаток
@@ -216,8 +219,11 @@ Ids приведены к фактическим в `web/src/lib/registry/pages/
### Сжатие и качество ### Сжатие и качество
- compress, reduce-to-size, pick-a-color, extract-barcode — **не реализованы**; - compress, reduce-to-size, pick-a-color, extract-barcode — **не реализованы**;
заведены в `docs/backlog.md` (reduce-to-size зависит от UX-райза «Download»). заведены в `docs/backlog.md`. Сжатие до целевого размера — прежде всего
Пипетка для выбора цвета уже есть в превью, отдельная страница не планируется. селектор формата в Download (S1f, `docs/plan-seo.md` §4): для lossy это
quality-ручка, а для PNG — квантование палитры. Нужен ли отдельный инструмент
под этот интент, решается по вордстату, имя страницы пока не выбрано. Пипетка
для выбора цвета уже есть в превью, отдельная страница не планируется.
Извлечение штрихкодов — HARD, вне планов. Извлечение штрихкодов — HARD, вне планов.
--- ---
@@ -227,12 +233,21 @@ Ids приведены к фактическим в `web/src/lib/registry/pages/
Развёрнутые описания этих идей живут в `docs/backlog.md`, раздел «Идеи»; здесь Развёрнутые описания этих идей живут в `docs/backlog.md`, раздел «Идеи»; здесь
только список. только список.
Буллиты этого раздела, как и раздела 2, перечисляют **несуществующие** страницы:
уже реализованные в них не называются, иначе тест решит, что slug пора
переносить в раздел 1. Реализованные примеры пишутся прозой между буллитами, как
ниже.
- **Region-инструменты** — нужен selection-компонент на превью: censor-region, - **Region-инструменты** — нужен selection-компонент на превью: censor-region,
erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area. erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area.
Один раз делаем selection — получаем шесть инструментов. Один раз делаем selection — получаем шесть инструментов.
- **Мультифайловый выход** — механизм «результат = набор файлов» (zip) - **Мультифайловый выход** — остальные потребители механизма «результат = набор
реализован на `split-into-parts-png`. Остальные 1→many: gif-to-frames, файлов»: gif-to-frames, separate-colors, multiply-grid-as-files — подключаются
separate-colors, multiply-grid-as-files — подключаются по мере нужды. по мере нужды.
Механизм уже реализован на `split-into-parts-png` (`docs/architecture.md`,
раздел 7), он в разделе 1.
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling: - **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата. это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital, - **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
@@ -240,6 +255,6 @@ Ids приведены к фактическим в `web/src/lib/registry/pages/
- **Нишевые серии** (logo/icon/stamp/signature — замер в шапке документа) — - **Нишевые серии** (logo/icon/stamp/signature — замер в шапке документа) —
сознательно не копируем: это обычные операции над конкретным контентом, у нас сознательно не копируем: это обычные операции над конкретным контентом, у нас
они доступны через базовые инструменты + цепочки. они доступны через базовые инструменты + цепочки.
- **Входные «из формата в PNG»** (jpg-to-png, webp-to-png) — решение и его SEO- - **Входные «из формата в PNG»** (jpg-to-png, webp-to-png) — решение и его
последствия описаны в разделе 1 выше; отдельные страницы под эти интенты — SEO-последствия описаны в `docs/plan-seo.md` §5; отдельные страницы под эти
предмет решения в `docs/plan-seo.md` §5. интенты — предмет решения там же.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "easy-png-tools", "name": "easy-png-tools",
"version": "0.1.0", "version": "0.1.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+16
View File
@@ -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);
}
+6 -2
View File
@@ -175,8 +175,9 @@ report-only (`warn`), baseline — 0 срабатываний.
- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не - **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не
используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`, используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`,
поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение
оставить проверку неблокирующей зафиксировано в `docs/quality-gates.md`. оставить проверку неблокирующей — `docs/decisions.md`, раздел 6 (как оно
Провалами (exit 1) считаются parity, hct-авторство и резолв `var()`. выглядит в гейте — `docs/quality-gates.md`). Провалами (exit 1) считаются
parity, hct-авторство и резолв `var()`.
## Тесты кастомных линт-правил ## Тесты кастомных линт-правил
@@ -187,6 +188,9 @@ report-only (`warn`), baseline — 0 срабатываний.
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами; `no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из `no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
`__fixtures__/src/app.css` (не из реального `src/app.css`). `__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__` — - Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
`process.cwd()` не трогается, реальные `src/` не читаются. `process.cwd()` не трогается, реальные `src/` не читаются.
+1 -1
View File
@@ -63,7 +63,7 @@ export default tseslint.config(
"e2e/helpers/page.ts", "e2e/helpers/page.ts",
"e2e/navigation.spec.ts", "e2e/navigation.spec.ts",
"e2e/catalog.spec.ts", "e2e/catalog.spec.ts",
"e2e/pipeline.spec.ts", "e2e/tool-flows.spec.ts",
"e2e/text-and-verdicts.spec.ts", "e2e/text-and-verdicts.spec.ts",
"e2e/tools-smoke.spec.ts", "e2e/tools-smoke.spec.ts",
"e2e/generators.spec.ts", "e2e/generators.spec.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.0.1", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+2 -2
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "playwright/test"; import { defineConfig } from "playwright/test";
const browserContractTests = [ const browserContractTests = [
"**/pipeline.spec.ts", "**/tool-flows.spec.ts",
"**/png-fixtures.spec.ts", "**/png-fixtures.spec.ts",
"**/text-and-verdicts.spec.ts", "**/text-and-verdicts.spec.ts",
]; ];
@@ -10,7 +10,7 @@ const mobileTests = [
"**/navigation.spec.ts", "**/navigation.spec.ts",
"**/catalog.spec.ts", "**/catalog.spec.ts",
"**/generators.spec.ts", "**/generators.spec.ts",
"**/pipeline.spec.ts", "**/tool-flows.spec.ts",
]; ];
export default defineConfig({ export default defineConfig({
-14
View File
@@ -4,20 +4,6 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" /> <meta name="text-scale" content="scale" />
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-MEQJ7JHRQQ"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-MEQJ7JHRQQ");
</script>
<script> <script>
(() => { (() => {
try { try {
@@ -1,62 +0,0 @@
<script lang="ts">
import { Upload } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import DownloadButton from "./ui/DownloadButton.svelte";
interface Props {
inputMode: "image" | "text" | "none";
canDownload: boolean;
running: boolean;
onupload: (file: File) => void;
ondownload: () => void;
}
let { inputMode, canDownload, running, onupload, ondownload }: Props =
$props();
</script>
<div class="actions">
{#if inputMode === "image"}
<label class="btn upload">
<Upload size={14} />
{t("actions.openImage")}
<input
type="file"
accept="image/*"
onchange={(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) onupload(f);
}}
/>
</label>
{/if}
{#if canDownload}
<DownloadButton
label={t("actions.downloadResult")}
onclick={ondownload}
disabled={running}
/>
{/if}
</div>
<style>
.actions {
display: flex;
align-items: center;
gap: var(--space-l);
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
cursor: pointer;
}
.upload input {
display: none;
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Funnel, Search } from "@lucide/svelte"; import { Funnel, Search } from "@lucide/svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
interface Props { interface Props {
@@ -33,14 +34,11 @@
<div class="catalog-filters"> <div class="catalog-filters">
<Funnel size={15} /> <Funnel size={15} />
{#each FILTERS as f (f.value)} {#each FILTERS as f (f.value)}
<button <ChipButton
type="button" label={f.label}
class:active={category === f.value} active={category === f.value}
aria-pressed={category === f.value}
onclick={() => (category = f.value)} onclick={() => (category = f.value)}
> />
{f.label}
</button>
{/each} {/each}
</div> </div>
</div> </div>
@@ -81,20 +79,4 @@
flex-wrap: wrap; flex-wrap: wrap;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.catalog-filters button {
border: var(--size-border) solid var(--color-border);
background: var(--color-panel);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
text-transform: uppercase;
padding: var(--space-s) var(--space-m);
border-radius: var(--radius-m);
cursor: pointer;
}
.catalog-filters button.active {
background: var(--color-main);
color: var(--color-background);
border-color: var(--color-main);
}
</style> </style>
@@ -2,7 +2,7 @@
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { ArrowUpRight } from "@lucide/svelte"; import { ArrowUpRight } from "@lucide/svelte";
import type { Component } from "svelte"; import type { Component } from "svelte";
import Icon from "./ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
interface Props { interface Props {
title: string; title: string;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Copy } from "@lucide/svelte"; import { Copy } from "@lucide/svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import IconButton from "./ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
interface Props { interface Props {
code: string; code: string;
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Ellipsis, GripVertical, X } from "@lucide/svelte"; import { Ellipsis, GripVertical, X } from "@lucide/svelte";
import IconButton from "$lib/components/ui/IconButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
@@ -26,14 +27,13 @@
</div> </div>
<div class="step-tools"> <div class="step-tools">
{#if tools}{@render tools()}{/if} {#if tools}{@render tools()}{/if}
<button <IconButton
type="button" icon={X}
class="step-remove" label={t("chain.removeStepAria")}
aria-label={t("chain.removeStepAria")} variant="clear"
size="s"
onclick={() => onremove?.()} onclick={() => onremove?.()}
> />
<X size={16} />
</button>
<Ellipsis size={17} /> <Ellipsis size={17} />
</div> </div>
</div> </div>
@@ -81,24 +81,10 @@
align-items: center; align-items: center;
gap: var(--space-l); gap: var(--space-l);
} }
.step-remove {
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
line-height: inherit;
& :global(svg) {
display: block;
line-height: inherit;
}
}
.step-card :global(.drag) { .step-card :global(.drag) {
margin-top: var(--space-xl); margin-top: var(--space-xl);
color: var(--color-border); color: var(--color-border);
} }
.step-remove:hover {
color: var(--color-danger);
}
.step-body > :not(.step-heading) { .step-body > :not(.step-heading) {
padding: var(--space-l); padding: var(--space-l);
} }
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Component, Snippet } from "svelte"; import type { Component, Snippet } from "svelte";
import Icon from "./ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
interface Props { interface Props {
title: string; title: string;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import StatusDot from "./StatusDot.svelte"; import StatusDot from "./StatusDot.svelte";
import MonoLabel from "./MonoLabel.svelte"; import MonoLabel from "$lib/components/display/MonoLabel.svelte";
interface Props { interface Props {
label: string; label: string;
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import MonoLabel from "../MonoLabel.svelte"; import MonoLabel from "$lib/components/display/MonoLabel.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
interface Preset { interface Preset {
@@ -49,10 +50,12 @@
</div> </div>
<div class="angle-presets"> <div class="angle-presets">
{#each presets as preset (preset.value)} {#each presets as preset (preset.value)}
<button <ChipButton
class:active={preset.value === value} label={preset.label}
onclick={() => onchange(preset.value)}>{preset.label}</button variant="outline"
> active={preset.value === value}
onclick={() => onchange(preset.value)}
/>
{/each} {/each}
</div> </div>
</div> </div>
@@ -88,22 +91,4 @@
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: var(--space-m); gap: var(--space-m);
} }
.angle-presets button {
padding: var(--space-m) 0;
color: var(--color-text-muted);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
cursor: pointer;
}
.angle-presets button:hover {
color: var(--color-text);
border-color: var(--color-main);
}
.angle-presets button.active {
color: var(--color-main);
border-color: var(--color-main);
}
</style> </style>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { CheckboxSpec, FieldSpec } from "$lib/registry-schema"; import type { CheckboxSpec, FieldSpec } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -15,30 +16,16 @@
); );
</script> </script>
<label class="control checkbox-control"> <Control element="label" {label} row>
<span>{label}</span>
<input <input
type="checkbox" type="checkbox"
class="checkbox-field" class="checkbox-field"
checked={current} checked={current}
onchange={(e) => onchange?.((e.target as HTMLInputElement).checked)} onchange={(e) => onchange?.((e.target as HTMLInputElement).checked)}
/> />
</label> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.checkbox-control {
display: flex;
justify-content: space-between;
align-items: center;
}
.checkbox-field { .checkbox-field {
width: var(--space-xl); width: var(--space-xl);
height: var(--space-xl); height: var(--space-xl);
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ColorSpec, FieldSpec } from "$lib/registry-schema"; import type { ColorSpec, FieldSpec } from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -13,47 +15,6 @@
const current = $derived(typeof value === "string" ? value : sp.default); const current = $derived(typeof value === "string" ? value : sp.default);
</script> </script>
<label class="control color-control"> <Control element="label" {label}>
<span>{label}</span> <ColorSwatchInput value={current} onchange={(v) => onchange?.(v)} />
<span class="color-row"> </Control>
<span class="swatch" style="background:{current}"></span>
<input
type="color"
value={current}
oninput={(e) => onchange?.((e.target as HTMLInputElement).value)}
/>
</span>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.color-control > span:first-child {
justify-content: flex-start;
}
.color-row {
display: flex;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
input[type="color"] {
width: 100%;
height: var(--space-xxl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-background);
cursor: pointer;
}
</style>
@@ -5,6 +5,8 @@
ColorPairSpec, ColorPairSpec,
FieldSpec, FieldSpec,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -28,45 +30,26 @@
} }
</script> </script>
<div class="control color-pair-control"> <Control {label} caps>
<span class="pair-label">{label}</span>
<div class="pair-field"> <div class="pair-field">
<label class="pair-axis"> <label class="pair-axis">
<span class="swatch" style="background:{current.from}"></span> <ColorSwatchInput
<input
type="color"
value={current.from} value={current.from}
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)} onchange={(v) => setColor("from", v)}
/> />
<span class="axis-label">{t("ui.from")}</span> <span class="axis-label">{t("ui.from")}</span>
</label> </label>
<label class="pair-axis"> <label class="pair-axis">
<span class="swatch" style="background:{current.to}"></span> <ColorSwatchInput
<input
type="color"
value={current.to} value={current.to}
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)} onchange={(v) => setColor("to", v)}
/> />
<span class="axis-label">{t("ui.to")}</span> <span class="axis-label">{t("ui.to")}</span>
</label> </label>
</div> </div>
</div> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.pair-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.pair-field { .pair-field {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -74,25 +57,6 @@
} }
.pair-axis { .pair-axis {
display: grid; display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m); gap: var(--space-m);
} }
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.pair-axis input[type="color"] {
width: 100%;
height: var(--space-xxl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-background);
cursor: pointer;
}
.axis-label {
grid-column: 1 / -1;
}
</style> </style>
@@ -0,0 +1,44 @@
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
onchange?: (value: string) => void;
}
let { value, disabled = false, onchange }: Props = $props();
</script>
<span class="swatch-field">
<span class="swatch" style="background:{value}"></span>
<input
type="color"
{value}
{disabled}
oninput={(e) => onchange?.((e.target as HTMLInputElement).value)}
/>
</span>
<style>
.swatch-field {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
cursor: pointer;
}
</style>
@@ -1,10 +1,14 @@
<script lang="ts"> <script lang="ts">
import { X } from "@lucide/svelte";
import Button from "$lib/components/ui/Button.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { import type {
ColorList, ColorList,
ColorListSpec, ColorListSpec,
FieldSpec, FieldSpec,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -41,8 +45,7 @@
} }
</script> </script>
<div class="control colors-control"> <Control {label} caps>
<span class="ctrl-label">{label}</span>
<div class="chips"> <div class="chips">
{#each current as hex, i (i)} {#each current as hex, i (i)}
<div class="chip"> <div class="chip">
@@ -55,38 +58,28 @@
/> />
</label> </label>
<span class="hex">{hex}</span> <span class="hex">{hex}</span>
<button <IconButton
type="button" icon={X}
class="remove" label={t("ui.removeColor")}
variant="clear"
size="s"
onclick={() => removeColor(i)} onclick={() => removeColor(i)}
disabled={current.length <= 1} disabled={current.length <= 1}
aria-label={t("ui.removeColor")} />
>
×
</button>
</div> </div>
{/each} {/each}
</div> </div>
<button type="button" class="add" onclick={addColor} <div class="add-row">
>{t("ui.addColor")}</button <Button
> label={t("ui.addColor")}
variant="outline"
size="s"
onclick={addColor}
/>
</div> </div>
</Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.ctrl-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.chips { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -121,39 +114,8 @@
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-s); font-size: var(--font-size-s);
} }
.remove { .add-row {
display: flex; display: flex;
align-items: center; justify-content: flex-start;
justify-content: center;
width: var(--space-xl);
height: var(--space-xl);
border: none;
border-radius: var(--radius-s);
background: none;
color: var(--color-text-muted);
font-size: var(--font-size-s);
cursor: pointer;
}
.remove:hover:not(:disabled) {
color: var(--color-text);
background: var(--color-background-muted);
}
.remove:disabled {
opacity: 0.4;
cursor: default;
}
.add {
justify-self: start;
padding: var(--space-s) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
} }
</style> </style>
@@ -0,0 +1,57 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
label: string;
element?: "div" | "label";
caps?: boolean;
row?: boolean;
trailing?: Snippet;
children: Snippet;
}
let {
label,
element = "div",
caps = false,
row = false,
trailing,
children,
}: Props = $props();
</script>
<svelte:element this={element} class="control" class:control--row={row}>
<span class="label" class:caps>
{label}
{#if trailing}{@render trailing()}{/if}
</span>
{@render children()}
</svelte:element>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.control--row {
display: flex;
justify-content: space-between;
align-items: center;
}
.label {
display: flex;
justify-content: space-between;
}
.caps {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
</style>
@@ -5,6 +5,7 @@
DimensionSpec, DimensionSpec,
FieldSpec, FieldSpec,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -34,8 +35,7 @@
} }
</script> </script>
<div class="control dimension-control"> <Control {label} caps>
<span class="dimension-label">{label}</span>
<div class="dimension-field"> <div class="dimension-field">
<label class="dimension-axis"> <label class="dimension-axis">
<span>{t("ui.width")}</span> <span>{t("ui.width")}</span>
@@ -60,23 +60,9 @@
/> />
</label> </label>
</div> </div>
</div> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.dimension-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.dimension-field { .dimension-field {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -6,6 +6,8 @@
FontStyle, FontStyle,
FontStyleSpec, FontStyleSpec,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -43,8 +45,7 @@
]; ];
</script> </script>
<div class="control font-style-control"> <Control {label} caps>
<span class="fs-label">{label}</span>
<div class="fs-grid"> <div class="fs-grid">
<label class="fs-cell"> <label class="fs-cell">
<span class="fs-sub">{t("ui.font")}</span> <span class="fs-sub">{t("ui.font")}</span>
@@ -73,15 +74,10 @@
</label> </label>
<label class="fs-cell"> <label class="fs-cell">
<span class="fs-sub">{t("ui.color")}</span> <span class="fs-sub">{t("ui.color")}</span>
<span class="fs-color"> <ColorSwatchInput
<span class="swatch" style="background:{current.color}"></span>
<input
class="fs-color-input"
type="color"
value={current.color} value={current.color}
oninput={(e) => set("color", (e.target as HTMLInputElement).value)} onchange={(v) => set("color", v)}
/> />
</span>
</label> </label>
<label class="fs-cell fs-bold"> <label class="fs-cell fs-bold">
<span class="fs-sub">{t("ui.bold")}</span> <span class="fs-sub">{t("ui.bold")}</span>
@@ -92,23 +88,9 @@
/> />
</label> </label>
</div> </div>
</div> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.fs-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.fs-grid { .fs-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -122,8 +104,7 @@
font-size: var(--font-size-s); font-size: var(--font-size-s);
} }
.fs-select, .fs-select,
.fs-input, .fs-input {
.fs-color-input {
width: 100%; width: 100%;
padding: var(--space-m) var(--space-l); padding: var(--space-m) var(--space-l);
background: var(--color-background); background: var(--color-background);
@@ -132,23 +113,6 @@
color: var(--color-text); color: var(--color-text);
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
.fs-color {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.fs-color-input {
height: var(--space-xxl);
padding: 0;
cursor: pointer;
}
.fs-bold { .fs-bold {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import AngleControl from "./AngleControl.svelte"; import AngleControl from "./AngleControl.svelte";
import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema"; import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema";
@@ -29,46 +31,21 @@
} }
</script> </script>
<div class="control gradient-control"> <Control {label} caps>
<span class="gradient-label">{label}</span>
<div class="gradient-colors"> <div class="gradient-colors">
<label class="color-field"> <label class="color-field">
<span>{t("ui.from")}</span> <span>{t("ui.from")}</span>
<span class="swatch" style="background:{current.from}"></span> <ColorSwatchInput value={current.from} onchange={(v) => set("from", v)} />
<input
type="color"
value={current.from}
oninput={(e) => set("from", (e.target as HTMLInputElement).value)}
/>
</label> </label>
<label class="color-field"> <label class="color-field">
<span>{t("ui.to")}</span> <span>{t("ui.to")}</span>
<span class="swatch" style="background:{current.to}"></span> <ColorSwatchInput value={current.to} onchange={(v) => set("to", v)} />
<input
type="color"
value={current.to}
oninput={(e) => set("to", (e.target as HTMLInputElement).value)}
/>
</label> </label>
</div> </div>
<AngleControl value={current.angle} onchange={(a) => set("angle", a)} /> <AngleControl value={current.angle} onchange={(a) => set("angle", a)} />
</div> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.gradient-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.gradient-colors { .gradient-colors {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -76,22 +53,8 @@
} }
.color-field { .color-field {
display: grid; display: grid;
grid-template-columns: auto var(--space-xxl) 1fr; grid-template-columns: auto 1fr;
align-items: center; align-items: center;
gap: var(--space-m); gap: var(--space-m);
} }
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.color-field input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
}
</style> </style>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { FieldSpec, Offset, OffsetSpec } from "$lib/registry-schema"; import type { FieldSpec, Offset, OffsetSpec } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -23,8 +24,7 @@
} }
</script> </script>
<div class="control offset-control"> <Control {label} caps>
<span class="offset-label">{label}</span>
<div class="offset-field"> <div class="offset-field">
<label class="offset-axis"> <label class="offset-axis">
<span>X</span> <span>X</span>
@@ -49,23 +49,9 @@
/> />
</label> </label>
</div> </div>
</div> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.offset-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.offset-field { .offset-field {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FieldSpec, Plate, PlateSpec } from "$lib/registry-schema"; import type { FieldSpec, Plate, PlateSpec } from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -28,8 +30,7 @@
} }
</script> </script>
<div class="control plate-control"> <Control {label} caps>
<span class="plate-label">{label}</span>
<div class="plate-row {current.enabled ? '' : 'off'}"> <div class="plate-row {current.enabled ? '' : 'off'}">
<label class="plate-enabled"> <label class="plate-enabled">
<span>{t("ui.backingPlate")}</span> <span>{t("ui.backingPlate")}</span>
@@ -39,15 +40,11 @@
onchange={(e) => set("enabled", (e.target as HTMLInputElement).checked)} onchange={(e) => set("enabled", (e.target as HTMLInputElement).checked)}
/> />
</label> </label>
<label class="plate-color"> <ColorSwatchInput
<span class="swatch" style="background:{current.color}"></span>
<input
type="color"
value={current.color} value={current.color}
disabled={!current.enabled} disabled={!current.enabled}
oninput={(e) => set("color", (e.target as HTMLInputElement).value)} onchange={(v) => set("color", v)}
/> />
</label>
<label class="plate-opacity"> <label class="plate-opacity">
<span> <span>
{t("ui.opacity")} {t("ui.opacity")}
@@ -65,23 +62,9 @@
/> />
</label> </label>
</div> </div>
</div> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.plate-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.plate-row { .plate-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -101,26 +84,6 @@
height: var(--space-xl); height: var(--space-xl);
accent-color: var(--color-main); accent-color: var(--color-main);
} }
.plate-color {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.plate-color input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
}
.plate-opacity { .plate-opacity {
display: grid; display: grid;
gap: var(--space-m); gap: var(--space-m);
@@ -5,6 +5,7 @@
type FieldSpec, type FieldSpec,
type Position9Spec, type Position9Spec,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -23,8 +24,7 @@
); );
</script> </script>
<div class="control position-control"> <Control {label} caps>
<span class="position-label">{label}</span>
<div class="grid"> <div class="grid">
{#each POSITION9_VALUES as position (position)} {#each POSITION9_VALUES as position (position)}
<button <button
@@ -38,23 +38,9 @@
</button> </button>
{/each} {/each}
</div> </div>
</div> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.position-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.grid { .grid {
display: inline-grid; display: inline-grid;
grid-template-columns: repeat(3, var(--space-xl)); grid-template-columns: repeat(3, var(--space-xl));
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { FieldSpec, NumberSpec, SliderSpec } from "$lib/registry-schema"; import type { FieldSpec, NumberSpec, SliderSpec } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -19,11 +20,10 @@
} }
</script> </script>
<label class="control"> <Control element="label" {label}>
<span> {#snippet trailing()}
{label}
<output>{current}</output> <output>{current}</output>
</span> {/snippet}
<input <input
aria-label={label} aria-label={label}
type="range" type="range"
@@ -33,22 +33,10 @@
value={current} value={current}
oninput={handle} oninput={handle}
/> />
</label> </Control>
<style> <style>
.control { output {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.control > span {
display: flex;
justify-content: space-between;
}
.control output {
color: var(--color-text); color: var(--color-text);
} }
input[type="range"] { input[type="range"] {
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { optionLabel } from "$lib/i18n/schema-tool-strings"; import { optionLabel } from "$lib/i18n/schema-tool-strings";
import type { FieldSpec, SelectSpec } from "$lib/registry-schema"; import type { FieldSpec, SelectSpec } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -20,8 +21,7 @@
); );
</script> </script>
<label class="control"> <Control element="label" {label}>
<span>{label}</span>
<select <select
class="select-field" class="select-field"
value={current} value={current}
@@ -33,17 +33,9 @@
</option> </option>
{/each} {/each}
</select> </select>
</label> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.select-field { .select-field {
width: 100%; width: 100%;
padding: var(--space-m) var(--space-l); padding: var(--space-m) var(--space-l);
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { FieldSpec, TextSpec } from "$lib/registry-schema"; import type { FieldSpec, TextSpec } from "$lib/registry-schema";
import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -13,8 +14,7 @@
const current = $derived(typeof value === "string" ? value : sp.default); const current = $derived(typeof value === "string" ? value : sp.default);
</script> </script>
<label class="control"> <Control element="label" {label}>
<span>{label}</span>
<input <input
class="text-field" class="text-field"
type="text" type="text"
@@ -22,17 +22,9 @@
placeholder={sp.placeholder} placeholder={sp.placeholder}
oninput={(e) => onchange?.((e.target as HTMLInputElement).value)} oninput={(e) => onchange?.((e.target as HTMLInputElement).value)}
/> />
</label> </Control>
<style> <style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.text-field { .text-field {
width: 100%; width: 100%;
padding: var(--space-m) var(--space-l); padding: var(--space-m) var(--space-l);
+7 -1
View File
@@ -1,5 +1,11 @@
<script lang="ts">
import { version } from "../../../../package.json";
const versionLabel = `v${version}`;
</script>
<footer class="preview-footer"> <footer class="preview-footer">
<span>easy-png-tools <b class="version">v0.1.0</b></span> <span>easy-png-tools <b class="version">{versionLabel}</b></span>
<span>© 2026</span> <span>© 2026</span>
</footer> </footer>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import PanelHeading from "../PanelHeading.svelte"; import PanelHeading from "./PanelHeading.svelte";
interface Props { interface Props {
title: string; title: string;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte"; import MonoLabel from "$lib/components/display/MonoLabel.svelte";
interface Props { interface Props {
title: string; title: string;
+3 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import LangToggle from "../ui/LangToggle.svelte"; import LangToggle from "$lib/components/ui/LangToggle.svelte";
import ThemeToggle from "../ui/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ui/ThemeToggle.svelte";
import TopNav from "../ui/TopNav.svelte"; import TopNav from "./TopNav.svelte";
interface Props { interface Props {
theme: "light" | "dark"; theme: "light" | "dark";
@@ -0,0 +1,37 @@
<script lang="ts">
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte";
import { t } from "$lib/i18n/t";
interface Props {
inputMode: "image" | "text" | "none";
canDownload: boolean;
running: boolean;
onupload: (file: File) => void;
ondownload: () => void;
}
let { inputMode, canDownload, running, onupload, ondownload }: Props =
$props();
</script>
<div class="actions">
{#if inputMode === "image"}
<UploadButton label={t("actions.openImage")} onfile={onupload} />
{/if}
{#if canDownload}
<DownloadButton
label={t("actions.downloadResult")}
onclick={ondownload}
disabled={running}
/>
{/if}
</div>
<style>
.actions {
display: flex;
align-items: center;
gap: var(--space-l);
flex-wrap: wrap;
}
</style>
@@ -9,19 +9,20 @@
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { RotateCcw } from "@lucide/svelte"; import { RotateCcw } from "@lucide/svelte";
import type { Component } from "svelte"; import type { Component } from "svelte";
import DimensionField from "./fields/DimensionField.svelte"; import Button from "$lib/components/ui/Button.svelte";
import CheckboxControl from "./fields/schema/CheckboxControl.svelte"; import DimensionField from "$lib/components/fields/schema/DimensionField.svelte";
import ColorControl from "./fields/schema/ColorControl.svelte"; import CheckboxControl from "$lib/components/fields/schema/CheckboxControl.svelte";
import ColorPairControl from "./fields/schema/ColorPairControl.svelte"; import ColorControl from "$lib/components/fields/schema/ColorControl.svelte";
import ColorsControl from "./fields/schema/ColorsControl.svelte"; import ColorPairControl from "$lib/components/fields/schema/ColorPairControl.svelte";
import RangeControl from "./fields/schema/RangeControl.svelte"; import ColorsControl from "$lib/components/fields/schema/ColorsControl.svelte";
import SelectControl from "./fields/schema/SelectControl.svelte"; import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
import TextControl from "./fields/schema/TextControl.svelte"; import SelectControl from "$lib/components/fields/schema/SelectControl.svelte";
import OffsetControl from "./fields/schema/OffsetControl.svelte"; import TextControl from "$lib/components/fields/schema/TextControl.svelte";
import PositionControl from "./fields/schema/PositionControl.svelte"; import OffsetControl from "$lib/components/fields/schema/OffsetControl.svelte";
import FontStyleControl from "./fields/schema/FontStyleControl.svelte"; import PositionControl from "$lib/components/fields/schema/PositionControl.svelte";
import PlateControl from "./fields/schema/PlateControl.svelte"; import FontStyleControl from "$lib/components/fields/schema/FontStyleControl.svelte";
import GradientControl from "./fields/schema/GradientControl.svelte"; import PlateControl from "$lib/components/fields/schema/PlateControl.svelte";
import GradientControl from "$lib/components/fields/schema/GradientControl.svelte";
interface FieldControlProps { interface FieldControlProps {
label: string; label: string;
@@ -86,10 +87,13 @@
{/each} {/each}
<div class="panel-foot"> <div class="panel-foot">
<button class="reset-btn" onclick={onreset}> <Button
<RotateCcw size={14} /> icon={RotateCcw}
{t("ui.reset")} label={t("ui.reset")}
</button> variant="clear"
size="s"
onclick={onreset}
/>
<span class="auto-note"><i></i> {t("ui.autoUpdate")}</span> <span class="auto-note"><i></i> {t("ui.autoUpdate")}</span>
</div> </div>
@@ -120,19 +124,6 @@
padding-top: var(--space-l); padding-top: var(--space-l);
border-top: var(--size-border) solid var(--color-border); border-top: var(--size-border) solid var(--color-border);
} }
.reset-btn {
display: inline-flex;
align-items: center;
gap: var(--space-m);
background: none;
border: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.reset-btn:hover {
color: var(--color-text);
}
.auto-note i { .auto-note i {
width: var(--size-border-thick); width: var(--size-border-thick);
height: var(--size-border-thick); height: var(--size-border-thick);
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import MetaList from "$lib/components/MetaList.svelte"; import MetaList from "$lib/components/display/MetaList.svelte";
import { buildSchemaPreviewModel } from "$lib/components/schema-preview-model"; import { buildSchemaPreviewModel } from "./schema-preview-model";
import SchemaActions from "$lib/components/SchemaActions.svelte"; import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "$lib/components/SchemaResultTile.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "$lib/components/SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FileResult, InputMode, ResultKind } from "$lib/registry"; import type { FileResult, InputMode, ResultKind } from "$lib/registry";
@@ -5,7 +5,7 @@
import type { FileResult, ResultKind } from "$lib/registry"; import type { FileResult, ResultKind } from "$lib/registry";
import { Check } from "@lucide/svelte"; import { Check } from "@lucide/svelte";
import SchemaTextResult from "./SchemaTextResult.svelte"; import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./layout/PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
interface Props { interface Props {
resultKind?: ResultKind; resultKind?: ResultKind;
@@ -3,7 +3,7 @@
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { InputMode } from "$lib/registry"; import type { InputMode } from "$lib/registry";
import PreviewTile from "./layout/PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
import SchemaTextSource from "./SchemaTextSource.svelte"; import SchemaTextSource from "./SchemaTextSource.svelte";
interface Props { interface Props {
@@ -2,7 +2,7 @@
import { verdictText, verdictTone } from "$lib/i18n/schema-tool-strings"; import { verdictText, verdictTone } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { Copy, Download, FileText } from "@lucide/svelte"; import { Copy, Download, FileText } from "@lucide/svelte";
import IconButton from "./ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
interface Props { interface Props {
value: string; value: string;
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ArrowUp, Upload } from "@lucide/svelte"; import { ArrowUp, Upload } from "@lucide/svelte";
import Button from "$lib/components/ui/Button.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
interface Props { interface Props {
@@ -36,15 +37,21 @@
}}></textarea> }}></textarea>
<div class="actions"> <div class="actions">
{#if onsample} {#if onsample}
<button type="button" class="secondary" onclick={onsample}> <Button
<Upload size={14} /> icon={Upload}
{t("textSource.trySample")} label={t("textSource.trySample")}
</button> variant="outline"
size="s"
onclick={onsample}
/>
{/if} {/if}
<button type="button" class="render" onclick={onrender} {disabled}> <Button
<ArrowUp size={14} /> icon={ArrowUp}
{t("textSource.render")} label={t("textSource.render")}
</button> size="s"
onclick={onrender}
{disabled}
/>
</div> </div>
</div> </div>
@@ -76,36 +83,4 @@
gap: var(--space-m); gap: var(--space-m);
justify-content: flex-end; justify-content: flex-end;
} }
.render {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-main);
border-radius: var(--radius-m);
background: var(--color-main);
color: var(--color-background);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.render:disabled {
opacity: 0.6;
cursor: default;
}
.secondary {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.secondary:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
</style> </style>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import SchemaFields from "$lib/components/SchemaFields.svelte"; import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "$lib/components/SchemaPreview.svelte"; import SchemaPreview from "./SchemaPreview.svelte";
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { ToolError, type ErrorVars } from "$lib/core/errors"; import { ToolError, type ErrorVars } from "$lib/core/errors";
import { decodeFile, downloadBlob, encode } from "$lib/core/io"; import { decodeFile, downloadBlob, encode } from "$lib/core/io";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { makeImage } from "../core/test-helpers"; import { makeImage } from "../../core/test-helpers";
import { import {
buildSchemaPreviewModel, buildSchemaPreviewModel,
type PreviewTranslate, type PreviewTranslate,
@@ -1,5 +1,5 @@
import type { PixelImage } from "../core/types"; import type { PixelImage } from "$lib/core/types";
import type { FileResult, InputMode, ResultKind } from "../registry"; import type { FileResult, InputMode, ResultKind } from "$lib/registry";
export type PreviewTranslate = (key: string) => string; export type PreviewTranslate = (key: string) => string;
+46 -2
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import { ButtonVariantDefine, type ButtonVariant } from "../define"; import {
ButtonVariantDefine,
type ButtonVariant,
type ButtonSize,
} from "./define";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
interface Props { interface Props {
@@ -8,21 +12,27 @@
ariaLabel?: string; ariaLabel?: string;
onclick?: () => void; onclick?: () => void;
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize;
icon?: Component<{ size?: number; class?: string }>; icon?: Component<{ size?: number; class?: string }>;
disabled?: boolean; disabled?: boolean;
type?: "button" | "submit";
} }
let { let {
label, label,
ariaLabel = label, ariaLabel = label,
onclick, onclick,
variant = ButtonVariantDefine.PRIMARY, variant = ButtonVariantDefine.PRIMARY,
size = "m",
icon, icon,
disabled = false, disabled = false,
type = "button",
}: Props = $props(); }: Props = $props();
</script> </script>
<button <button
class="btn btn--{variant}" class="btn btn--{variant} btn--{size}"
class:btn--icon={!label}
{type}
{disabled} {disabled}
aria-label={ariaLabel} aria-label={ariaLabel}
onclick={() => onclick?.()} onclick={() => onclick?.()}
@@ -51,12 +61,36 @@
color: var(--color-background); color: var(--color-background);
} }
.btn--s {
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border-width: var(--size-border);
border-radius: var(--radius-m);
font-size: var(--font-size-s);
font-family: var(--font-mono);
font-weight: normal;
}
.btn--s.btn--icon {
padding: var(--space-s);
}
.btn--outline { .btn--outline {
background: transparent; background: transparent;
color: var(--color-main-tint); color: var(--color-main-tint);
border-color: var(--color-main-tint); border-color: var(--color-main-tint);
} }
.btn--s.btn--outline {
color: var(--color-text-muted);
border-color: var(--color-border);
}
.btn--s.btn--outline:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
.btn--accent { .btn--accent {
background: var(--color-accent); background: var(--color-accent);
color: var(--color-background); color: var(--color-background);
@@ -69,6 +103,16 @@
border-color: var(--color-danger); border-color: var(--color-danger);
} }
.btn--clear {
background: none;
border-color: transparent;
color: var(--color-text-muted);
}
.btn--clear:hover {
color: var(--color-text);
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -0,0 +1,56 @@
<script lang="ts">
interface Props {
label: string;
active?: boolean;
variant?: "filled" | "outline";
onclick?: () => void;
}
let { label, active = false, variant = "filled", onclick }: Props = $props();
</script>
<button
type="button"
class="chip chip--{variant}"
class:active
aria-pressed={active}
onclick={() => onclick?.()}
>
{label}
</button>
<style>
.chip {
border: var(--size-border) solid var(--color-border);
background: var(--color-panel);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
text-transform: uppercase;
padding: var(--space-s) var(--space-m);
border-radius: var(--radius-m);
cursor: pointer;
}
.chip--filled.active {
background: var(--color-main);
color: var(--color-background);
border-color: var(--color-main);
}
.chip--outline {
padding: var(--space-m) 0;
background: var(--color-background);
border-radius: var(--radius-s);
text-transform: none;
}
.chip--outline:hover {
color: var(--color-text);
border-color: var(--color-main);
}
.chip--outline.active {
color: var(--color-main);
border-color: var(--color-main);
}
</style>
+16 -2
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import { ButtonVariantDefine, type ButtonVariant } from "../define"; import {
ButtonVariantDefine,
type ButtonVariant,
type ButtonSize,
} from "./define";
import Button from "./Button.svelte"; import Button from "./Button.svelte";
interface Props { interface Props {
@@ -8,6 +12,7 @@
label: string; label: string;
onclick?: () => void; onclick?: () => void;
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean; disabled?: boolean;
} }
@@ -16,8 +21,17 @@
label, label,
onclick, onclick,
variant = ButtonVariantDefine.PRIMARY, variant = ButtonVariantDefine.PRIMARY,
size = "m",
disabled = false, disabled = false,
}: Props = $props(); }: Props = $props();
</script> </script>
<Button label="" {onclick} {variant} {disabled} {icon} ariaLabel={label} /> <Button
label=""
{onclick}
{variant}
{size}
{disabled}
{icon}
ariaLabel={label}
/>
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Moon, Sun } from "@lucide/svelte"; import { Moon, Sun } from "@lucide/svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import IconButton from "../ui/IconButton.svelte"; import IconButton from "./IconButton.svelte";
interface Props { interface Props {
theme: "light" | "dark"; theme: "light" | "dark";
@@ -0,0 +1,47 @@
<script lang="ts">
import { Upload } from "@lucide/svelte";
import Icon from "./Icon.svelte";
interface Props {
label: string;
accept?: string;
onfile: (file: File) => void;
}
let { label, accept = "image/*", onfile }: Props = $props();
</script>
<label class="upload">
<Icon icon={Upload} size={14} />
{label}
<input
type="file"
{accept}
onchange={(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) onfile(f);
}}
/>
</label>
<style>
.upload {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
cursor: pointer;
}
.upload:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
.upload input {
display: none;
}
</style>
@@ -8,3 +8,11 @@ export const ButtonVariantDefine = {
export type ButtonVariant = export type ButtonVariant =
(typeof ButtonVariantDefine)[keyof typeof ButtonVariantDefine]; (typeof ButtonVariantDefine)[keyof typeof ButtonVariantDefine];
export const ButtonSizeDefine = {
M: "m",
S: "s",
} as const;
export type ButtonSize =
(typeof ButtonSizeDefine)[keyof typeof ButtonSizeDefine];
+28
View File
@@ -59,4 +59,32 @@ describe("renderShape", () => {
expect(shifted.data[3]).toBe(0); expect(shifted.data[3]).toBe(0);
expect(shifted.data[4 + 3]).toBe(255); expect(shifted.data[4 + 3]).toBe(255);
}); });
// Регрессия старого бага square-mask: полный размер без смещения обязан
// сохранять квадратную картинку целиком.
it("квадрат 100% без смещения сохраняет всё квадратное изображение", () => {
const out = renderShape(
makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])),
boxTest(0.5, 0.5),
);
for (let i = 3; i < out.data.length; i += 4) expect(out.data[i]).toBe(255);
});
// Смещение — доля свободного места: ±0.5 прижимает фигуру к краю при любом
// её размере, фигура не уезжает за холст.
it("максимальное смещение прижимает фигуру к краю", () => {
const out = renderShape(
makeImage(4, 4, new Array(16).fill([255, 255, 255, 255])),
boxTest(0.25, 0.25),
0.5,
0.5,
{ x: 0.25, y: 0.25 },
);
const opaqueAt = (x: number, y: number) =>
out.data[(y * 4 + x) * 4 + 3] === 255;
expect(opaqueAt(3, 3)).toBe(true);
expect(opaqueAt(2, 2)).toBe(true);
expect(opaqueAt(1, 1)).toBe(false);
expect(opaqueAt(2, 1)).toBe(false);
});
}); });
+15 -3
View File
@@ -3,6 +3,12 @@ import { createPixelImage } from "./types";
export type ShapeTest = (nx: number, ny: number) => boolean; export type ShapeTest = (nx: number, ny: number) => boolean;
/** Полуразмер фигуры в нормированных координатах теста, по осям. */
export interface ShapeExtent {
x: number;
y: number;
}
/** /**
* Координаты теста: нормированные к половине меньшей стороны изображения, * Координаты теста: нормированные к половине меньшей стороны изображения,
* центр в (0,0), ось Y вниз как в пикселях. * центр в (0,0), ось Y вниз как в пикселях.
@@ -54,18 +60,24 @@ export function wavyTest(
/** /**
* Вырезает фигуру из изображения: внутри фигуры сохраняются исходные пиксели * Вырезает фигуру из изображения: внутри фигуры сохраняются исходные пиксели
* (с их альфой), снаружи альфа обнуляется. Смещение задаётся в долях меньшей стороны. * (с их альфой), снаружи альфа обнуляется. Смещение задаётся в долях свободного
* места по каждой оси: ±0.5 прижимает фигуру к краю независимо от её размера,
* поэтому фигура никогда не уезжает за холст. Свободное место считается от
* extent (полуразмера фигуры); без extent смещение считается от точки.
*/ */
export function renderShape( export function renderShape(
img: PixelImage, img: PixelImage,
test: ShapeTest, test: ShapeTest,
offsetXFrac = 0, offsetXFrac = 0,
offsetYFrac = 0, offsetYFrac = 0,
extent: ShapeExtent = { x: 0, y: 0 },
): PixelImage { ): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const minDim = Math.min(img.width, img.height); const minDim = Math.min(img.width, img.height);
const cx = img.width / 2 + offsetXFrac * minDim; const freeX = Math.max(0, img.width / (2 * minDim) - extent.x);
const cy = img.height / 2 + offsetYFrac * minDim; const freeY = Math.max(0, img.height / (2 * minDim) - extent.y);
const cx = img.width / 2 + offsetXFrac * 2 * freeX * minDim;
const cy = img.height / 2 + offsetYFrac * 2 * freeY * minDim;
for (let y = 0; y < img.height; y++) { for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) { for (let x = 0; x < img.width; x++) {
const di = (y * img.width + x) * 4; const di = (y * img.width + x) * 4;
+57 -13
View File
@@ -8,30 +8,35 @@ const MAP_PATH = fileURLToPath(
); );
const SECTION_START = /^## 1\.\s/; const SECTION_START = /^## 1\.\s/;
const SECTION_END = /^## 2\.\s/; const SECTION_PLANNED = /^## 2\.\s/;
// Все slug страниц содержат дефис, поэтому формат строгий: это отсекает слова // Все slug страниц содержат дефис, поэтому формат строгий: это отсекает слова
// описания вроде `median-cut` из «colors (k, median-cut)». // описания вроде `median-cut` из «colors (k, median-cut)».
const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/; const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
const ID_TOKEN = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g;
type Mention = { id: string; line: number }; type Mention = { id: string; line: number };
/** /**
* Буллиты раздела «1. Реализовано» вместе с номером первой строки. * Буллиты раздела вместе с номером первой строки. Перенесённые строки
* Перенесённые строки приклеиваются к буллету: иначе slug, оказавшийся на * приклеиваются к буллету: иначе slug, оказавшийся на следующей строке, молча
* следующей строке, молча выпадал бы из разбора и страница считалась бы * выпадал бы из разбора и страница считалась бы неописанной.
* неописанной.
*/ */
function implementedBullets( function sectionBullets(
markdown: string, markdown: string,
startPattern: RegExp,
endPattern: RegExp | null,
): { text: string; line: number }[] { ): { text: string; line: number }[] {
const lines = markdown.split("\n"); const lines = markdown.split("\n");
const start = lines.findIndex((l) => SECTION_START.test(l)); const start = lines.findIndex((l) => startPattern.test(l));
const end = lines.findIndex((l) => SECTION_END.test(l)); const end =
endPattern === null
? lines.length
: lines.findIndex((l) => endPattern.test(l));
if (start === -1 || end === -1 || end <= start) { if (start === -1 || end === -1 || end <= start) {
throw new Error( throw new Error(
"docs/tools-map.md: не найден раздел «1. Реализовано» или «2.» — " + `docs/tools-map.md: не найден раздел по ${startPattern} — тест не может ` +
"тест разбирает только реализованные инструменты", "определить границы разделов карты",
); );
} }
@@ -47,7 +52,7 @@ function implementedBullets(
!/^#{1,6}\s/.test(text) && !/^#{1,6}\s/.test(text) &&
!/^[-*]\s/.test(text); !/^[-*]\s/.test(text);
if (isBullet) { if (isBullet) {
bullets.push({ text: text.slice(2), line: start + i + 1 }); bullets.push({ text: text.slice(2), line: i + 1 });
} else if (isContinuation) { } else if (isContinuation) {
previous.text += ` ${text}`; previous.text += ` ${text}`;
} }
@@ -61,7 +66,11 @@ function implementedBullets(
*/ */
function mentionedIds(markdown: string): Mention[] { function mentionedIds(markdown: string): Mention[] {
const found: Mention[] = []; const found: Mention[] = [];
for (const { text, line } of implementedBullets(markdown)) { for (const { text, line } of sectionBullets(
markdown,
SECTION_START,
SECTION_PLANNED,
)) {
const head = text.split("—")[0]; const head = text.split("—")[0];
for (const raw of head.split(/[/,]/)) { for (const raw of head.split(/[/,]/)) {
const id = raw.trim().replace(/`/g, ""); const id = raw.trim().replace(/`/g, "");
@@ -71,14 +80,35 @@ function mentionedIds(markdown: string): Mention[] {
return found; return found;
} }
/**
* Плановые разделы «2. Можно добавить» и «3. Идеи» устроены прозой: slug стоит
* где угодно в буллите, после тире и в скобках. Поэтому берём все токены
* нужной формы из всего текста буллита, а не из «головы» до тире.
*/
function plannedIds(markdown: string): Mention[] {
const found: Mention[] = [];
for (const { text, line } of sectionBullets(
markdown,
SECTION_PLANNED,
null,
)) {
for (const id of text.replace(/`/g, "").match(ID_TOKEN) ?? []) {
found.push({ id, line });
}
}
return found;
}
describe("docs/tools-map.md ↔ страницы реестра", () => { describe("docs/tools-map.md ↔ страницы реестра", () => {
const markdown = readFileSync(MAP_PATH, "utf8"); const markdown = readFileSync(MAP_PATH, "utf8");
const mentioned = mentionedIds(markdown); const mentioned = mentionedIds(markdown);
const planned = plannedIds(markdown);
const registrySlugs = new Set(PAGES.map((page) => page.slug)); const registrySlugs = new Set(PAGES.map((page) => page.slug));
it("разбирает непустой список slug (парсер не сломался молча)", () => { it("разбирает непустой список slug (парсер не сломался молча)", () => {
expect(implementedBullets(markdown).length).toBeGreaterThan(0); expect(mentioned.length).toBeGreaterThan(0);
expect(new Set(mentioned.map((m) => m.id)).size).toBeGreaterThan(50); expect(new Set(mentioned.map((m) => m.id)).size).toBeGreaterThan(50);
expect(new Set(planned.map((m) => m.id)).size).toBeGreaterThan(10);
}); });
it("в карте нет slug, которых нет в реестре", () => { it("в карте нет slug, которых нет в реестре", () => {
@@ -116,4 +146,18 @@ describe("docs/tools-map.md ↔ страницы реестра", () => {
} }
expect(dupes, `повторяющиеся slug:\n${dupes.join("\n")}`).toEqual([]); expect(dupes, `повторяющиеся slug:\n${dupes.join("\n")}`).toEqual([]);
}); });
it("плановый slug уже не реализован (перенос из «2./3.» в «1.»)", () => {
const stale = planned.filter((m) => registrySlugs.has(m.id));
expect(
stale,
stale
.map(
(m) =>
`${m.id} (docs/tools-map.md:${m.line}) — страница уже есть в реестре, ` +
"перенеси её из планового раздела в «1. Реализовано»",
)
.join("\n"),
).toEqual([]);
});
}); });
+4
View File
@@ -139,6 +139,7 @@ const circleMask: Tool<CircleMaskParams> = {
circleTest(p.size / 200), circleTest(p.size / 200),
p.offset.x / 100, p.offset.x / 100,
p.offset.y / 100, p.offset.y / 100,
{ x: p.size / 200, y: p.size / 200 },
), ),
), ),
}; };
@@ -197,6 +198,7 @@ const squareMask: Tool<SquareMaskParams> = {
boxTest(p.widthPct / 200, p.heightPct / 200), boxTest(p.widthPct / 200, p.heightPct / 200),
p.offset.x / 100, p.offset.x / 100,
p.offset.y / 100, p.offset.y / 100,
{ x: p.widthPct / 200, y: p.heightPct / 200 },
), ),
), ),
}; };
@@ -271,6 +273,7 @@ const starMask: Tool<StarMaskParams> = {
starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation), starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation),
p.offset.x / 100, p.offset.x / 100,
p.offset.y / 100, p.offset.y / 100,
{ x: p.size / 200, y: p.size / 200 },
), ),
), ),
}; };
@@ -345,6 +348,7 @@ const wavyMask: Tool<WavyMaskParams> = {
wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase), wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase),
p.offset.x / 100, p.offset.x / 100,
p.offset.y / 100, p.offset.y / 100,
{ x: (p.size + p.amplitude) / 200, y: (p.size + p.amplitude) / 200 },
), ),
), ),
}; };
+11
View File
@@ -31,6 +31,10 @@
let crumb = $derived(toCrumb(page.url.pathname)); let crumb = $derived(toCrumb(page.url.pathname));
// GA подключается только в production-деплое: флаг выставляется в
// deploy-workflow, локальные и preview-сборки тег не получают.
const gaEnabled = import.meta.env.PUBLIC_GA_ENABLED === "true";
function toCrumb(path: string): string { function toCrumb(path: string): string {
if (path === "/") return "/ " + t("header.home"); if (path === "/") return "/ " + t("header.home");
if (path === "/list-tools") return "/ " + t("header.catalog"); if (path === "/list-tools") return "/ " + t("header.catalog");
@@ -46,6 +50,13 @@
<svelte:head> <svelte:head>
<link rel="icon" href={`${resolve("/")}favicon.svg`} /> <link rel="icon" href={`${resolve("/")}favicon.svg`} />
{#if gaEnabled}
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-MEQJ7JHRQQ"
></script>
<script src={`${resolve("/")}ga.js`}></script>
{/if}
</svelte:head> </svelte:head>
<main class="preview-root" data-theme={theme}> <main class="preview-root" data-theme={theme}>
+4 -4
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { PREVIEW_GROUPS, PREVIEW_TOTAL } from "$lib/catalog"; import { PREVIEW_GROUPS, PREVIEW_TOTAL } from "$lib/catalog";
import CatalogGroup from "$lib/components/CatalogGroup.svelte"; import CatalogGroup from "$lib/components/catalog/CatalogGroup.svelte";
import CatalogHeader from "$lib/components/CatalogHeader.svelte"; import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/ToolCard.svelte"; import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import { import {
pageDescription, pageDescription,
pageTitle, pageTitle,
+33 -10
View File
@@ -1,26 +1,28 @@
<script lang="ts"> <script lang="ts">
import CheckerCanvas from "$lib/components/CheckerCanvas.svelte"; import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import CodeBlock from "$lib/components/CodeBlock.svelte"; import CheckerCanvas from "$lib/components/display/CheckerCanvas.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import CodeBlock from "$lib/components/display/CodeBlock.svelte";
import ImageCard from "$lib/components/display/ImageCard.svelte";
import MetaList from "$lib/components/display/MetaList.svelte";
import SettingGroup from "$lib/components/display/SettingGroup.svelte";
import SettingsFooter from "$lib/components/display/SettingsFooter.svelte";
import StepCard from "$lib/components/display/StepCard.svelte";
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
import StatusLine from "$lib/components/feedback/StatusLine.svelte";
import CheckboxField from "$lib/components/fields/CheckboxField.svelte"; import CheckboxField from "$lib/components/fields/CheckboxField.svelte";
import ColorField from "$lib/components/fields/ColorField.svelte"; import ColorField from "$lib/components/fields/ColorField.svelte";
import SelectField from "$lib/components/fields/SelectField.svelte"; import SelectField from "$lib/components/fields/SelectField.svelte";
import SliderField from "$lib/components/fields/SliderField.svelte"; import SliderField from "$lib/components/fields/SliderField.svelte";
import TextField from "$lib/components/fields/TextField.svelte"; import TextField from "$lib/components/fields/TextField.svelte";
import ImageCard from "$lib/components/ImageCard.svelte";
import Panel from "$lib/components/layout/Panel.svelte"; import Panel from "$lib/components/layout/Panel.svelte";
import MetaList from "$lib/components/MetaList.svelte";
import SettingGroup from "$lib/components/SettingGroup.svelte";
import SettingsFooter from "$lib/components/SettingsFooter.svelte";
import StatusLine from "$lib/components/StatusLine.svelte";
import StepCard from "$lib/components/StepCard.svelte";
import ToolCard from "$lib/components/ToolCard.svelte";
import Badge from "$lib/components/ui/Badge.svelte"; import Badge from "$lib/components/ui/Badge.svelte";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import DownloadButton from "$lib/components/ui/DownloadButton.svelte"; import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
import Segmented from "$lib/components/ui/Segmented.svelte"; import Segmented from "$lib/components/ui/Segmented.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte";
import { Download, ImageOff, Plus, Trash2 } from "@lucide/svelte"; import { Download, ImageOff, Plus, Trash2 } from "@lucide/svelte";
import { PAGES } from "$lib/registry"; import { PAGES } from "$lib/registry";
@@ -52,6 +54,26 @@
<Button variant="outline" onclick={() => {}} label="Outline" /> <Button variant="outline" onclick={() => {}} label="Outline" />
<Button variant="accent" onclick={() => {}} label="Accent" /> <Button variant="accent" onclick={() => {}} label="Accent" />
<Button variant="danger" onclick={() => {}} label="Danger" /> <Button variant="danger" onclick={() => {}} label="Danger" />
<Button variant="clear" onclick={() => {}} label="Clear" />
</SettingGroup>
<SettingGroup label="size s">
<Button size="s" onclick={() => {}} label="Primary" />
<Button size="s" variant="outline" onclick={() => {}} label="Outline" />
<Button size="s" variant="clear" onclick={() => {}} label="Clear" />
<IconButton size="s" icon={Plus} label="Add" onclick={() => {}} />
<IconButton
size="s"
variant="clear"
icon={Trash2}
label="Delete"
onclick={() => {}}
/>
</SettingGroup>
<SettingGroup label="chips">
<ChipButton label="All" active onclick={() => {}} />
<ChipButton label="Convert" onclick={() => {}} />
<ChipButton label="0°" variant="outline" active onclick={() => {}} />
<ChipButton label="90°" variant="outline" onclick={() => {}} />
</SettingGroup> </SettingGroup>
<SettingGroup label="states"> <SettingGroup label="states">
<Button disabled onclick={() => {}} label="Disabled" /> <Button disabled onclick={() => {}} label="Disabled" />
@@ -71,6 +93,7 @@
</SettingGroup> </SettingGroup>
</div> </div>
<SettingsFooter> <SettingsFooter>
<UploadButton label="Open image" onfile={() => {}} />
<DownloadButton label="Download PNG" onclick={() => {}} /> <DownloadButton label="Download PNG" onclick={() => {}} />
</SettingsFooter> </SettingsFooter>
</Panel> </Panel>
+4 -4
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { PREVIEW_GROUPS, PREVIEW_TOTAL } from "$lib/catalog"; import { PREVIEW_GROUPS, PREVIEW_TOTAL } from "$lib/catalog";
import CatalogGroup from "$lib/components/CatalogGroup.svelte"; import CatalogGroup from "$lib/components/catalog/CatalogGroup.svelte";
import CatalogHeader from "$lib/components/CatalogHeader.svelte"; import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/ToolCard.svelte"; import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import { import {
pageDescription, pageDescription,
pageTitle, pageTitle,
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/feedback/EmptyState.svelte";
import SchemaToolView from "$lib/components/SchemaToolView.svelte"; import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { getPageBySlug, getTool } from "$lib/registry"; import { getPageBySlug, getTool } from "$lib/registry";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte"; import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
+6
View File
@@ -0,0 +1,6 @@
window.dataLayer = window.dataLayer || [];
function gtag() {
window.dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-MEQJ7JHRQQ");
+2
View File
@@ -11,6 +11,8 @@ const base: "" | `/${string}` =
: (`/${rawBase.replace(/^\/+/, "").replace(/\/+$/, "")}` as `/${string}`); : (`/${rawBase.replace(/^\/+/, "").replace(/\/+$/, "")}` as `/${string}`);
export default defineConfig({ export default defineConfig({
// PUBLIC_* попадают в import.meta.env — флаги окружения вроде GA.
envPrefix: ["VITE_", "PUBLIC_"],
ssr: { ssr: {
noExternal: ["@lucide/svelte"], noExternal: ["@lucide/svelte"],
}, },

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