Compare commits
62
Commits
4794523084
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e35f4fdf | ||
|
|
46da54140c | ||
|
|
420d03f58c | ||
|
|
3f21f1c024 | ||
|
|
9a7b7c178b | ||
|
|
e3d50f2128 | ||
|
|
2d24f51fd5 | ||
|
|
5a4fbe7ac6 | ||
|
|
6f1771cb07 | ||
|
|
987f4535c8 | ||
|
|
20e07fae85 | ||
|
|
a71252dc0c | ||
|
|
751e8d97a0 | ||
|
|
a1438e6f7f | ||
|
|
5884f4bd82 | ||
|
|
e5b76ca3e1 | ||
|
|
a49785764b | ||
|
|
c0cebdc81f | ||
|
|
37a4a0576c | ||
|
|
b3d460a951 | ||
|
|
6be9d10c42 | ||
|
|
cc70750727 | ||
|
|
e2735c52ab | ||
|
|
d383cf29e3 | ||
|
|
bfad0c7d02 | ||
|
|
6750d5a373 | ||
|
|
6cbb9f95f0 | ||
|
|
e57faddfde | ||
|
|
e662d1df24 | ||
|
|
d48262e482 | ||
|
|
cad9bf6cfd | ||
|
|
b99ccf5d78 | ||
|
|
0a7c4a84c9 | ||
|
|
f24ccf9240 | ||
|
|
0c28925673 | ||
|
|
743ae817ee | ||
|
|
19f2e13b88 | ||
|
|
4a13bfbd16 | ||
|
|
f286044ab9 | ||
|
|
2601aa5b5c | ||
|
|
1e9b35406d | ||
|
|
520a1e1d37 | ||
|
|
a19d3db929 | ||
|
|
58e75f2334 | ||
|
|
1c746299c9 | ||
|
|
d1a386f92f | ||
|
|
1813383724 | ||
|
|
1ae4b36c9c | ||
|
|
a57acd1ff2 | ||
|
|
6ec3e75df4 | ||
|
|
a24a182d18 | ||
|
|
1e63eaa03b | ||
|
|
e9de7a4bf8 | ||
|
|
7e94205866 | ||
|
|
2f758a88af | ||
|
|
991fe69381 | ||
|
|
11c4ccece4 | ||
|
|
8a6f87938d | ||
|
|
3d0f8ebb10 | ||
|
|
28ff9d3ee1 | ||
|
|
ef74cabf1e | ||
|
|
c590bdf91a |
@@ -9,3 +9,4 @@ refs/public/placeholder-logo.svg
|
||||
refs/public/placeholder-logo.png
|
||||
refs/public/placeholder.svg
|
||||
web/audit/cdp-audit*.txt
|
||||
css-lint.txt
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allowlist: prettier в корне форматирует только markdown docs/.
|
||||
# web/ и refs/ имеют свои тулинги, refs-html/ — генерат: всё остальное
|
||||
# в корне (включая появившееся позже) игнорируется целиком.
|
||||
/*
|
||||
!/docs
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"proseWrap": "always"
|
||||
}
|
||||
@@ -9,10 +9,15 @@
|
||||
обёртки в корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`).
|
||||
Не использовать npm.
|
||||
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`,
|
||||
тесты: `pnpm --dir web test`.
|
||||
- Форматирование: `pnpm --dir web format` (Prettier + `prettier-plugin-svelte`,
|
||||
тесты: `pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
|
||||
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт 4173).
|
||||
- Форматирование: два независимых прогона. Корневой prettier форматирует
|
||||
только markdown `docs/` (`.prettierrc` + allowlist `.prettierignore` в корне,
|
||||
`pnpm format:docs`, проверка — `pnpm check:docs`). Код `web/` форматируется
|
||||
отдельно: `pnpm --dir web format` (Prettier + `prettier-plugin-svelte`,
|
||||
конфиг `web/.prettierrc`, игнор `web/.prettierignore`). Проверка без записи:
|
||||
`pnpm --dir web exec prettier --check .`.
|
||||
`pnpm --dir web exec prettier --check .`. Оба прогона сразу: `pnpm format`
|
||||
в корне.
|
||||
- Линтинг: `pnpm --dir web lint` (ESLint, flat-конфиг `web/eslint.config.js`).
|
||||
Устроен инкрементально:
|
||||
- На **весь код** — парсинг TS/Svelte + правило
|
||||
@@ -114,8 +119,12 @@ scoped-путям (`kit/**`, `preview/**`).
|
||||
`hct()` (эммит в sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`)
|
||||
и только в preview.css.
|
||||
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
||||
- Брейкпоинты: `--bp-mobile` (640px), `--bp-tablet` (800px), `--bp-desktop`
|
||||
(1100px) — mobile-first.
|
||||
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
|
||||
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в preview.css,
|
||||
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
|
||||
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
|
||||
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
|
||||
подтягиваются через `@csstools/postcss-global-data`).
|
||||
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
|
||||
- Правило `custom-property-pattern` в stylelint тестирует паттерн **без** `--`
|
||||
(`--x` → `x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
|
||||
@@ -138,6 +147,29 @@ scoped-путям (`kit/**`, `preview/**`).
|
||||
определён в `src/preview.css` (словарь токенов) или локально в компоненте.
|
||||
Файл читается один раз и кэшируется (не `glob`-зависим).
|
||||
|
||||
### Изоляция веток old ↔ preview
|
||||
|
||||
`isolation/no-mixed-imports` (`web/eslint-plugins/isolation/no-mixed-imports.js`,
|
||||
включён на весь `**/*.{ts,svelte}`) — **полная взаимная изоляция** старого UI и
|
||||
новой preview-ветки. В отличие от `no-restricted-imports`, правило **резолвит**
|
||||
каждый импорт (и `$lib/...`, и относительные `./`/`../`) до реального файла и
|
||||
классифицирует стороны по фактическому пути, поэтому относительным импортом
|
||||
правило не обойти.
|
||||
|
||||
- Старое: `routes/(old)/**`, `lib/registry.ts`, `lib/registry/**`,
|
||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||
`lib/components/**` (кроме `kit/`).
|
||||
- Новое: `routes/preview/**`, `lib/registry-new/**`, `lib/preview/**`,
|
||||
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`, `lib/components/kit/**`.
|
||||
- Общее (разрешено обоим): `core/`, `i18n/`, `theme`, `assets/`, корневой `lib`.
|
||||
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
|
||||
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
|
||||
glob-паттернов в настройке, а не код правила.
|
||||
- Изоляция уже достигнута: старый `registry/` не тянет `registry-schema`
|
||||
(пилоты add-border/add-stroke работают через `params`), `registry.ts` не
|
||||
импортирует `ToolSchema`; preview-`list-tools` использует копию
|
||||
`preview/tool-icons.ts`, а не старый `tools/tool-icons.ts`.
|
||||
|
||||
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
|
||||
preview.css) — известный техдолг, его НЕ чинить и не игнорировать правилами.
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ Static output is written to `web/build/`.
|
||||
| `pnpm --dir web test` | Run unit tests (Vitest) |
|
||||
| `pnpm --dir web check` | Type-check (svelte-check) |
|
||||
| `pnpm --dir web lint` | Lint (ESLint) |
|
||||
| `pnpm --dir web format` | Format (Prettier) |
|
||||
| `pnpm --dir web format` | Format code (Prettier) |
|
||||
| `pnpm format:docs` | Format docs (Prettier) |
|
||||
| `pnpm --dir web preview` | Preview production build |
|
||||
|
||||
## Project Structure
|
||||
|
||||
+208
-74
@@ -1,9 +1,13 @@
|
||||
# Анализ: набор PNG-утилит для браузера
|
||||
|
||||
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере. Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких загрузок на сервер, всё считается локально.
|
||||
Стек: **SvelteKit + @sveltejs/adapter-static** (статический экспорт, никакого сервера). Все данные обрабатываются на клиенте, ничего не уходит в сеть.
|
||||
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере.
|
||||
Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких
|
||||
загрузок на сервер, всё считается локально. Стек: **SvelteKit +
|
||||
@sveltejs/adapter-static** (статический экспорт, никакого сервера). Все данные
|
||||
обрабатываются на клиенте, ничего не уходит в сеть.
|
||||
|
||||
Источник идеи: типовой перечень утилит, который встречается в онлайн-наборах PNG-инструментов (~296 операций).
|
||||
Источник идеи: типовой перечень утилит, который встречается в онлайн-наборах
|
||||
PNG-инструментов (~296 операций).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,63 +25,121 @@
|
||||
| Тяжёлая математика без блокировки UI | Web Workers, SharedArrayBuffer, WASM (zlib, квантование, ML) |
|
||||
| Экономия памяти при больших изображениях | `ImageBitmap`, работа тайлами, downscale до декорирования |
|
||||
|
||||
**Вывод для архитектуры:** ~90% утилит — это одна и та же связка «декодировать → прогнать через пиксельную функцию → закодировать». Разумно сделать общий ядро (`core/pipeline.ts` + набор операций над `ImageData`) и тонькие страницы-обёртки в SvelteKit. Каждая утилита = отдельный маршрут со своим UI (drag&drop слева, превью, параметры, скачивание справа).
|
||||
**Вывод для архитектуры:** ~90% утилит — это одна и та же связка «декодировать →
|
||||
прогнать через пиксельную функцию → закодировать». Разумно сделать общий ядро
|
||||
(`core/pipeline.ts` + набор операций над `ImageData`) и тонькие страницы-обёртки
|
||||
в SvelteKit. Каждая утилита = отдельный маршрут со своим UI (drag&drop слева,
|
||||
превью, параметры, скачивание справа).
|
||||
|
||||
---
|
||||
|
||||
## 2. Уровень EASY — canvas + ImageData, простые попиксельные операции и композиция
|
||||
|
||||
Реализация: загрузка в canvas → `getImageData()` → цикл по пикселям / перекомпоновка / отрисовка на выходной canvas → `toBlob('image/png')`. Без сложных алгоритмов, каждая утилита — часы работы.
|
||||
Реализация: загрузка в canvas → `getImageData()` → цикл по пикселям /
|
||||
перекомпоновка / отрисовка на выходной canvas → `toBlob('image/png')`. Без
|
||||
сложных алгоритмов, каждая утилита — часы работы.
|
||||
|
||||
### 2.1 Конвертация форматов и текстовые представления
|
||||
|
||||
- **convert-png-to-jpg / convert-jpg-to-png / convert-webp-to-png / convert-png-to-webp / convert-svg-to-png** — рисование на canvas + `toBlob()` нужного типа
|
||||
- **convert-bmp-to-png / convert-ico-to-png / convert-gif-to-png** (первый кадр) — нативное декодирование браузером
|
||||
- **convert-png-to-jpg / convert-jpg-to-png / convert-webp-to-png /
|
||||
convert-png-to-webp / convert-svg-to-png** — рисование на canvas + `toBlob()`
|
||||
нужного типа
|
||||
- **convert-bmp-to-png / convert-ico-to-png / convert-gif-to-png** (первый кадр)
|
||||
— нативное декодирование браузером
|
||||
- **convert-png-to-bmp** — BMP-формат простой, ~30 строк энкодера
|
||||
- **convert-png-to-base64 / convert-base64-to-png / data-uri (в обе стороны)** — `FileReader.readAsDataURL` + строка
|
||||
- **convert-png-to-bytes / convert-bytes-to-png / convert-png-to-hex / convert-hex-to-png / convert-png-to-rgb-values / convert-rgb-values-to-png** — чтение/запись `ImageData` в текст
|
||||
- **convert-png-to-base64 / convert-base64-to-png / data-uri (в обе стороны)** —
|
||||
`FileReader.readAsDataURL` + строка
|
||||
- **convert-png-to-bytes / convert-bytes-to-png / convert-png-to-hex /
|
||||
convert-hex-to-png / convert-png-to-rgb-values / convert-rgb-values-to-png** —
|
||||
чтение/запись `ImageData` в текст
|
||||
- **convert-png-to-gif** (один кадр) — `gifenc`, тривиально
|
||||
|
||||
### 2.2 Прозрачность и альфа-канал
|
||||
|
||||
- **create-transparent-png, remove-color-from-png(простая версия), transparent-logo-maker, remove-logo/icon-background (по цвету)** — замена цвета на прозрачность по порогу близости
|
||||
- **remove-alpha-channel-from-png, fill-alpha-channel-in-png, replace-alpha-channel-in-png, extract-alpha-channel-from-png, invert-png-alpha-channel** — манипуляции только с A-компонентой
|
||||
- **generate-png-alpha-mask, show-transparent-png-areas, fill-transparent-png-pixels, convert-semi-transparent-png-to-opaque** — пороги и заливки по альфе
|
||||
- **create-transparent-png, remove-color-from-png(простая версия),
|
||||
transparent-logo-maker, remove-logo/icon-background (по цвету)** — замена
|
||||
цвета на прозрачность по порогу близости
|
||||
- **remove-alpha-channel-from-png, fill-alpha-channel-in-png,
|
||||
replace-alpha-channel-in-png, extract-alpha-channel-from-png,
|
||||
invert-png-alpha-channel** — манипуляции только с A-компонентой
|
||||
- **generate-png-alpha-mask, show-transparent-png-areas,
|
||||
fill-transparent-png-pixels, convert-semi-transparent-png-to-opaque** — пороги
|
||||
и заливки по альфе
|
||||
- **harden-png-edges** — бинаризация альфы по порогу
|
||||
|
||||
### 2.3 Цвет: замена, тон, каналы, цветовые пространства
|
||||
|
||||
- **change-png-color, change-png-color-tone, change-png-opacity, swap-png-colors, change-png-brightness, change-png-contrast, convert-png-to-grayscale, add-sepia-tone-to-png, change-png-hue, invert-png-colors, reverse-png-colors, convert-png-to-two-colors, convert-png-to-black-and-white, create-monochrome-png, generate-single-color-png** — попиксельные арифметики/преобразования
|
||||
- **extract-png-color-channels, swap-rgba-color-channels, split-png-into-rgb-components** — перестановка/выделение RGBA-каналов
|
||||
- **convert-png-to-hsl/hsv/hsi/cmyk/ycbcr/lab-colors** — известные формулы конвертации (Lab — чуть больше математики, но детерминировано)
|
||||
- **generate-\*-color-palette (complementary/monochromatic/analogous/triadic/tetradic), generate-similar-color-shades, color-wheel-generator, mix-multiple-colors, calculate-average-color, simulate-paint-colors, blend-two-colors, step-between-two-colors, convert-color-names-to-png** — генераторы палитр/смешение без работы с изображением
|
||||
- **posterize-png, create-color-spectrum-png, create-multi-color-gradient-png, generate-png-gradient-palette** — простые градиенты/квантование уровней
|
||||
- **change-png-color, change-png-color-tone, change-png-opacity,
|
||||
swap-png-colors, change-png-brightness, change-png-contrast,
|
||||
convert-png-to-grayscale, add-sepia-tone-to-png, change-png-hue,
|
||||
invert-png-colors, reverse-png-colors, convert-png-to-two-colors,
|
||||
convert-png-to-black-and-white, create-monochrome-png,
|
||||
generate-single-color-png** — попиксельные арифметики/преобразования
|
||||
- **extract-png-color-channels, swap-rgba-color-channels,
|
||||
split-png-into-rgb-components** — перестановка/выделение RGBA-каналов
|
||||
- **convert-png-to-hsl/hsv/hsi/cmyk/ycbcr/lab-colors** — известные формулы
|
||||
конвертации (Lab — чуть больше математики, но детерминировано)
|
||||
- **generate-\*-color-palette
|
||||
(complementary/monochromatic/analogous/triadic/tetradic),
|
||||
generate-similar-color-shades, color-wheel-generator, mix-multiple-colors,
|
||||
calculate-average-color, simulate-paint-colors, blend-two-colors,
|
||||
step-between-two-colors, convert-color-names-to-png** — генераторы
|
||||
палитр/смешение без работы с изображением
|
||||
- **posterize-png, create-color-spectrum-png, create-multi-color-gradient-png,
|
||||
generate-png-gradient-palette** — простые градиенты/квантование уровней
|
||||
|
||||
### 2.4 Геометрия, композиция, холст
|
||||
|
||||
- **resize-png, zoom-png, crop-png, cut-png, rotate-png, skew-png, shift-png, change-png-canvas-size, change-png-aspect-ratio, fit-png-in-rectangle, convert-landscape/portrait-png-to-portrait/landscape-png** — операции через `CanvasRenderingContext2D` (`drawImage`, `rotate`, `setTransform`)
|
||||
- **flip-png-horizontally/vertically, mirror-png, reverse-png** — отражение/поворот на 180°
|
||||
- **center-png, center-logo, center-icon** — центровка по bounding box альфа-канала
|
||||
- **duplicate-png, clone-png, multiply-png, create-png-tile, create-symmetric-png, split-png-into-parts** — тайлинг/копирование
|
||||
- **create-round-png, create-square-png, create-star-shaped-png, create-wavy-shaped-png, round-png-corners** — маски-«штампы» на альфе
|
||||
- **add-png-border, add-padding-to-png, add-space-around-png, remove-padding-from-png, remove-space-around-png, remove-png-padding** — расширение/усадка холста
|
||||
- **add-png-background, add-signature/logo/icon/stamp-background** — подложка под прозрачность
|
||||
- **resize-png, zoom-png, crop-png, cut-png, rotate-png, skew-png, shift-png,
|
||||
change-png-canvas-size, change-png-aspect-ratio, fit-png-in-rectangle,
|
||||
convert-landscape/portrait-png-to-portrait/landscape-png** — операции через
|
||||
`CanvasRenderingContext2D` (`drawImage`, `rotate`, `setTransform`)
|
||||
- **flip-png-horizontally/vertically, mirror-png, reverse-png** —
|
||||
отражение/поворот на 180°
|
||||
- **center-png, center-logo, center-icon** — центровка по bounding box
|
||||
альфа-канала
|
||||
- **duplicate-png, clone-png, multiply-png, create-png-tile,
|
||||
create-symmetric-png, split-png-into-parts** — тайлинг/копирование
|
||||
- **create-round-png, create-square-png, create-star-shaped-png,
|
||||
create-wavy-shaped-png, round-png-corners** — маски-«штампы» на альфе
|
||||
- **add-png-border, add-padding-to-png, add-space-around-png,
|
||||
remove-padding-from-png, remove-space-around-png, remove-png-padding** —
|
||||
расширение/усадка холста
|
||||
- **add-png-background, add-signature/logo/icon/stamp-background** — подложка
|
||||
под прозрачность
|
||||
|
||||
### 2.5 Текст, рамки, простые эффекты
|
||||
|
||||
- **add-text-to-png, convert-text-to-png, create-emoji-png, add-png-watermark, wrap-png-in-polaroid, create-logo-from-text, create-icon-from-text/emoji, create-custom-stamp, create-\*-stamp (round/rect/green/red/number/date-time/monogram/from-text/emoji), create-placeholder-png, create-empty-png, create-custom-png, generate-colorful-png, generate-random-png, generate-white-noise-png, generate-1x1-png, draw-png-grid** — рисование примитивов/случайных данных
|
||||
- **add-noise-to-png, create-glitch-png, pixelate-png, censor-png, erase-part-of-png, randomize-png-pixels** — шум/порча байтов/блоки/кисть
|
||||
- **add-text-to-png, convert-text-to-png, create-emoji-png, add-png-watermark,
|
||||
wrap-png-in-polaroid, create-logo-from-text, create-icon-from-text/emoji,
|
||||
create-custom-stamp, create-\*-stamp
|
||||
(round/rect/green/red/number/date-time/monogram/from-text/emoji),
|
||||
create-placeholder-png, create-empty-png, create-custom-png,
|
||||
generate-colorful-png, generate-random-png, generate-white-noise-png,
|
||||
generate-1x1-png, draw-png-grid** — рисование примитивов/случайных данных
|
||||
- **add-noise-to-png, create-glitch-png, pixelate-png, censor-png,
|
||||
erase-part-of-png, randomize-png-pixels** — шум/порча байтов/блоки/кисть
|
||||
- **add-shadow-to-png** — смещение + лёгкое размытие + композиция
|
||||
- **add-artifacts-to-png** — простой вариант: JPEG-раундтрип через canvas
|
||||
|
||||
### 2.6 Анализ и проверки
|
||||
|
||||
- **analyze-png, find-png-file-size, find-png-dimensions, check-if-png-is-landscape-or-portrait, find-png-color-count, check-if-png-is-grayscale, check-if-png-is-transparent, verify-if-image-is-png, pick-png-color, pick-logo/icon-color, png-viewer, test-png, preview-png-on-colorful-background, test-png-on-multiple-backgrounds** — чтение заголовков/ImageData/гистограмм, «пипетка» по клику
|
||||
- **analyze-png, find-png-file-size, find-png-dimensions,
|
||||
check-if-png-is-landscape-or-portrait, find-png-color-count,
|
||||
check-if-png-is-grayscale, check-if-png-is-transparent,
|
||||
verify-if-image-is-png, pick-png-color, pick-logo/icon-color, png-viewer,
|
||||
test-png, preview-png-on-colorful-background,
|
||||
test-png-on-multiple-backgrounds** — чтение заголовков/ImageData/гистограмм,
|
||||
«пипетка» по клику
|
||||
- **sort-colors** — сортировка списка палитры (не изображения)
|
||||
|
||||
### 2.7 Семейства signature/logo/icon/stamp — простые вариации
|
||||
|
||||
Почти все «-maker/-color/-resize/-crop/-rotate/-bg/-space/-padding/-grayscale/-single-color/-white/-black/-semi-transparent/-shadow» для signature/logo/icon/stamp — это переиспользование операций из разделов выше с одним цветом/боксом. Отдельно перечислены только те, что требуют алгоритмов (см. MEDIUM/HARD).
|
||||
Почти все
|
||||
«-maker/-color/-resize/-crop/-rotate/-bg/-space/-padding/-grayscale/-single-color/-white/-black/-semi-transparent/-shadow»
|
||||
для signature/logo/icon/stamp — это переиспользование операций из разделов выше
|
||||
с одним цветом/боксом. Отдельно перечислены только те, что требуют алгоритмов
|
||||
(см. MEDIUM/HARD).
|
||||
|
||||
> Примерный объём: **≈180–190 утилит уровня EASY.**
|
||||
|
||||
@@ -85,54 +147,96 @@
|
||||
|
||||
## 3. Уровень MEDIUM — нужен настоящий алгоритм (свёртки, морфология, квантование, анимация)
|
||||
|
||||
Реализация: честные алгоритмы на `ImageData`, часто в Web Worker, при необходимости — обвязка вокруг готовых JS-библиотек.
|
||||
Реализация: честные алгоритмы на `ImageData`, часто в Web Worker, при
|
||||
необходимости — обвязка вокруг готовых JS-библиотек.
|
||||
|
||||
### 3.1 Фильтры и свёртки
|
||||
|
||||
- **blur-png, sharpen-png** — box/gaussian blur, ядро свёртки (упрощённо можно `ctx.filter`, но качественно — своя свёртка)
|
||||
- **refine-png-edges, feather-png-edges, clean-png-edges** — работа с полупрозрачными краями: размытие альфы, де-контуринг, suppression полупрозрачности
|
||||
- **blur-png, sharpen-png** — box/gaussian blur, ядро свёртки (упрощённо можно
|
||||
`ctx.filter`, но качественно — своя свёртка)
|
||||
- **refine-png-edges, feather-png-edges, clean-png-edges** — работа с
|
||||
полупрозрачными краями: размытие альфы, де-контуринг, suppression
|
||||
полупрозрачности
|
||||
- **add-glow-effect-to-png** — расширение альфы + размытие + цветное наложение
|
||||
|
||||
### 3.2 Морфология и контуры (операции над альфа-маской)
|
||||
|
||||
- **add-stroke-to-png, add-outline-to-png, remove-stroke-from-png, remove-outline-from-png, find-png-outline** — dilation/erosion по альфе, извлечение контура
|
||||
- **make-signature-thicker / make-signature-thinner, make-icon-thicker / make-icon-thinner** — dilation/erosion
|
||||
- **convert-outlined-icon-to-filled-icon** — flood-fill замкнутых областей; **convert-filled-icon-to-outlined-icon** — эрозия
|
||||
- **remove-png-border, remove-border-around-icon, remove-stamp-border** — детект однотонной/декоративной рамки и вырезание
|
||||
- **add-stroke-to-png, add-outline-to-png, remove-stroke-from-png,
|
||||
remove-outline-from-png, find-png-outline** — dilation/erosion по альфе,
|
||||
извлечение контура
|
||||
- **make-signature-thicker / make-signature-thinner, make-icon-thicker /
|
||||
make-icon-thinner** — dilation/erosion
|
||||
- **convert-outlined-icon-to-filled-icon** — flood-fill замкнутых областей;
|
||||
**convert-filled-icon-to-outlined-icon** — эрозия
|
||||
- **remove-png-border, remove-border-around-icon, remove-stamp-border** — детект
|
||||
однотонной/декоративной рамки и вырезание
|
||||
|
||||
### 3.3 Квантование и цветовая кластеризация
|
||||
|
||||
- **quantize-png, decrease-png-color-count, set-custom-png-color-palette, add-dithering-to-png** — median cut / octree / Wu + Floyd–Steinberg; готовые JS-библиотеки (image-q и т.п.)
|
||||
- **change-png-quality, create-low-quality-png** — симуляция потери качества через квантование + JPEG-раундтрип
|
||||
- **convert-png-to-color-blocks** — мультипликативный блок-эффект/квантование по блокам
|
||||
- **extract-png-color-palette, find-logo-color-scheme, extract-icon-colors** — частотная гистограмма + выбор топ-N / кластеризация
|
||||
- **create-unique/popular/multi-color/outlier-color-mask** — статистика распределения цветов и выделение по маске
|
||||
- **quantize-png, decrease-png-color-count, set-custom-png-color-palette,
|
||||
add-dithering-to-png** — median cut / octree / Wu + Floyd–Steinberg; готовые
|
||||
JS-библиотеки (image-q и т.п.)
|
||||
- **change-png-quality, create-low-quality-png** — симуляция потери качества
|
||||
через квантование + JPEG-раундтрип
|
||||
- **convert-png-to-color-blocks** — мультипликативный блок-эффект/квантование по
|
||||
блокам
|
||||
- **extract-png-color-palette, find-logo-color-scheme, extract-icon-colors** —
|
||||
частотная гистограмма + выбор топ-N / кластеризация
|
||||
- **create-unique/popular/multi-color/outlier-color-mask** — статистика
|
||||
распределения цветов и выделение по маске
|
||||
|
||||
### 3.4 Выделение объектов по цвету / фону
|
||||
|
||||
- **remove-png-chroma-key** — порог по цвету + деспилл (убрать зелёную окантовку)
|
||||
- **remove-color-from-png (качественная версия), extract-color-from-png, separate-png-colors, separate-logo-colors, separate-icon-colors** — сегментация по близости цвета
|
||||
- **convert-png-to-silhouette, convert-png-to-stencil** — порог + заливка силуэта
|
||||
- **extract-stamp-from-image (по цвету печати)** — хромакей/частотный фильтр по цвету чернил
|
||||
- **remove-png-background (вариант без ИИ)** — проверенный цветовой подход, знакомый по популярным онлайн-наборам PNG-утилит. Работает хорошо на однотонном/градиентном фоне и в ряде случаев удобнее ИИ. Опции и алгоритм:
|
||||
- **Background Color** — выбор удаляемого цвета: пипеткой по изображению или hex/RGB
|
||||
- **Percentage (похожесть цвета)** — slider; 0% = убрать только точный цвет, >0% = убрать соседние оттенки в окрестности цвета (в цветовом пространстве)
|
||||
- **Delete Outer Areas (галочка)** — режим «только внешние пиксели»: удаляются только области, связанные с краями изображения (flood-fill/region-growing от границ). Выключена = удалять по всему холсту. Именно эта опция позволяет убрать фон, не задевая «пятна» того же цвета внутри объекта
|
||||
- **Smooth Edge Line + Radius** — постобработка: полупрозрачная полоса толщиной N px по границе удаления (сгладить переход)
|
||||
- **Preview Deleted Background** — двухцветный предпросмотр маски (что удаляется/что остаётся)
|
||||
- Реализация: выбор цвета → порог по цветовому расстоянию → (опц.) flood-fill от краёв → сглаживание краёв → маска поверх превью. Всё на `ImageData`, без ML.
|
||||
- **remove-png-chroma-key** — порог по цвету + деспилл (убрать зелёную
|
||||
окантовку)
|
||||
- **remove-color-from-png (качественная версия), extract-color-from-png,
|
||||
separate-png-colors, separate-logo-colors, separate-icon-colors** —
|
||||
сегментация по близости цвета
|
||||
- **convert-png-to-silhouette, convert-png-to-stencil** — порог + заливка
|
||||
силуэта
|
||||
- **extract-stamp-from-image (по цвету печати)** — хромакей/частотный фильтр по
|
||||
цвету чернил
|
||||
- **remove-png-background (вариант без ИИ)** — проверенный цветовой подход,
|
||||
знакомый по популярным онлайн-наборам PNG-утилит. Работает хорошо на
|
||||
однотонном/градиентном фоне и в ряде случаев удобнее ИИ. Опции и алгоритм:
|
||||
- **Background Color** — выбор удаляемого цвета: пипеткой по изображению или
|
||||
hex/RGB
|
||||
- **Percentage (похожесть цвета)** — slider; 0% = убрать только точный
|
||||
цвет, >0% = убрать соседние оттенки в окрестности цвета (в цветовом
|
||||
пространстве)
|
||||
- **Delete Outer Areas (галочка)** — режим «только внешние пиксели»: удаляются
|
||||
только области, связанные с краями изображения (flood-fill/region-growing от
|
||||
границ). Выключена = удалять по всему холсту. Именно эта опция позволяет
|
||||
убрать фон, не задевая «пятна» того же цвета внутри объекта
|
||||
- **Smooth Edge Line + Radius** — постобработка: полупрозрачная полоса
|
||||
толщиной N px по границе удаления (сгладить переход)
|
||||
- **Preview Deleted Background** — двухцветный предпросмотр маски (что
|
||||
удаляется/что остаётся)
|
||||
- Реализация: выбор цвета → порог по цветовому расстоянию → (опц.) flood-fill
|
||||
от краёв → сглаживание краёв → маска поверх превью. Всё на `ImageData`, без
|
||||
ML.
|
||||
|
||||
### 3.5 Геометрические искажения и пиксель-сортировка
|
||||
|
||||
- **add-whirl-to-png** — свирл/вихрь: обратное отображение координат
|
||||
- **sort-png-pixels** — pixel sorting (сортировка строк/столбцов по яркости/цвету)
|
||||
- **upscale-png / downscale-png** — качественный ресемплинг (Lanczos, mipmap) поверх базового canvas-resize
|
||||
- **remove-signature-background, transparent-png-signature-maker, clean-up-signature-edges, improve-low-quality-signature, clean-logo-edges, clean-icon-edges, fix-low-quality-stamp, convert-rubber-stamp-to-digital-stamp** — комбинация «цвет→прозрачность + де-шум + морфология краёв»
|
||||
- **convert-image/png/jpg/icon/logo/signature-to-stamp** — стилизация в двухцветную «печать» (порог + зернистость + края)
|
||||
- **sort-png-pixels** — pixel sorting (сортировка строк/столбцов по
|
||||
яркости/цвету)
|
||||
- **upscale-png / downscale-png** — качественный ресемплинг (Lanczos, mipmap)
|
||||
поверх базового canvas-resize
|
||||
- **remove-signature-background, transparent-png-signature-maker,
|
||||
clean-up-signature-edges, improve-low-quality-signature, clean-logo-edges,
|
||||
clean-icon-edges, fix-low-quality-stamp,
|
||||
convert-rubber-stamp-to-digital-stamp** — комбинация «цвет→прозрачность +
|
||||
де-шум + морфология краёв»
|
||||
- **convert-image/png/jpg/icon/logo/signature-to-stamp** — стилизация в
|
||||
двухцветную «печать» (порог + зернистость + края)
|
||||
|
||||
### 3.6 Анимации и GIF-энкодинг
|
||||
|
||||
- **convert-png-to-gif, slowly-reveal-png, create-disappearing-png, fade-in-png, fade-out-png, create-scrolling-png, create-png-sequence-from-gif** — декомпозиция кадров GIF / генерация последовательности кадров + `gifenc`/`UPNG` (APNG)
|
||||
- **convert-png-to-gif, slowly-reveal-png, create-disappearing-png, fade-in-png,
|
||||
fade-out-png, create-scrolling-png, create-png-sequence-from-gif** —
|
||||
декомпозиция кадров GIF / генерация последовательности кадров +
|
||||
`gifenc`/`UPNG` (APNG)
|
||||
|
||||
> Примерный объём: **≈71 утилита уровня MEDIUM.**
|
||||
|
||||
@@ -140,35 +244,65 @@
|
||||
|
||||
## 4. Уровень HARD — WASM, ML, длительная обработка, тонкая подгонка алгоритма
|
||||
|
||||
Реализация: WASM-модули, ML-модели в браузере, длительная обработка с прогресс-барами.
|
||||
Реализация: WASM-модули, ML-модели в браузере, длительная обработка с
|
||||
прогресс-барами.
|
||||
|
||||
- **compress-png / reduce-png-file-size / optimize-png** — сжатие на уровне формата: нужен качественный PNG-энкодер с контролем zlib-фильтров/битовой глубины и оптимизаторы. Решение: **pngquant / oxipng / zopfli (WASM)** + подгонка под «качество→размер» (это главный кандидат на HARD — именно здесь тюнинг бесконечен)
|
||||
- **remove-png-background (вариант с ИИ)** — опциональное улучшение к MEDIUM-версии: ML-сегментация (Rembg/DeepLab в WASM или ONNX) для произвольных фото, где цветовой матчинг бессилен. Начинать можно без него — не-ИИ версия уже даёт рабочий инструмент
|
||||
- **extract-signature-from-image** — детект подписи на скане/фото с произвольным фоном: де-шум, контраст, ML-детекция
|
||||
- **convert-handwritten-signature-to-digital-signature** — очистка и «оцифровка» почерка (ML, денойзинг, выравнивание)
|
||||
- **extract-barcode-from-image** — декодирование штрих-кодов/QR: готовая библиотека **zxing-wasm**, сопряжение с камерой/фото
|
||||
- **compress-png / reduce-png-file-size / optimize-png** — сжатие на уровне
|
||||
формата: нужен качественный PNG-энкодер с контролем zlib-фильтров/битовой
|
||||
глубины и оптимизаторы. Решение: **pngquant / oxipng / zopfli (WASM)** +
|
||||
подгонка под «качество→размер» (это главный кандидат на HARD — именно здесь
|
||||
тюнинг бесконечен)
|
||||
- **remove-png-background (вариант с ИИ)** — опциональное улучшение к
|
||||
MEDIUM-версии: ML-сегментация (Rembg/DeepLab в WASM или ONNX) для произвольных
|
||||
фото, где цветовой матчинг бессилен. Начинать можно без него — не-ИИ версия
|
||||
уже даёт рабочий инструмент
|
||||
- **extract-signature-from-image** — детект подписи на скане/фото с произвольным
|
||||
фоном: де-шум, контраст, ML-детекция
|
||||
- **convert-handwritten-signature-to-digital-signature** — очистка и «оцифровка»
|
||||
почерка (ML, денойзинг, выравнивание)
|
||||
- **extract-barcode-from-image** — декодирование штрих-кодов/QR: готовая
|
||||
библиотека **zxing-wasm**, сопряжение с камерой/фото
|
||||
|
||||
> Итого **6 утилит уровня HARD** (все — опциональные улучшения/надстройки). Всё остальное (≈290) — EASY/MEDIUM.
|
||||
> Итого **6 утилит уровня HARD** (все — опциональные улучшения/надстройки). Всё
|
||||
> остальное (≈290) — EASY/MEDIUM.
|
||||
|
||||
---
|
||||
|
||||
## 5. Итоговая сводка
|
||||
|
||||
**EASY — ~185 утилит.** canvas + ImageData.
|
||||
Примеры: crop, resize, rotate, конвертации, альфа-канал, палитры, текст, рамки, анализ.
|
||||
**EASY — ~185 утилит.** canvas + ImageData. Примеры: crop, resize, rotate,
|
||||
конвертации, альфа-канал, палитры, текст, рамки, анализ.
|
||||
|
||||
**MEDIUM — ~71 утилита.** Алгоритмы, Web Workers, gifenc.
|
||||
Примеры: blur, sharpen, морфология, квантование, дизеринг, pixel sorting, анимации GIF, remove-background (без ИИ).
|
||||
**MEDIUM — ~71 утилита.** Алгоритмы, Web Workers, gifenc. Примеры: blur,
|
||||
sharpen, морфология, квантование, дизеринг, pixel sorting, анимации GIF,
|
||||
remove-background (без ИИ).
|
||||
|
||||
**HARD — 6 утилит.** WASM (pngquant/oxipng/zxing), ML (ONNX/Rembg).
|
||||
Примеры: compress/optimize, remove-background (ИИ), extract-signature, barcode.
|
||||
**HARD — 6 утилит.** WASM (pngquant/oxipng/zxing), ML (ONNX/Rembg). Примеры:
|
||||
compress/optimize, remove-background (ИИ), extract-signature, barcode.
|
||||
|
||||
---
|
||||
|
||||
## 6. Рекомендации по реализации
|
||||
|
||||
1. **Единое ядро обработки** — `src/lib/core/`: загрузка файла → `ImageData`, библиотека операций (`color.ts`, `alpha.ts`, `geometry.ts`, `filters.ts`, `morphology.ts`, `quantize.ts`, `channels.ts`), экспорт через `toBlob`. Каждая утилита — тонкая SvelteKit-страница + общие компоненты (DropZone, Preview, Download).
|
||||
2. **Порядок внедрения:** MVP = ~40 топовых EASY (crop, resize, rotate, convert, alpha, grayscale, brightness, палитры, watermark, add-text, add-background, pick-color, analyze). Затем остальные EASY → MEDIUM пакетами по темам (фильтры, морфология, квантование, анимации). `remove-png-background` (без ИИ) — отличная «витринная» MEDIUM-фича ранних этапов: не требует ML и переиспользует `remove-color` + flood-fill + сглаживание краёв. HARD — в последнюю очередь.
|
||||
3. **Производительность:** вся пиксельная работа и тяжёлые алгоритмы — в Web Worker; большие изображения декодировать сразу в downscale для превью; прогресс-бары для MEDIUM/HARD.
|
||||
4. **SEO/структура:** SvelteKit + static adapter — каждая утилита отдельный маршрут (`/crop-png`, `/rotate-png`), что даёт удобные адреса и повторяет распространённую структуру наборов PNG-инструментов.
|
||||
5. **Семейства logo/icon/stamp/signature** — переиспользуют общие операции; в коде это конфиг-опции той же страницы (например `/resize-png` и `/resize-icon` = один компонент с разными подписями/дефолтами). Не плодить дублирующий код.
|
||||
1. **Единое ядро обработки** — `src/lib/core/`: загрузка файла → `ImageData`,
|
||||
библиотека операций (`color.ts`, `alpha.ts`, `geometry.ts`, `filters.ts`,
|
||||
`morphology.ts`, `quantize.ts`, `channels.ts`), экспорт через `toBlob`.
|
||||
Каждая утилита — тонкая SvelteKit-страница + общие компоненты (DropZone,
|
||||
Preview, Download).
|
||||
2. **Порядок внедрения:** MVP = ~40 топовых EASY (crop, resize, rotate, convert,
|
||||
alpha, grayscale, brightness, палитры, watermark, add-text, add-background,
|
||||
pick-color, analyze). Затем остальные EASY → MEDIUM пакетами по темам
|
||||
(фильтры, морфология, квантование, анимации). `remove-png-background` (без
|
||||
ИИ) — отличная «витринная» MEDIUM-фича ранних этапов: не требует ML и
|
||||
переиспользует `remove-color` + flood-fill + сглаживание краёв. HARD — в
|
||||
последнюю очередь.
|
||||
3. **Производительность:** вся пиксельная работа и тяжёлые алгоритмы — в Web
|
||||
Worker; большие изображения декодировать сразу в downscale для превью;
|
||||
прогресс-бары для MEDIUM/HARD.
|
||||
4. **SEO/структура:** SvelteKit + static adapter — каждая утилита отдельный
|
||||
маршрут (`/crop-png`, `/rotate-png`), что даёт удобные адреса и повторяет
|
||||
распространённую структуру наборов PNG-инструментов.
|
||||
5. **Семейства logo/icon/stamp/signature** — переиспользуют общие операции; в
|
||||
коде это конфиг-опции той же страницы (например `/resize-png` и
|
||||
`/resize-icon` = один компонент с разными подписями/дефолтами). Не плодить
|
||||
дублирующий код.
|
||||
|
||||
@@ -1,103 +1,118 @@
|
||||
# Demo fidelity: /preview/demo vs refs-html/demo.html
|
||||
|
||||
> ⚠️ **УТРАТИЛ АКТУАЛЬНОСТЬ — архивная копия.** Актуальный план фикса верности и
|
||||
> текущий статус пунктов B/F — в `docs/plan-design-fix.md` (Этап 1). Аудит верности
|
||||
> теперь автоматизирован (`pnpm audit` → `web/audit/audit-report.md`; пиксель-дифф не
|
||||
> используется); темы в `refs-html` переключаются (скрипт инжектится в
|
||||
> `scripts/extract-static.mjs`). Ниже — исходный текст на момент написания.
|
||||
> текущий статус пунктов B/F — в `docs/plan-design-fix.md` (Этап 1). Аудит
|
||||
> верности теперь автоматизирован (`pnpm audit` → `web/audit/audit-report.md`;
|
||||
> пиксель-дифф не используется); темы в `refs-html` переключаются (скрипт
|
||||
> инжектится в `scripts/extract-static.mjs`). Ниже — исходный текст на момент
|
||||
> написания.
|
||||
|
||||
Метод: Playwright, viewport 1440×900, скриншоты обеих страниц + замер вычисленных
|
||||
стилей ключевых элементов + пиксель-дифф (без чтения картинки моделью).
|
||||
Пиксель-дифф: **24.9%** (наш скролл 1731px, реф 1579px). Зоны различий размазаны
|
||||
по всей высоте → править надо глобально, не точечно.
|
||||
Метод: Playwright, viewport 1440×900, скриншоты обеих страниц + замер
|
||||
вычисленных стилей ключевых элементов + пиксель-дифф (без чтения картинки
|
||||
моделью). Пиксель-дифф: **24.9%** (наш скролл 1731px, реф 1579px). Зоны различий
|
||||
размазаны по всей высоте → править надо глобально, не точечно.
|
||||
|
||||
Теперь процесс автоматизирован: `pnpm audit` (Playwright) пишет
|
||||
`web/audit/audit-report.md` — light/dark токены + поэлементный дифф стилей по тексту.
|
||||
Пиксель-дифф убран (неудобен для работы). Фиксы можно делать в любой момент и
|
||||
перепроверять аудитом.
|
||||
`web/audit/audit-report.md` — light/dark токены + поэлементный дифф стилей по
|
||||
тексту. Пиксель-дифф убран (неудобен для работы). Фиксы можно делать в любой
|
||||
момент и перепроверять аудитом.
|
||||
|
||||
## Баги (объективно, правим в любом случае)
|
||||
|
||||
- **B1. Кнопки/инпуты рендерятся шрифтом Arial, а не IBM Plex Sans.**
|
||||
Доказательство: пробы `Reset pipeline` → `fontFamily: Arial`. Причина: у `button/input`
|
||||
не задан `font-family: inherit`. Фикс: добавить в reset `design2.css`
|
||||
`button, input, select, textarea { font-family: inherit; }`. Влияет на все страницы. (P1)
|
||||
- **B2. Двойной топбар.** Страница демо рендерит свой `.topbar` (brand / AUTO PIPELINE /
|
||||
help / theme / lang), который перекрывается глобальным `fixed` Header из
|
||||
`preview/+layout.svelte` (в нём только theme-toggle). В рефе — один топбар.
|
||||
Контент демо-топбара (brand, статус, язык) визуально спрятан за фикс-хедером.
|
||||
Фикс: убрать кастомный `.topbar` из демо и сделать хедер лейаута реф-стайл топбаром
|
||||
(brand + version + status + help + theme + lang) — улучшит и C9/C10. (P1)
|
||||
Доказательство: пробы `Reset pipeline` → `fontFamily: Arial`. Причина: у
|
||||
`button/input` не задан `font-family: inherit`. Фикс: добавить в reset
|
||||
`design2.css` `button, input, select, textarea { font-family: inherit; }`.
|
||||
Влияет на все страницы. (P1)
|
||||
- **B2. Двойной топбар.** Страница демо рендерит свой `.topbar` (brand / AUTO
|
||||
PIPELINE / help / theme / lang), который перекрывается глобальным `fixed`
|
||||
Header из `preview/+layout.svelte` (в нём только theme-toggle). В рефе — один
|
||||
топбар. Контент демо-топбара (brand, статус, язык) визуально спрятан за
|
||||
фикс-хедером. Фикс: убрать кастомный `.topbar` из демо и сделать хедер лейаута
|
||||
реф-стайл топбаром (brand + version + status + help + theme + lang) — улучшит
|
||||
и C9/C10. (P1)
|
||||
|
||||
## Расхождения дизайна (нужно решение)
|
||||
|
||||
- **F1. Фон холста.** Реф: плоский серый `#EEF1F4`. Наш: белый + blueprint-сетка.
|
||||
Решение: совпасть с рефом (убрать сетку на демо) или оставить сетку как сигнатуру нового дизайна.
|
||||
- **F2. Бейдж типа шага** (BACKGROUND/TRANSFORM/STYLE). Реф: приглушённый серый `#6D787F`.
|
||||
Решено C16: `StepCard` рендерит `type` как приглушённый моно-текст (не синий бейдж);
|
||||
индекс шага (`step-index`) — синий `#1769D2`, как в рефе.
|
||||
- **F3. Подписи превью-тайлов** (SOURCE / STEP 01 / FINAL OUTPUT). Реф: синий `#1769D2`,
|
||||
weight 400. Решено C16: `PreviewTile` label — синий `#1769D2`.
|
||||
- **F4. Раскладка preview-stack.** Реф: одна колонка (5 строк, выше). Наш: 2 колонки (grid).
|
||||
Решение: одна колонка (как реф) или оставить 2 колонки как выбор нового дизайна.
|
||||
- **F1. Фон холста.** Реф: плоский серый `#EEF1F4`. Наш: белый +
|
||||
blueprint-сетка. Решение: совпасть с рефом (убрать сетку на демо) или оставить
|
||||
сетку как сигнатуру нового дизайна.
|
||||
- **F2. Бейдж типа шага** (BACKGROUND/TRANSFORM/STYLE). Реф: приглушённый серый
|
||||
`#6D787F`. Решено C16: `StepCard` рендерит `type` как приглушённый моно-текст
|
||||
(не синий бейдж); индекс шага (`step-index`) — синий `#1769D2`, как в рефе.
|
||||
- **F3. Подписи превью-тайлов** (SOURCE / STEP 01 / FINAL OUTPUT). Реф: синий
|
||||
`#1769D2`, weight 400. Решено C16: `PreviewTile` label — синий `#1769D2`.
|
||||
- **F4. Раскладка preview-stack.** Реф: одна колонка (5 строк, выше). Наш: 2
|
||||
колонки (grid). Решение: одна колонка (как реф) или оставить 2 колонки как
|
||||
выбор нового дизайна.
|
||||
- **F5. Вес лейблов контролов.** Реф: 400. Наш (`MonoLabel`): 500. Мелко.
|
||||
- **F6. Мета-подписи** (DIMENSIONS/FORMAT/SIZE): реф fs 9px, наш 10px. Мелко.
|
||||
- **F7. Копирайт:** `Add tool` у нас `+ Add tool`; кнопка `Download result` — сверить стиль
|
||||
(в рефе синяя с белым текстом 478×42). Пробы `Download result`/`Add tool`/`DIRECTION`
|
||||
вернули NOT FOUND — скорее артефакт замера (вложенный текст/чип размера), проверить визуально.
|
||||
- **F7. Копирайт:** `Add tool` у нас `+ Add tool`; кнопка `Download result` —
|
||||
сверить стиль (в рефе синяя с белым текстом 478×42). Пробы
|
||||
`Download result`/`Add tool`/`DIRECTION` вернули NOT FOUND — скорее артефакт
|
||||
замера (вложенный текст/чип размера), проверить визуально.
|
||||
- **F8. h1 line-height** чуть выше (наш 150px vs реф 115px) — мелкий зазор.
|
||||
- **F9. Вертикальный ритм:** контент на ~152px выше, зазоры между секциями отличаются
|
||||
(pipeline-head y 434 vs 400 и т.д.) — подровнять паддинги под реф.
|
||||
- **F10. Внутренние отступы preview-панели** (Hide intermediate x 907 vs 1218) — паддинг панели.
|
||||
- **F9. Вертикальный ритм:** контент на ~152px выше, зазоры между секциями
|
||||
отличаются (pipeline-head y 434 vs 400 и т.д.) — подровнять паддинги под реф.
|
||||
- **F10. Внутренние отступы preview-панели** (Hide intermediate x 907 vs 1218) —
|
||||
паддинг панели.
|
||||
|
||||
## Темы (отложено — F1-F4)
|
||||
|
||||
Серый фон `#EEF1F4` — это **светлая** тема. Уточнение по F1: наш токен `--background`
|
||||
уже равен `#EEF1F4` (совпадает с рефом); видимое отличие — только blueprint-сетка
|
||||
поверх фона, а не «белый vs серый». То есть F1 = «сетка против плоского», не цвет фона.
|
||||
Серый фон `#EEF1F4` — это **светлая** тема. Уточнение по F1: наш токен
|
||||
`--background` уже равен `#EEF1F4` (совпадает с рефом); видимое отличие — только
|
||||
blueprint-сетка поверх фона, а не «белый vs серый». То есть F1 = «сетка против
|
||||
плоского», не цвет фона.
|
||||
|
||||
Перед тем как сверять цвета бейджей/тайлов/раскладок (F2-F4), нужно сначала
|
||||
зафиксировать палитры light/dark как единый источник токенов. Сейчас они есть в
|
||||
`web/src/lib/styles/design2.css` (`:root` + `[data-theme="dark"]`), но не сверены с рефом.
|
||||
`web/src/lib/styles/design2.css` (`:root` + `[data-theme="dark"]`), но не
|
||||
сверены с рефом.
|
||||
|
||||
**Как проверить темы в refs-html:**
|
||||
|
||||
- Открыть `refs-html/demo.html` (и соседние `gradient.html`, `background-remover.html`)
|
||||
через Playwright/браузер.
|
||||
- Открыть `refs-html/demo.html` (и соседние `gradient.html`,
|
||||
`background-remover.html`) через Playwright/браузер.
|
||||
- Нажать theme-toggle в топбаре рефа (иконка луны/солнца) — переключает тему
|
||||
(скорее всего меняет атрибут/класс на `<html>` или `.page` и переопределяет CSS-переменные).
|
||||
- Снять скриншоты светлой и тёмной темы, замерить computed-стиль `:root` переменных
|
||||
рефа (через `getComputedStyle(document.documentElement).getPropertyValue('--...')`)
|
||||
и сопоставить с нашими токенами в `design2.css`.
|
||||
(скорее всего меняет атрибут/класс на `<html>` или `.page` и переопределяет
|
||||
CSS-переменные).
|
||||
- Снять скриншоты светлой и тёмной темы, замерить computed-стиль `:root`
|
||||
переменных рефа (через
|
||||
`getComputedStyle(document.documentElement).getPropertyValue('--...')`) и
|
||||
сопоставить с нашими токенами в `design2.css`.
|
||||
- Рефы, похоже, не доделаны по тёмной теме — зафиксировать, какие именно токены
|
||||
меняются и где расхождения. На основе этого выровнять наши токены, затем вернуться
|
||||
к F2 (бейдж типа), F3 (тайлы), F4 (раскладка preview-stack).
|
||||
меняются и где расхождения. На основе этого выровнять наши токены, затем
|
||||
вернуться к F2 (бейдж типа), F3 (тайлы), F4 (раскладка preview-stack).
|
||||
|
||||
Решение по F1-F4 принимается только после сверки токенов светлой/тёмной темы с рефом.
|
||||
Решение по F1-F4 принимается только после сверки токенов светлой/тёмной темы с
|
||||
рефом.
|
||||
|
||||
**Состояние тем (уточнение):** наша dark-тема реализована в `design2.css`
|
||||
(`[data-theme="dark"]`), но пока подключена только на preview-маршрутах (старые
|
||||
страницы грузят `app.css`). Этого достаточно для сверки: токены, отличные от light,
|
||||
соберём по всем `refs-html/*.html` (не только `demo.html`), остальные токены
|
||||
не меняются. Light-токены уже совпадают с рефом (`--background`, `--panel`,
|
||||
`--foreground`, `--muted`, `--line`, `--blue` равны).
|
||||
страницы грузят `app.css`). Этого достаточно для сверки: токены, отличные от
|
||||
light, соберём по всем `refs-html/*.html` (не только `demo.html`), остальные
|
||||
токены не меняются. Light-токены уже совпадают с рефом (`--background`,
|
||||
`--panel`, `--foreground`, `--muted`, `--line`, `--blue` равны).
|
||||
|
||||
## Состояние (C16)
|
||||
|
||||
Цветовые расхождения против `refs-html/demo.html` устранены: стек шрифтов сверен с
|
||||
рефом (`"IBM Plex Sans", sans-serif` / `"IBM Plex Mono", monospace`), бренд топбара —
|
||||
`--foreground`, индекс шага — синий, тип шага и подпись плитки — как в рефе,
|
||||
`Segmented` — 10px/32px. Аудит: токены light 10 / dark 16, элементы 47. Остаток —
|
||||
sub-pixel (10px→9px лейблы, letter-spacing) и структурные (`display`/`rect` от наших
|
||||
flex/grid раскладок против статичного мока); визуально незначимы, правим точечно при
|
||||
желании. Старые тесты (583) — без регрессий.
|
||||
Цветовые расхождения против `refs-html/demo.html` устранены: стек шрифтов сверен
|
||||
с рефом (`"IBM Plex Sans", sans-serif` / `"IBM Plex Mono", monospace`), бренд
|
||||
топбара — `--foreground`, индекс шага — синий, тип шага и подпись плитки — как в
|
||||
рефе, `Segmented` — 10px/32px. Аудит: токены light 10 / dark 16, элементы 47.
|
||||
Остаток — sub-pixel (10px→9px лейблы, letter-spacing) и структурные
|
||||
(`display`/`rect` от наших flex/grid раскладок против статичного мока);
|
||||
визуально незначимы, правим точечно при желании. Старые тесты (583) — без
|
||||
регрессий.
|
||||
|
||||
## План действий (предлагаю)
|
||||
|
||||
> Актуальный пошаговый план фикса вынесен в `docs/plan-design-fix.md`
|
||||
> (Этапы 0–1: рефакторинг + верность). Ниже — исходные наброски.
|
||||
> Актуальный пошаговый план фикса вынесен в `docs/plan-design-fix.md` (Этапы
|
||||
> 0–1: рефакторинг + верность). Ниже — исходные наброски.
|
||||
|
||||
1. Сразу поправить B1 + B2 (чёткие баги, улучшают все страницы).
|
||||
2. По F1–F4 — решение за тобой (совпадать с рефом дословно ИЛИ оставить как сигнатуру нового дизайна).
|
||||
3. F5–F10 — мелочи, подчистить в том же проходе, если решаем «совпадать с рефом».
|
||||
2. По F1–F4 — решение за тобой (совпадать с рефом дословно ИЛИ оставить как
|
||||
сигнатуру нового дизайна).
|
||||
3. F5–F10 — мелочи, подчистить в том же проходе, если решаем «совпадать с
|
||||
рефом».
|
||||
4. Переснять скриншоты и перепроверить пиксель-дифф.
|
||||
|
||||
@@ -4,20 +4,36 @@
|
||||
|
||||
## 1. Цели и принципы
|
||||
|
||||
1. **Один источник правды** — все визуальные константы живут в токенах `web/src/app.css`; ни один компонент не пишет «магические» цвета/размеры напрямую.
|
||||
2. **Никакого дублирования** — повторяющийся визуальный рецепт оформляется один раз: либо токен, либо глобальный утилитарный класс, либо компонент в `ui/`.
|
||||
3. **Простые компоненты важнее умных** — если проще сделать отдельные `SliderField` и `TextField`, делаем два маленьких компонента, а не один конфигурируемый монстр.
|
||||
4. **Scoped-стили — только для раскладки** конкретного компонента (сетка, отступы между блоками). Внешний вид контролов и текстов — из дизайн-системы.
|
||||
1. **Один источник правды** — все визуальные константы живут в токенах
|
||||
`web/src/app.css`; ни один компонент не пишет «магические» цвета/размеры
|
||||
напрямую.
|
||||
2. **Никакого дублирования** — повторяющийся визуальный рецепт оформляется один
|
||||
раз: либо токен, либо глобальный утилитарный класс, либо компонент в `ui/`.
|
||||
3. **Простые компоненты важнее умных** — если проще сделать отдельные
|
||||
`SliderField` и `TextField`, делаем два маленьких компонента, а не один
|
||||
конфигурируемый монстр.
|
||||
4. **Scoped-стили — только для раскладки** конкретного компонента (сетка,
|
||||
отступы между блоками). Внешний вид контролов и текстов — из дизайн-системы.
|
||||
|
||||
## 2. Аудит: что дублируется прямо сейчас
|
||||
|
||||
- **«Приглушённый текст»** (`--text-muted` + размер 0.85–0.95rem) — повторён в семи местах: `ToolPage` (.description, .hint), главная (.lead, .card-desc), лейаут (.nav-link, footer), `Preview` (.dims), `ParamForm` (label), `InfoPanel` (dt).
|
||||
- **«Заголовок секции капсом»** (uppercase, letter-spacing, muted) — точная копипаста в `ToolPage` и на главной.
|
||||
- **Базовые инпуты** (padding, border, radius) — живут только внутри `ParamForm`; любые новые типы контролов (слайдеры, пипетка цвета) пришлось бы копировать вручную.
|
||||
- **Блок «label + отступы»** — дублировался бы в каждом `*Field`; решается обёрткой `Field.svelte`.
|
||||
- **«Панель»** (surface + border + radius) — три ручные реализации одного паттерна: `DropZone`, карточки главной, строки `InfoPanel`.
|
||||
- **Кнопки** — глобальные классы `.primary`/`.secondary` без компонента; состояния «занят/недоступен» каждый описывает заново.
|
||||
- **Ошибка-баннер** — только в `ToolPage`, а понадобится минимум ещё раз в workspace.
|
||||
- **«Приглушённый текст»** (`--text-muted` + размер 0.85–0.95rem) — повторён в
|
||||
семи местах: `ToolPage` (.description, .hint), главная (.lead, .card-desc),
|
||||
лейаут (.nav-link, footer), `Preview` (.dims), `ParamForm` (label),
|
||||
`InfoPanel` (dt).
|
||||
- **«Заголовок секции капсом»** (uppercase, letter-spacing, muted) — точная
|
||||
копипаста в `ToolPage` и на главной.
|
||||
- **Базовые инпуты** (padding, border, radius) — живут только внутри
|
||||
`ParamForm`; любые новые типы контролов (слайдеры, пипетка цвета) пришлось бы
|
||||
копировать вручную.
|
||||
- **Блок «label + отступы»** — дублировался бы в каждом `*Field`; решается
|
||||
обёрткой `Field.svelte`.
|
||||
- **«Панель»** (surface + border + radius) — три ручные реализации одного
|
||||
паттерна: `DropZone`, карточки главной, строки `InfoPanel`.
|
||||
- **Кнопки** — глобальные классы `.primary`/`.secondary` без компонента;
|
||||
состояния «занят/недоступен» каждый описывает заново.
|
||||
- **Ошибка-баннер** — только в `ToolPage`, а понадобится минимум ещё раз в
|
||||
workspace.
|
||||
|
||||
## 3. Токены (расширение `app.css`)
|
||||
|
||||
@@ -55,11 +71,13 @@
|
||||
- `.text-muted` — приглушённый текст (размер наследуется);
|
||||
- `.text-caption` — мелкий текст `--text-s`;
|
||||
- `.heading-section` — заголовок секции капсом (вместо двух копипаст);
|
||||
- `.panel` — поверхность с бордером/радиусом (DropZone, карточки, InfoPanel-строки);
|
||||
- `.panel` — поверхность с бордером/радиусом (DropZone, карточки,
|
||||
InfoPanel-строки);
|
||||
- `.error-banner` — баннер ошибки;
|
||||
- `.visually-hidden` — скрытый, но читаемый скринридерами.
|
||||
|
||||
Элементные стили из текущего `app.css` (голые `input, select { ... }`) убрать — внешний вид контролов переезжает в компоненты `ui/`.
|
||||
Элементные стили из текущего `app.css` (голые `input, select { ... }`) убрать —
|
||||
внешний вид контролов переезжает в компоненты `ui/`.
|
||||
|
||||
## 5. Состав UI-компонентов (`web/src/lib/components/ui/`)
|
||||
|
||||
@@ -76,15 +94,23 @@
|
||||
| `ColorField.svelte` | `id`, `label`, `value = $bindable()` | color-ветка `ParamForm`; точка будущего расширения пипеткой |
|
||||
| `EmptyState.svelte` | `title`, `hint`, слот действия | «Обработка…», будущие пустые панели исходника/результата |
|
||||
|
||||
`Field` — чисто раскладочная обёртка (`<div class="field"><label for>…</label><slot /></div>`), ничего не знает о типе контрола. На ней построены `TextField`, `SliderField`, `SelectField`, `ColorField`. Исключение — `CheckboxField`: у чекбокса label оборачивает контрол (кликабельная строка), поэтому у него своя раскладка.
|
||||
`Field` — чисто раскладочная обёртка
|
||||
(`<div class="field"><label for>…</label><slot /></div>`), ничего не знает о
|
||||
типе контрола. На ней построены `TextField`, `SliderField`, `SelectField`,
|
||||
`ColorField`. Исключение — `CheckboxField`: у чекбокса label оборачивает контрол
|
||||
(кликабельная строка), поэтому у него своя раскладка.
|
||||
|
||||
## 6. Миграция существующего
|
||||
|
||||
1. `ParamForm` — переписывается на `Field` + `*Field`-компоненты; собственная стилизация контролов и label удаляется.
|
||||
1. `ParamForm` — переписывается на `Field` + `*Field`-компоненты; собственная
|
||||
стилизация контролов и label удаляется.
|
||||
2. `DownloadButton` и кнопки `ToolPage` — на `Button`.
|
||||
3. `DropZone`, карточки главной, строки `InfoPanel` — на `.panel` + свои раскладочные стили.
|
||||
4. `ToolPage`, главная, лейаут — приглушённые тексты на `.text-*`, заголовки секций на `.heading-section`, баннер на `.error-banner`.
|
||||
5. Из `app.css` удалить глобальные классы кнопок и голые стили инпутов после перехода.
|
||||
3. `DropZone`, карточки главной, строки `InfoPanel` — на `.panel` + свои
|
||||
раскладочные стили.
|
||||
4. `ToolPage`, главная, лейаут — приглушённые тексты на `.text-*`, заголовки
|
||||
секций на `.heading-section`, баннер на `.error-banner`.
|
||||
5. Из `app.css` удалить глобальные классы кнопок и голые стили инпутов после
|
||||
перехода.
|
||||
|
||||
## 7. Порядок работ
|
||||
|
||||
@@ -97,13 +123,18 @@
|
||||
|
||||
## 8. Критерии готовности
|
||||
|
||||
- Ни один компонент не задаёт цвет/радиус/шрифт мимо токенов или классов системы.
|
||||
- Ни один компонент не задаёт цвет/радиус/шрифт мимо токенов или классов
|
||||
системы.
|
||||
- Все интерактивные контролы на страницах — экземпляры `ui/*`.
|
||||
- `grep -E '#[0-9a-f]{3,6}' web/src/lib/components` не находит ничего, кроме токенов в `app.css`.
|
||||
- Визуальной регрессии нет (ручная сверка всех страниц до/после), тесты и билд зелёные.
|
||||
- `grep -E '#[0-9a-f]{3,6}' web/src/lib/components` не находит ничего, кроме
|
||||
токенов в `app.css`.
|
||||
- Визуальной регрессии нет (ручная сверка всех страниц до/после), тесты и билд
|
||||
зелёные.
|
||||
|
||||
## 9. Что сознательно не делаем
|
||||
|
||||
- Никаких CSS-фреймворков и препроцессоров — обычный CSS, как решено на старте.
|
||||
- Никаких тем/тёмного режима — но токены сразу семантические, чтобы тема добавилась заменой значений `:root`.
|
||||
- Никаких сложных составных компонентов (модалки, таблицы) — они появятся, когда потребуется реальная задача.
|
||||
- Никаких тем/тёмного режима — но токены сразу семантические, чтобы тема
|
||||
добавилась заменой значений `:root`.
|
||||
- Никаких сложных составных компонентов (модалки, таблицы) — они появятся, когда
|
||||
потребуется реальная задача.
|
||||
|
||||
+116
-45
@@ -4,38 +4,59 @@
|
||||
>
|
||||
> **Что сделано по плану:**
|
||||
>
|
||||
> - Шаг 0 — каркас: SvelteKit + adapter-static, design-токены на CSS, лейаут с навигацией по категориям ✔
|
||||
> - Шаг 1 — ядро: `PixelImage`, декодирование файлов, кодирование PNG/JPEG/WebP, скачивание ✔
|
||||
> - Шаг 2 — операции ядра: color / geometry / alpha / analyze, чистые функции без DOM ✔
|
||||
> - Шаг 0 — каркас: SvelteKit + adapter-static, design-токены на CSS, лейаут с
|
||||
> навигацией по категориям ✔
|
||||
> - Шаг 1 — ядро: `PixelImage`, декодирование файлов, кодирование PNG/JPEG/WebP,
|
||||
> скачивание ✔
|
||||
> - Шаг 2 — операции ядра: color / geometry / alpha / analyze, чистые функции
|
||||
> без DOM ✔
|
||||
> - Шаг 3 — реестр из 11 инструментов + санитизация параметров ✔
|
||||
> - Шаг 4 — универсальные UI-компоненты (DropZone, Preview, ParamForm, DownloadButton, InfoPanel, ToolPage) ✔
|
||||
> - Шаг 5 — маршруты: `/tools/[id]` с prerender всех страниц, главная-каталог по категориям ✔
|
||||
> - Шаг 4 — универсальные UI-компоненты (DropZone, Preview, ParamForm,
|
||||
> DownloadButton, InfoPanel, ToolPage) ✔
|
||||
> - Шаг 5 — маршруты: `/tools/[id]` с prerender всех страниц, главная-каталог по
|
||||
> категориям ✔
|
||||
> - Шаги 6–7 — обработка ошибок, состояния, полировка; верификация ✔
|
||||
>
|
||||
> **Checkpoint пройден:** все 11 инструментов вручную проверены в браузере (загрузка → применение → скачивание), включая найденный и исправленный баг resize с сохранением пропорций. Тесты 75/75, svelte-check 0 ошибок, статический экспорт собирается.
|
||||
> **Checkpoint пройден:** все 11 инструментов вручную проверены в браузере
|
||||
> (загрузка → применение → скачивание), включая найденный и исправленный баг
|
||||
> resize с сохранением пропорций. Тесты 75/75, svelte-check 0 ошибок,
|
||||
> статический экспорт собирается.
|
||||
>
|
||||
> **Попутные улучшения сверх плана:** корневой скрипт `pnpm dev`; `sanitizeParams` против пустых/невалидных значений полей; семантика «вписать в размеры» для resize.
|
||||
> **Попутные улучшения сверх плана:** корневой скрипт `pnpm dev`;
|
||||
> `sanitizeParams` против пустых/невалидных значений полей; семантика «вписать в
|
||||
> размеры» для resize.
|
||||
|
||||
## 0. Зафиксированные решения
|
||||
|
||||
- **Объём MVP:** только одиночные инструменты «загрузил → настроил → скачал». Пайплайн-workspace (`/workspace`) вне скоупа этого плана.
|
||||
- **Набор инструментов первого среза:** ~11 базовых EASY (см. §3). Достаточно, чтобы проверить насквозь ядро → реестр → универсальную страницу.
|
||||
- **Стили:** обычный CSS без фреймворка — design-токены на CSS custom properties, ноль зависимостей.
|
||||
- **Стек:** SvelteKit + `@sveltejs/adapter-static` + TypeScript, пакетный менеджер pnpm (уже зафиксирован в корневом `package.json`). Весь код сайта — в `web/`.
|
||||
- **Ядро без DOM:** все пиксельные операции — чистые функции над `PixelImage`; DOM/canvas допускается только в модуле IO и компонентах превью.
|
||||
- **Объём MVP:** только одиночные инструменты «загрузил → настроил → скачал».
|
||||
Пайплайн-workspace (`/workspace`) вне скоупа этого плана.
|
||||
- **Набор инструментов первого среза:** ~11 базовых EASY (см. §3). Достаточно,
|
||||
чтобы проверить насквозь ядро → реестр → универсальную страницу.
|
||||
- **Стили:** обычный CSS без фреймворка — design-токены на CSS custom
|
||||
properties, ноль зависимостей.
|
||||
- **Стек:** SvelteKit + `@sveltejs/adapter-static` + TypeScript, пакетный
|
||||
менеджер pnpm (уже зафиксирован в корневом `package.json`). Весь код сайта — в
|
||||
`web/`.
|
||||
- **Ядро без DOM:** все пиксельные операции — чистые функции над `PixelImage`;
|
||||
DOM/canvas допускается только в модуле IO и компонентах превью.
|
||||
|
||||
---
|
||||
|
||||
## 1. Шаг 0 — Каркас проекта
|
||||
|
||||
1. Создать SvelteKit-приложение в `web/`: `npx sv create web` (шаблон minimal, TypeScript, без лишних аддонов).
|
||||
1. Создать SvelteKit-приложение в `web/`: `npx sv create web` (шаблон minimal,
|
||||
TypeScript, без лишних аддонов).
|
||||
2. `pnpm install` внутри `web/`.
|
||||
3. `svelte.config.js`: заменить адаптер на `@sveltejs/adapter-static`.
|
||||
4. `src/routes/+layout.ts`: `export const prerender = true;` — весь сайт prerenderится в статику.
|
||||
5. `src/app.css`: дизайн-токены (палитра, типографика, отступы, радиусы через CSS custom properties) + минимальный сброс стилей.
|
||||
6. `src/routes/+layout.svelte`: шапка с названием сайта и навигацией по категориям инструментов (категории — константа из реестра), футер.
|
||||
4. `src/routes/+layout.ts`: `export const prerender = true;` — весь сайт
|
||||
prerenderится в статику.
|
||||
5. `src/app.css`: дизайн-токены (палитра, типографика, отступы, радиусы через
|
||||
CSS custom properties) + минимальный сброс стилей.
|
||||
6. `src/routes/+layout.svelte`: шапка с названием сайта и навигацией по
|
||||
категориям инструментов (категории — константа из реестра), футер.
|
||||
|
||||
**Проверка:** `pnpm dev` открывает пустой каркас; `pnpm build && pnpm preview` отдаёт статический экспорт.
|
||||
**Проверка:** `pnpm dev` открывает пустой каркас; `pnpm build && pnpm preview`
|
||||
отдаёт статический экспорт.
|
||||
|
||||
---
|
||||
|
||||
@@ -46,28 +67,42 @@
|
||||
- `types.ts`
|
||||
|
||||
```ts
|
||||
export type PixelImage = { width: number; height: number; data: Uint8ClampedArray }; // RGBA, как ImageData
|
||||
export type PixelImage = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
}; // RGBA, как ImageData
|
||||
```
|
||||
|
||||
- `io.ts` — единственное место ядра, где разрешён canvas/DOM:
|
||||
- `decodeFile(file: File): Promise<PixelImage>` — `createImageBitmap(file)` → отрисовка на canvas → `getImageData`;
|
||||
- `encode(img: PixelImage, mime: 'image/png' | 'image/jpeg' | 'image/webp', quality?: number): Promise<Blob>` — `putImageData` → `canvas.toBlob`;
|
||||
- `decodeFile(file: File): Promise<PixelImage>` — `createImageBitmap(file)` →
|
||||
отрисовка на canvas → `getImageData`;
|
||||
- `encode(img: PixelImage, mime: 'image/png' | 'image/jpeg' | 'image/webp', quality?: number): Promise<Blob>`
|
||||
— `putImageData` → `canvas.toBlob`;
|
||||
- `downloadBlob(blob: Blob, filename: string): void`.
|
||||
|
||||
**Проверка:** юнит-смоук — декодировать фикстурный PNG, закодировать обратно, размеры совпадают.
|
||||
**Проверка:** юнит-смоук — декодировать фикстурный PNG, закодировать обратно,
|
||||
размеры совпадают.
|
||||
|
||||
---
|
||||
|
||||
## 3. Шаг 2 — Операции ядра (чистые функции)
|
||||
|
||||
Файлы в `web/src/lib/core/`, каждая функция: `(img, params) => new PixelImage`, без мутации входа:
|
||||
Файлы в `web/src/lib/core/`, каждая функция: `(img, params) => new PixelImage`,
|
||||
без мутации входа:
|
||||
|
||||
- `color.ts`: `grayscale(img)` (luma), `invert(img)`, `brightnessContrast(img, brightness −100..100, contrast −100..100)`.
|
||||
- `geometry.ts`: `flip(img, axis)`, `rotate90(img, turns)`, `crop(img, x, y, w, h)` — индексная математика; `resize(img, w, h)` — билинейная интерполяция на TS (не `drawImage`, чтобы ядро осталось DOM-free и воспроизводимым бит-в-бит).
|
||||
- `alpha.ts`: `removeColorToAlpha(img, hex, tolerancePercent)` — цветовое расстояние → альфа 0.
|
||||
- `color.ts`: `grayscale(img)` (luma), `invert(img)`,
|
||||
`brightnessContrast(img, brightness −100..100, contrast −100..100)`.
|
||||
- `geometry.ts`: `flip(img, axis)`, `rotate90(img, turns)`,
|
||||
`crop(img, x, y, w, h)` — индексная математика; `resize(img, w, h)` —
|
||||
билинейная интерполяция на TS (не `drawImage`, чтобы ядро осталось DOM-free и
|
||||
воспроизводимым бит-в-бит).
|
||||
- `alpha.ts`: `removeColorToAlpha(img, hex, tolerancePercent)` — цветовое
|
||||
расстояние → альфа 0.
|
||||
- `analyze.ts`: `imageInfo(img)` → `{ width, height, hasAlpha, colorCount }`.
|
||||
|
||||
**Проверка:** vitest на маленьких матрицах пикселей (2×2, 3×3): flip, rotate, crop, grayscale — ожидаемые значения руками.
|
||||
**Проверка:** vitest на маленьких матрицах пикселей (2×2, 3×3): flip, rotate,
|
||||
crop, grayscale — ожидаемые значения руками.
|
||||
|
||||
---
|
||||
|
||||
@@ -77,8 +112,22 @@
|
||||
|
||||
```ts
|
||||
export type ParamDef =
|
||||
| { id: string; label: string; type: "number"; min?: number; max?: number; step?: number; default: number }
|
||||
| { id: string; label: string; type: "select"; options: { value: string; label: string }[]; default: string }
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "number";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "select";
|
||||
options: { value: string; label: string }[];
|
||||
default: string;
|
||||
}
|
||||
| { id: string; label: string; type: "checkbox"; default: boolean }
|
||||
| { id: string; label: string; type: "color"; default: string };
|
||||
|
||||
@@ -88,11 +137,15 @@ export type ToolEntry = {
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
params: ParamDef[];
|
||||
run: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
run: (
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<PixelImage> | PixelImage;
|
||||
};
|
||||
```
|
||||
|
||||
Категории (константа там же): `convert`, `alpha`, `color`, `geometry`, `analyze`.
|
||||
Категории (константа там же): `convert`, `alpha`, `color`, `geometry`,
|
||||
`analyze`.
|
||||
|
||||
### Состав первого среза — 11 инструментов
|
||||
|
||||
@@ -110,9 +163,11 @@ export type ToolEntry = {
|
||||
| 10 | `remove-color-from-png` | alpha | hex color, tolerance % |
|
||||
| 11 | `png-info` | analyze | — особый случай: не картинка, а текстовая сводка |
|
||||
|
||||
Для `png-info` в записи реестра добавляется флаг `resultType: 'info'`; остальные — `'image'`.
|
||||
Для `png-info` в записи реестра добавляется флаг `resultType: 'info'`; остальные
|
||||
— `'image'`.
|
||||
|
||||
**Проверка:** импорт реестра в тесте — все id уникальны, у каждого `params` имеют дефолты, `run` определён.
|
||||
**Проверка:** импорт реестра в тесте — все id уникальны, у каждого `params`
|
||||
имеют дефолты, `run` определён.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,33 +175,45 @@ export type ToolEntry = {
|
||||
|
||||
`web/src/lib/components/`:
|
||||
|
||||
- `DropZone.svelte` — drag&drop + клик; эмитит `File`; проверка MIME (PNG/JPEG/WebP/GIF/BMP).
|
||||
- `Preview.svelte` — рендер `PixelImage` на canvas с fit-масштабированием; шахматный фон под прозрачность.
|
||||
- `ParamForm.svelte` — генерирует контролы из `ParamDef[]`, двусторонний `bind:values`.
|
||||
- `DownloadButton.svelte` — encode выбранного формата + `downloadBlob`, имя `<tool-id>.<ext>`.
|
||||
- `DropZone.svelte` — drag&drop + клик; эмитит `File`; проверка MIME
|
||||
(PNG/JPEG/WebP/GIF/BMP).
|
||||
- `Preview.svelte` — рендер `PixelImage` на canvas с fit-масштабированием;
|
||||
шахматный фон под прозрачность.
|
||||
- `ParamForm.svelte` — генерирует контролы из `ParamDef[]`, двусторонний
|
||||
`bind:values`.
|
||||
- `DownloadButton.svelte` — encode выбранного формата + `downloadBlob`, имя
|
||||
`<tool-id>.<ext>`.
|
||||
- `InfoPanel.svelte` — текстовый вывод для `resultType: 'info'`.
|
||||
- `ToolPage.svelte` — оркестратор страницы инструмента; состояния `idle → loaded → processing → done | error`; раскладка: слева DropZone, справа превью + форма параметров + кнопки «Применить» / «Скачать».
|
||||
- `ToolPage.svelte` — оркестратор страницы инструмента; состояния
|
||||
`idle → loaded → processing → done | error`; раскладка: слева DropZone, справа
|
||||
превью + форма параметров + кнопки «Применить» / «Скачать».
|
||||
|
||||
**Правило:** новый инструмент = новая запись в реестре (+ функция операции). Компоненты и маршрут не трогаем.
|
||||
**Правило:** новый инструмент = новая запись в реестре (+ функция операции).
|
||||
Компоненты и маршрут не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## 6. Шаг 5 — Маршруты
|
||||
|
||||
- `/` (`src/routes/+page.svelte`) — сетка карточек инструментов, сгруппированных по категориям; данные из реестра.
|
||||
- `/` (`src/routes/+page.svelte`) — сетка карточек инструментов, сгруппированных
|
||||
по категориям; данные из реестра.
|
||||
- `/tools/[id]` — универсальная страница:
|
||||
- `+page.ts`: `entries()` возвращает все `id` из реестра (обязательное условие prerender при adapter-static);
|
||||
- `+page.ts`: `entries()` возвращает все `id` из реестра (обязательное условие
|
||||
prerender при adapter-static);
|
||||
- `load()` возвращает запись реестра по `id`, неизвестный id → 404;
|
||||
- `+page.svelte` — тонкая обёртка над `ToolPage`.
|
||||
|
||||
**Проверка:** `pnpm build` — в `build/tools/` лежат все 11 страниц; главная содержит ссылки на каждую.
|
||||
**Проверка:** `pnpm build` — в `build/tools/` лежат все 11 страниц; главная
|
||||
содержит ссылки на каждую.
|
||||
|
||||
---
|
||||
|
||||
## 7. Шаг 6 — Ошибки и минимальная полировка
|
||||
|
||||
- Обработка ошибок: неподдерживаемый формат, ошибка декода, некорректные параметры crop (выход за границы — клампы или понятная ошибка).
|
||||
- Состояния: «перетащите файл», спиннер во время обработки, disabled-кнопки до загрузки.
|
||||
- Обработка ошибок: неподдерживаемый формат, ошибка декода, некорректные
|
||||
параметры crop (выход за границы — клампы или понятная ошибка).
|
||||
- Состояния: «перетащите файл», спиннер во время обработки, disabled-кнопки до
|
||||
загрузки.
|
||||
- Тексты описаний для всех 11 инструментов (title/description уже в реестре).
|
||||
|
||||
---
|
||||
@@ -161,6 +228,10 @@ cd web && pnpm build # статический экспорт должен
|
||||
cd web && pnpm test # vitest: ядро (геометрия/цвет) + целостность реестра
|
||||
```
|
||||
|
||||
Ручной smoke-тест каждого из 11 инструментов: загрузка PNG → применить → скачать → открыть результат.
|
||||
Ручной smoke-тест каждого из 11 инструментов: загрузка PNG → применить → скачать
|
||||
→ открыть результат.
|
||||
|
||||
**Checkpoint MVP:** любой из 11 инструментов доступен по прямому URL с главной страницы; PNG загружается drag&drop; параметры применяются; результат скачивается; всё работает офлайн со статического хостинга; ни одной серверной зависимости.
|
||||
**Checkpoint MVP:** любой из 11 инструментов доступен по прямому URL с главной
|
||||
страницы; PNG загружается drag&drop; параметры применяются; результат
|
||||
скачивается; всё работает офлайн со статического хостинга; ни одной серверной
|
||||
зависимости.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# План: аудит верности `preview/*` против `refs-html` (DOM → CSS)
|
||||
|
||||
> Статус: к выполнению. Ветка `feat/redesign`.
|
||||
> Источник правды по пикселям: `refs-html/*.html` (статический экспорт рефа).
|
||||
> Актуальный рабочий аудит сейчас — `pnpm refs-audit` (`scripts/audit.mjs`), но он
|
||||
> **сломан** (см. «Почему текущий аудит не работает») и подлежит замене.
|
||||
> **СТАТУС: РЕАЛИЗОВАН (2026-09-07) — устарел.** Скрипты
|
||||
> `web/scripts/audit-dom.mjs`, `audit-css.mjs`, `audit-cdp.mjs`,
|
||||
> `audit-cdp-responsive.mjs` и npm-скрипты `refs-dom-audit` / `refs-css-audit` /
|
||||
> `refs-cdp-audit` на месте; старый текстовый `audit.mjs` удалён. Актуальная
|
||||
> доводка верности и статус пунктов F — в `plan-design-fix.md`.
|
||||
|
||||
> Статус: к выполнению. Ветка `feat/redesign`. Источник правды по пикселям:
|
||||
> `refs-html/*.html` (статический экспорт рефа). Актуальный рабочий аудит сейчас
|
||||
> — `pnpm refs-audit` (`scripts/audit.mjs`), но он **сломан** (см. «Почему
|
||||
> текущий аудит не работает») и подлежит замене.
|
||||
|
||||
## Почему текущий аудит не работает
|
||||
|
||||
@@ -30,13 +36,13 @@
|
||||
Заводим их через `t()` — совпадёт с рефом (EN).
|
||||
2. **Имена классов НЕ сводим к рефу.** Матчинг делаем по **структурному пути**,
|
||||
а не по классу. Компоненты `kit/` остаются идиоматичными, Stage 0 не
|
||||
откатываем. Требуется только эквивалентность *структуры* (теги, вложенность,
|
||||
откатываем. Требуется только эквивалентность _структуры_ (теги, вложенность,
|
||||
порядок сиблингов, наличие узлов, текст).
|
||||
3. **Пиксельный дифф (Playwright + pixelmatch) пока НЕ делаем.** DOM+CSS-аудита
|
||||
достаточно; добавим как сеть позже, если стили что-то упустят.
|
||||
4. **Порядок прогона маршрутов:** `demo` → `list-tools` → затем 2 инструмента
|
||||
(`tools/linear-gradient-png` ↔ `gradient.html`,
|
||||
`tools/remove-background-png` ↔ `background-remover.html`).
|
||||
(`tools/linear-gradient-png` ↔ `gradient.html`, `tools/remove-background-png`
|
||||
↔ `background-remover.html`).
|
||||
|
||||
## Принципы
|
||||
|
||||
@@ -99,15 +105,15 @@ Svelte-атрибутов и комментов гидрации), чтобы п
|
||||
- `moved` — тот же текст/тег, другой путь (перепутанный порядок/родитель);
|
||||
- `textMismatch` — нормализованный текст различается (в т.ч. RU vs EN).
|
||||
|
||||
Класс-мисматч — **только информационно**, не блокирует (классы свои по
|
||||
решению №2). Счётчики: `added / removed / moved / textMismatch`.
|
||||
Класс-мисматч — **только информационно**, не блокирует (классы свои по решению
|
||||
№2). Счётчики: `added / removed / moved / textMismatch`.
|
||||
|
||||
### Рендеринг
|
||||
|
||||
- **наше:** dev-сервер на `5179` (как сейчас в `audit.mjs`).
|
||||
- **реф:** `pathToFileURL` к `refs-html/<ref>.html` (`file://`). Реф —
|
||||
самодостаточный (инлайн `<style>`), рендерится полностью. Шрифты для фазы A
|
||||
не важны (сравниваем структуру/текст, не растеризацию).
|
||||
самодостаточный (инлайн `<style>`), рендерится полностью. Шрифты для фазы A не
|
||||
важны (сравниваем структуру/текст, не растеризацию).
|
||||
- Переиспользуем из текущего `audit.mjs`: `discoverRoutes()`, `EXCLUDE`
|
||||
(`/preview`), `DYNAMIC` (`tools/[id]` → `gradient.html` /
|
||||
`background-remover.html`), `refNameFor()`.
|
||||
@@ -115,8 +121,8 @@ Svelte-атрибутов и комментов гидрации), чтобы п
|
||||
### Шаги (на каждом маршруте)
|
||||
|
||||
1. Прогнать `pnpm refs-dom-audit`, получить дельты.
|
||||
2. Поправить структуру Svelte под реф (добавить/убрать/переставить узлы,
|
||||
вынести тексты в `t()` с EN-базой).
|
||||
2. Поправить структуру Svelte под реф (добавить/убрать/переставить узлы, вынести
|
||||
тексты в `t()` с EN-базой).
|
||||
3. Перезапустить, пока `added/removed/moved/textMismatch` ≃ 0 (кроме `IGNORE`).
|
||||
|
||||
---
|
||||
@@ -128,17 +134,17 @@ Svelte-атрибутов и комментов гидрации), чтобы п
|
||||
|
||||
### Сравниваемые свойства (computed)
|
||||
|
||||
`borderRadius`, позиция/размер (`getBoundingClientRect`: x/y/w/h),
|
||||
`padding` (все стороны), `margin`, `color`, `backgroundColor`,
|
||||
`borderRadius`, позиция/размер (`getBoundingClientRect`: x/y/w/h), `padding`
|
||||
(все стороны), `margin`, `color`, `backgroundColor`,
|
||||
`borderTop/Right/Bottom/Left Width|Color`, `fontFamily`, `fontSize`,
|
||||
`fontWeight`, `letterSpacing`, `lineHeight`, `gap`, `display`, `boxShadow`.
|
||||
|
||||
### Дифф
|
||||
|
||||
Для каждого состыкованного по пути элемента — список свойств с дельтой
|
||||
(наше значение → реф). Элементы, не совпавшие по пути (остаток из фазы A),
|
||||
идут в `unmatched`. Отчёт: элемент → свойство → наше/реф. Правится одной
|
||||
строкой CSS, без брутфорса.
|
||||
Для каждого состыкованного по пути элемента — список свойств с дельтой (наше
|
||||
значение → реф). Элементы, не совпавшие по пути (остаток из фазы A), идут в
|
||||
`unmatched`. Отчёт: элемент → свойство → наше/реф. Правится одной строкой CSS,
|
||||
без брутфорса.
|
||||
|
||||
### Шаги
|
||||
|
||||
@@ -160,17 +166,17 @@ Svelte-атрибутов и комментов гидрации), чтобы п
|
||||
(шаги 1–5 плана list-tools). То есть фаза A/B для list-tools стартует после
|
||||
landing этих шагов.
|
||||
3. **`tools/linear-gradient-png`** (↔ `gradient.html`) и
|
||||
**`tools/remove-background-png`** (↔ `background-remover.html`) — **последние**.
|
||||
Известные расхождения: наши инструменты могут иметь **другие наборы
|
||||
параметров / UI**, чем конкретные рефовые тулзы. Ожидаются структурные и
|
||||
стилевые дельты, которые не сводятся к «подровнять паддинг». Для них —
|
||||
**`tools/remove-background-png`** (↔ `background-remover.html`) —
|
||||
**последние**. Известные расхождения: наши инструменты могут иметь **другие
|
||||
наборы параметров / UI**, чем конкретные рефовые тулзы. Ожидаются структурные
|
||||
и стилевые дельты, которые не сводятся к «подровнять паддинг». Для них —
|
||||
триаж после прогона:
|
||||
- реальный разрыв верности → фиксим;
|
||||
- принципиальное отличие (параметр, которого нет в реф-тулзе; иной контрол)
|
||||
→ либо аккуратно приводим наш UI параметра к рефу, либо **документируем
|
||||
как допустимое расхождение / заносим в allowlist** конфига аудита
|
||||
(селектор + причина). «Скипы/фиксы» для инструментов оформляем явно, чтобы
|
||||
аудит не шумел на осознанных отличиях.
|
||||
- принципиальное отличие (параметр, которого нет в реф-тулзе; иной контрол) →
|
||||
либо аккуратно приводим наш UI параметра к рефу, либо **документируем как
|
||||
допустимое расхождение / заносим в allowlist** конфига аудита (селектор +
|
||||
причина). «Скипы/фиксы» для инструментов оформляем явно, чтобы аудит не
|
||||
шумел на осознанных отличиях.
|
||||
|
||||
---
|
||||
|
||||
@@ -178,25 +184,25 @@ Svelte-атрибутов и комментов гидрации), чтобы п
|
||||
|
||||
- `scripts/audit-dom.mjs` — фаза A (нормализация + структурный дифф).
|
||||
- `scripts/audit-css.mjs` — фаза B (матчинг по пути + дифф стилей).
|
||||
- `package.json`: `refs-dom-audit`, `refs-css-audit` (опционально
|
||||
`refs-audit`, прогоняющий обе фазы).
|
||||
- Переиспользуем из старого `audit.mjs`: `discoverRoutes`, `EXCLUDE`,
|
||||
`DYNAMIC`, `refNameFor`, `waitFor` (fast-fail на 4xx/5xx).
|
||||
- `package.json`: `refs-dom-audit`, `refs-css-audit` (опционально `refs-audit`,
|
||||
прогоняющий обе фазы).
|
||||
- Переиспользуем из старого `audit.mjs`: `discoverRoutes`, `EXCLUDE`, `DYNAMIC`,
|
||||
`refNameFor`, `waitFor` (fast-fail на 4xx/5xx).
|
||||
- Отчёты: `web/audit/dom-report.{md,json}` и `web/audit/css-report.{md,json}`
|
||||
(старый `audit-report.*` заменяем).
|
||||
|
||||
## Верификация (после каждого коммита)
|
||||
|
||||
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`,
|
||||
`pnpm build` — без ошибок.
|
||||
- `pnpm refs-dom-audit` (фаза A) / `pnpm refs-css-audit` (фаза B) по
|
||||
затронутому маршруту: счётчики расхождений не растут без причины.
|
||||
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`, `pnpm build`
|
||||
— без ошибок.
|
||||
- `pnpm refs-dom-audit` (фаза A) / `pnpm refs-css-audit` (фаза B) по затронутому
|
||||
маршруту: счётчики расхождений не растут без причины.
|
||||
|
||||
## Риски / заметки
|
||||
|
||||
- **Стабильность матчинга:** нужна аккуратная нормализация (сортировка
|
||||
классов, игнор svelte-атрибутов/`data-*`, игнор whitespace-текстовых узлов,
|
||||
игнор внутренностей SVG-иконок). Подстраивается по первым прогонам demo.
|
||||
- **Стабильность матчинга:** нужна аккуратная нормализация (сортировка классов,
|
||||
игнор svelte-атрибутов/`data-*`, игнор whitespace-текстовых узлов, игнор
|
||||
внутренностей SVG-иконок). Подстраивается по первым прогонам demo.
|
||||
- **Оболочка** (TopBar/footer/язык) сейчас отличается по дизайну — сводим к рефу
|
||||
в рамках полного соответствия (без IGNORE); финальная унификация компонентов —
|
||||
отдельным рефакторингом позже.
|
||||
@@ -0,0 +1,593 @@
|
||||
# План: составные типы параметров и полная типизация pipeline
|
||||
|
||||
> **СТАТУС: ЗАКРЫТ (2026-09-07).** Типизация pipeline, составные типы
|
||||
> (dimension/color-pair/offset/position9/font-style/plate/gradient),
|
||||
> `schema.layout`, изоляция веток и вся миграция инструментов (121/125,
|
||||
> остальные 4 — отложены решениями) выполнены. Ключевые архитектурные решения
|
||||
> (параллельная сборка, правило копий, изоляция old↔preview) закреплены в
|
||||
> `AGENTS.md`. **Незакрытое из этого плана:** шаг 37 — физический переезд старых
|
||||
> файлов в папки `old/` (перенесён в `backlog.md`), и удаление старого кода — по
|
||||
> `plan-redesign.md` (C17–C21). Отложенные инструменты — `watermark-image-png`
|
||||
> (overlay), `png-info`, `reduce-to-size` — в `backlog.md`. Ниже — полное
|
||||
> состояние на момент закрытия.
|
||||
|
||||
> Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент в
|
||||
> preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены все
|
||||
> одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension` полностью
|
||||
> переведён** (12 инструментов: create-empty, single-color, random-noise,
|
||||
> linear-gradient, color-spectrum, random-colors, draw-grid, placeholder,
|
||||
> fit-on-background, change-canvas-size, resize, crop); **составной тип
|
||||
> `color-pair` полностью переведён** (4 инструмента: blend-two, step-colors,
|
||||
> linear-gradient, two-colors); **составной тип `offset` полностью переведён**
|
||||
> (4 инструмента: circle-mask, square-mask, star-mask, wavy-mask); **составной
|
||||
> тип `position9` — переведены add-text-png и date-stamp-png** (водяной
|
||||
> знак-картинка — отдельный шаг: требует overlay-механику в новом превью);
|
||||
> **составной тип `font-style` полностью переведён** (text-to-png, add-text,
|
||||
> date-stamp); **составной тип `plate` полностью переведён** (add-text,
|
||||
> date-stamp); **составной тип `gradient` полностью переведён**
|
||||
> (linear-gradient): вместо горизонтального/ вертикального select — **угол
|
||||
> 0..360°** (новый виджет `AngleControl`: slider + кнопки-пресеты
|
||||
> 0°/90°/180°/270°, переиспользуемый компонент); preview научен применять
|
||||
> **генераторы** (`executeGenerate`, кнопка Generate) и рендерить все kinds
|
||||
> схемы (slider/number/color/select/checkbox/dimension/
|
||||
> color-pair/offset/position9/font-style/plate/gradient). **Пер-инструмент
|
||||
> раскладка `schema.layout` реализована** (шаг 32): тип `ToolSchemaLayout`
|
||||
> {groups: [{title?, cols?, fields}]}, рендер групп в `SchemaFields.svelte`
|
||||
> (заголовок группы + сетка колонок, неупомянутые поля — в общей группе), пилот
|
||||
> — `add-text` (группы Text/Placement/Plate). **UI-макеты расставлены по
|
||||
> переведённым инструментам** (шаг 33): geometry (fit-on-background,
|
||||
> change-canvas-size, resize, crop), маски alpha (Shape/Position), generate
|
||||
> (create-empty, linear-gradient, color-spectrum, random-colors, draw-grid,
|
||||
> step-colors, placeholder, text-to-png), text (add-text, date-stamp), filters
|
||||
> (randomize-pixels, add-noise) — везде, где канвас отделён от параметров
|
||||
> эффекта либо фигура от позиции. Следующее закрыто в
|
||||
> `archive/plan-migrate-remaining.md` (2026-09-07): перевод оставшихся
|
||||
> инструментов выполнен (всего 121/125 в preview; 4 хвоста —
|
||||
> `watermark-image-png`, `png-info`, `reduce-to-size-png`, `compress-png` —
|
||||
> отложены решениями в `backlog.md`). Незакрытые продолжения: переезд старых
|
||||
> компонентов в папки `old/` (шаг 37 ниже), закрытие долгов и сбор фидбека — см.
|
||||
> также `plan-design-fix.md`.
|
||||
>
|
||||
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||
>
|
||||
> - `web/src/lib/preview/categories.ts`, `web/src/lib/registry-schema.ts` (kind
|
||||
> `dimension`), `kit/fields/DimensionField.svelte`,
|
||||
> `preview/executor/index.ts` (`executeGenerate`), `SchemaFields.svelte`
|
||||
> (полный рендер kinds). Старый `web/src/lib/registry.ts` разбит по категориям
|
||||
> в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
|
||||
|
||||
## Ключевая стратегия: параллельная сборка, старый UI не трогаем
|
||||
|
||||
Старый UI (группа `(old)/`, `ParamForm.svelte` + старый pipeline) **продолжает
|
||||
работать как сейчас, без рефакторинга**. Новый `ToolSchema<P>` строится рядом и
|
||||
служит источником для **нового UI**. Инструменты переписываются под новый
|
||||
registry по мере нужды — для нового UI.
|
||||
|
||||
Это та же логика, что в `plan-redesign.md` применена к дизайну: параллельная
|
||||
сборка, изоляция от старого, затем новый становится основным и старый удаляется
|
||||
вместе со старым дизайном.
|
||||
|
||||
**Правило копий (важно!):** если для нового UI/registry нужно внести изменения в
|
||||
файл, который **прямо или косвенно** уже используется `(old)` веткой, — этот
|
||||
файл **НЕ трогаем**. Вместо этого делаем **копию** в новом месте (например, в
|
||||
`lib/preview/`) и правим копию. Это приводит к дублированию, но **гарантированно
|
||||
не задевает старую ветку сайта**. Пример: категории — новый
|
||||
`lib/preview/categories.ts` (object as const) копирует и заменяет собой
|
||||
`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и
|
||||
остаётся без изменений. После перехода (Фаза 5) копия становится основной, а
|
||||
исходник продолжает обслуживать `(old)` UI (он не удаляется — см. шаг 35).
|
||||
|
||||
**Разделение схем: две независимые схемы.**
|
||||
|
||||
| Схема | Источник | Использование |
|
||||
| --------------------- | ------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| Старый `ParamDef[]` | В `ToolEntry.params` — **остаётся без изменений** | Старый UI, старый pipeline (`ParamForm.svelte`, executor, chain) |
|
||||
| Новый `ToolSchema<P>` | Новое поле `ToolEntry.schema` — добавляется рядом | Новый UI (kit/`ParamControl`, новый pipeline) |
|
||||
|
||||
- Старая и новая схемы **не выводятся друг из друга** — это два независимых
|
||||
описания инструмента.
|
||||
- Пока новый UI не готов — `schema` просто отсутствует у большинства
|
||||
инструментов, старый UI ничего не замечает.
|
||||
- Удаление старого UI (после перехода) попутно удаляет и старый `ParamDef[]`
|
||||
|
||||
### Разделение registry по UI (новое решение, Фаза 2+)
|
||||
|
||||
Помимо двух схем, registry тоже разделён по UI (по факту миграции):
|
||||
|
||||
- **Старый registry** (`web/src/lib/registry.ts` + `registry/` +
|
||||
`registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]`. **Не
|
||||
трогаем**; остаётся обслуживать `(old)/` маршруты (см. шаг 35).
|
||||
- **Новый registry** (`web/src/lib/registry-new/`) — строится **с нуля «как
|
||||
надо»**: `ToolEntry<P>` с обязательным `schema`, типизированный `run`, **без**
|
||||
`ParamDef[]` и **без** связи со старым. Импортирует core-функции
|
||||
(`expandCanvas`, `strokeImage`, …) напрямую. Наполняется **по-инструментно**
|
||||
по мере миграции (не разовым переводом всех 130). Preview показывает только
|
||||
переведённые инструменты.
|
||||
|
||||
Preview (`catalog.ts`, маршруты `preview/**`, `SchemaToolView`, `SchemaFields`)
|
||||
импортируют из `$lib/registry-new`. После удаления старого UI `registry-new/`
|
||||
переименуется в `registry`.
|
||||
|
||||
**Executor/worker для нового UI (осознанное дублирование):** общий
|
||||
`executor.worker.ts` резолвит инструменты по `id` в **старом** registry — для
|
||||
новых инструментов их там нет. Поэтому `executor.ts` + `executor.worker.ts`
|
||||
**скопированы** в `web/src/lib/preview/` и переписаны под `registry-new` +
|
||||
`sanitizeSchemaParams`. Старый executor/worker под старый UI остаются
|
||||
нетронутыми; `SchemaToolView` использует `$lib/preview/executor`. Это
|
||||
дублирование необходимо до ухода старого UI (после — preview-executor заменяет
|
||||
общий).
|
||||
|
||||
## Проблема (текущая)
|
||||
|
||||
- `ParamDef` — плоский union из 6 примитивов
|
||||
(`number | slider | select | checkbox | color | text`). Параметры инструмента
|
||||
— отдельные записи в плоском массиве.
|
||||
- `run()`/`generate()` получают `Record<string, unknown>`; значения достаются
|
||||
через `num(p, "fromColor")` — runtime string lookup **без type safety**, легко
|
||||
напутать id.
|
||||
- Поля инструмента **слабо связаны** (например «ширина» и «цвет» рамки — два
|
||||
независимых поля без общего контракта) → общий генерализованный рендер, нет
|
||||
возможности сделать идеальный пер-инструмент UI.
|
||||
- Preview-система (`tool-views.ts`) **дублирует** registry (FieldDef vs
|
||||
ParamDef) — одни и те же инструменты описаны дважды.
|
||||
|
||||
## Решения (принятые в обсуждении)
|
||||
|
||||
1. **Полная типизация.** `interface Params` (runtime-тип) объявляется явно в
|
||||
каждом инструменте. `run`/`generate` типизируются прямо на него, а не на
|
||||
`Record<string, unknown>`.
|
||||
2. **Отдельная типизированная схема для UI/дефолтов/валидации.** Два понятия на
|
||||
инструмент: runtime-тип + схема. Схема проверяется компилятором на
|
||||
соответствие `Params` (чтобы не рассинхронизировались).
|
||||
3. **Общий рендер,** но с пер-инструмент layout: общие компоненты рисуют поля по
|
||||
схеме; схема инструмента задаёт layout и группировку. Без отдельного
|
||||
Svelte-компонента на каждый инструмент.
|
||||
4. **Составные типы** (`gradient`, `color-pair`, `dimension`, `offset`,
|
||||
`position9`, `font-style`, `plate`) остаются в плане — это способ выразить в
|
||||
схеме связанную группу полей и переиспользовать её на нескольких инструментах
|
||||
и в UI.
|
||||
5. Рано или поздно `tool-views.ts` поглощается registry (preview/lede/layout
|
||||
переезжают в meta инструмента) — но это отдельный шаг, см. ниже.
|
||||
6. **Старый UI не рефакторим.** Две независимые схемы: старый `ParamDef[]`
|
||||
остаётся источником для старого UI без изменений; новый `ToolSchema<P>`
|
||||
строится рядом для нового UI (см. «Ключевая стратегия» выше).
|
||||
|
||||
## Целевая архитектура (на примере «добавить рамку»)
|
||||
|
||||
Цель: «добавить рамку: ширина и цвет» — **один интерфейс**, а не два слабо
|
||||
связанных поля.
|
||||
|
||||
```ts
|
||||
// 1. Runtime-тип — что получает run/generate
|
||||
interface AddBorderParams {
|
||||
thickness: number; // рамка: ширина
|
||||
color: string; // рамка: цвет
|
||||
}
|
||||
|
||||
// 2. Типизированная UI-схема, проверяемая компилятором против Params
|
||||
// Ключи схемы обязаны совпадать с полями Params; default совместим с типом.
|
||||
const addBorderSchema = toolSchema<AddBorderParams>({
|
||||
thickness: field.slider({ min: 1, max: 500, default: 5 }),
|
||||
color: field.color({ default: "#000000" }),
|
||||
layout: { group: "border", cols: 2 }, // пер-инструмент раскладка
|
||||
});
|
||||
|
||||
// 3. Инструмент в registry
|
||||
const addBorder: ToolEntry<AddBorderParams> = {
|
||||
id: "add-border-png",
|
||||
title: "Add border to PNG",
|
||||
category: "geometry",
|
||||
schema: addBorderSchema,
|
||||
run: (img, p) => expandCanvas(img, 4x..., p.color), // p.thickness — type safe
|
||||
};
|
||||
```
|
||||
|
||||
### Что даёт
|
||||
|
||||
- `p.thickness`, `p.color` — IDE autocomplete + compile-time ошибка при ошибке.
|
||||
- `run()` нельзя передать лишний/чужой ключ.
|
||||
- Схема — единый источник для дефолтов, валидации и рендера.
|
||||
- Схема одного инструмента может задавать layout, которого нет в общем рендере.
|
||||
|
||||
## Типовая система (набросок)
|
||||
|
||||
```ts
|
||||
// ─── Поле схемы ───
|
||||
interface NumberField { kind: "number"; default: number; min?: number; max?: number; step?: number }
|
||||
interface SliderField { kind: "slider"; default: number; min: number; max: number; step?: number }
|
||||
interface ColorField { kind: "color"; default: string }
|
||||
interface SelectField { kind: "select"; default: string; options: { value: string; label: string }[] }
|
||||
interface TextField { kind: "text"; default: string; placeholder?: string }
|
||||
interface CheckboxField { kind: "checkbox"; default: boolean }
|
||||
|
||||
type FieldDef =
|
||||
| NumberField | SliderField | ColorField | SelectField | TextField | CheckboxField;
|
||||
|
||||
// ─── «поле» описывает тип значения + схему вместе, чтобы не рассинхронизировать ───
|
||||
interface Field<T> {
|
||||
schema: FieldDef;
|
||||
// T — ожидаемый runtime-тип этого поля (number|string|boolean)
|
||||
}
|
||||
|
||||
// ─── Builder: field.slider({...}) возвращает Field<number> с типизированной схемой ───
|
||||
const field = {
|
||||
slider: (s: SliderField): Field<number> => ({ schema: s }),
|
||||
number: (s: NumberField): Field<number> => ({ schema: s }),
|
||||
color: (s: ColorField): Field<string> => ({ schema: s }),
|
||||
select: <V extends string>(s: SelectField & { options: {value: V}[] }): Field<V> => ({ schema: s }),
|
||||
text: (s: TextField): Field<string> => ({ schema: s }),
|
||||
checkbox: (s: CheckboxField): Field<boolean> => ({ schema: s }),
|
||||
};
|
||||
|
||||
// ─── toolSchema<P>: проверяет, что ключи схемы === полям P и типы сходятся ───
|
||||
type FieldsOf<T> = { [K in keyof T]: Field<T[K]> } & {
|
||||
layout?: { group?: string; cols?: number };
|
||||
};
|
||||
function toolSchema<P>(fields: FieldsOf<P>): ToolSchema<P> { ... }
|
||||
// TS выдаст ошибку, если schema содержит ключ, отсутствующий в Params, или
|
||||
// если type поля не совпадает с типом Params[K].
|
||||
```
|
||||
|
||||
Ключевая идея связки: **builder `field.x<T>()` связывает runtime-тип поля с его
|
||||
схемой на этапе компиляции**, а `toolSchema<P>` заставляет ключи схемы совпадать
|
||||
с ключами `P`. Это и есть защита от рассинхрона.
|
||||
|
||||
## Составные типы (для переиспользования и группировки)
|
||||
|
||||
| Тип | Под-поля | Инструментов |
|
||||
| ------------ | -------------------------- | ------------ |
|
||||
| `dimension` | width + height | 12 |
|
||||
| `color-pair` | from + to | 4 |
|
||||
| `offset` | x + y | 4 |
|
||||
| `gradient` | from + to + dir (+ type?) | 1 |
|
||||
| `position9` | position (3×3 grid) | 3 |
|
||||
| `font-style` | font + size + bold + color | 3-4 |
|
||||
| `plate` | enabled + color + opacity | 2-3 |
|
||||
|
||||
Составной тип = группы полей, которые в интерфейсе `Params` могут
|
||||
разворачиваться во вложенный объект или плоские ключи — решается на этапе
|
||||
реализации. Пример для `gradient`:
|
||||
|
||||
```ts
|
||||
interface GradientParams {
|
||||
from: string;
|
||||
to: string;
|
||||
dir: string;
|
||||
}
|
||||
// схема: field.color(from), field.color(to), field.select(dir)
|
||||
// плюс annotation в схеме: { group: "gradient" } → UI рисует как цветовой переход
|
||||
```
|
||||
|
||||
## Инвентарь: какие инструменты мигрировать
|
||||
|
||||
### dimension (12)
|
||||
|
||||
resize-png*, crop-png*, fit-on-background-png, create-empty-png,
|
||||
single-color-png, random-noise-png, linear-gradient-png, color-spectrum-png,
|
||||
random-colors-png, draw-grid-png, placeholder-png, change-canvas-size-png
|
||||
|
||||
(\* у resize/crop — не просто dimension, есть специфика: keepAspect, x/y и т.д.)
|
||||
|
||||
### color-pair (4)
|
||||
|
||||
blend-two-png, step-colors-png, linear-gradient-png, two-colors-png
|
||||
|
||||
### offset (4)
|
||||
|
||||
circle-mask-png, square-mask-png, star-mask-png, wavy-mask-png
|
||||
|
||||
### position9 (3)
|
||||
|
||||
add-text-png, date-stamp-png, watermark-image-png
|
||||
|
||||
### font-style (3-4)
|
||||
|
||||
text-to-png, add-text-png, date-stamp-png
|
||||
|
||||
### plate (2-3)
|
||||
|
||||
add-text-png, date-stamp-png
|
||||
|
||||
## Пошаговый план перевода инструментов
|
||||
|
||||
> Каждый шаг — **атомарное, маленькое изменение** (в идеале 1 инструмент = 1
|
||||
> diff, укладывается в правило «коммиты < ~500 строк»). После каждого шага —
|
||||
> ревью; параллельно можно делать UI нового инструмента.
|
||||
>
|
||||
> **Принцип малых шагов:** перевод инструмента не меняет поведение старой схемы
|
||||
> (`params` остаётся), не ломает старый UI. Инструмент получает ДОПОЛНИТЕЛЬНО
|
||||
> новое поле `schema` + `interface Params`, старое `run`/`params` не трогаем.
|
||||
> Это гарантирует, что старый UI продолжает работать без регресса.
|
||||
>
|
||||
> **Новый подход (решение пользователя):** сначала делаем **один полноценный
|
||||
> рабочий инструмент в preview** — не эталон «на бумаге», а реально юзабельный,
|
||||
> чтобы руками оценить, насколько это рабочее решение и какие возникнут
|
||||
> сложности. Оцениваем, при необходимости правим инфраструктуру, и только потом
|
||||
> переводим остальные инструменты тем же проверенным паттерном.
|
||||
|
||||
### Фаза 0 — фундамент ✔ (сделано)
|
||||
|
||||
Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` — в
|
||||
`web/src/lib/registry-schema.ts`. `ToolEntry<P>` generic + опциональное поле
|
||||
`schema`. Новые `defaultSchemaParams`/`sanitizeSchemaParams`. Старый
|
||||
`ParamDef[]`/pipeline не тронуты. Проверено: compile-time-защита
|
||||
(лишний/неверный/отсутствующий ключ ловит TS), тесты, svelte-check без новых
|
||||
ошибок.
|
||||
|
||||
### Фаза 1 — ОДИН полноценный рабочий инструмент в preview (срез) ✔ (сделано)
|
||||
|
||||
Инструмент переехал в `registry-new` и рендерится через `SchemaToolView`
|
||||
(`SchemaFields`/`SchemaPreview`); применение реально работает end-to-end до
|
||||
картинки. Паттерн подтверждён. `find-contour-png` — следующий в этой фазе-2.
|
||||
|
||||
5. **Инструмент «добавить рамку» (`add-border-png`)** — сделать полностью
|
||||
рабочим в preview:
|
||||
- `interface AddBorderParams { thickness: number; color: string }`
|
||||
- `addBorderSchema = toolSchema<AddBorderParams>(...)` + `schema: ...` в
|
||||
entry (старый `params`/`run` остаются — старый UI не регрессит).
|
||||
- **Новый рендер в preview** (`kit/ToolView` → `ParamControl` или новый
|
||||
компонент схемы): читает `ToolSchema<P>`, рисует поля рамки, применение
|
||||
реально работает (end-to-end до картинки).
|
||||
- Дефолты/валидация/значения — из схемы (`defaultSchemaParams`,
|
||||
`sanitizeSchemaParams`).
|
||||
- **Ревью-оценка:** удобно ли это, что сломалось/усложнилось, что поправить в
|
||||
инфраструктуре до масштабирования. Принимается макет/решение.
|
||||
|
||||
Результат: один реально рабочий инструмент на новой системе = доказываем
|
||||
паттерн, прежде чем переводить остальных.
|
||||
|
||||
### Фаза 2 — простые инструменты без составных типов (1 инструмент = 1 шаг)
|
||||
|
||||
Одиночные/небольшие инструменты, где `interface Params` + `schema` не требуют
|
||||
составных типов. Каждый — отдельный маленький diff (~15-30 строк), тем самым
|
||||
проверенным в Фазе 1 паттерном. **Переезжают в `registry-new`.**
|
||||
|
||||
Переведено: `add-border-png` (Фаза 1), `add-stroke-png`, `find-contour-png`,
|
||||
плюс весь блок простых инструментов ниже (пункты 8-22). Следующее — Фаза 3.
|
||||
|
||||
6. ~~add-stroke-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
7. ~~find-contour-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
8. ~~convert-png-to-jpg (color + slider)~~ → переведён в `registry-new` ✔
|
||||
9. ~~convert-png-to-webp (slider)~~ → переведён в `registry-new` ✔
|
||||
10. ~~remove-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
11. ~~extract-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
12. ~~add-noise-png (slider + select + number)~~ → переведён в `registry-new` ✔
|
||||
13. ~~randomize-pixels-png (slider + number)~~ → переведён в `registry-new` ✔
|
||||
14. ~~pixelate-png (slider)~~ → переведён в `registry-new` ✔
|
||||
15. ~~vignette-png (slider)~~ → переведён в `registry-new` ✔
|
||||
16. ~~gamma-png (slider)~~ → переведён в `registry-new` ✔
|
||||
17. ~~temperature-png (slider)~~ → переведён в `registry-new` ✔
|
||||
18. ~~tint-png (color + slider)~~ → переведён в `registry-new` ✔
|
||||
19. ~~quantize-png (slider)~~ → переведён в `registry-new` ✔
|
||||
20. ~~custom-palette-png (text)~~ → переведён в `registry-new` ✔
|
||||
21. ~~dithering-png (slider + select)~~ → переведён в `registry-new` ✔
|
||||
22. ~~jpeg-artifacts-png (slider)~~ → переведён в `registry-new` ✔
|
||||
|
||||
> **Замечание (конвертеры):** старые `convert-png-to-jpg`/`convert-png-to-webp`
|
||||
> несли `output`-метаданные (`mime`/`ext`/`qualityParamId`) для выбора формата
|
||||
> вывода. В новом `ToolEntry<P>` поля `output` пока нет, поэтому при переводе
|
||||
> эти метаданные не перенесены — preview пока отдаёт результат как PNG. Механика
|
||||
> выбора формата/качества в новом UI — отдельный шаг (не блокирует Фазу 2).
|
||||
|
||||
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
|
||||
|
||||
24. `dimension` — **переведены все 12 инструментов** ✔ (`create-empty-png`,
|
||||
`single-color-png`, `random-noise-png`, `linear-gradient-png`,
|
||||
`color-spectrum-png`, `random-colors-png`, `draw-grid-png`,
|
||||
`placeholder-png` — генераторы в `registry-new/generate.ts`;
|
||||
`fit-on-background-png`, `change-canvas-size-png`, `resize-png`, `crop-png`
|
||||
— в `registry-new/geometry.ts`). Составной тип во всех видах: вложенный
|
||||
объект `size: { width, height }` в Params + `field.dimension`, виджет
|
||||
`kit/fields/DimensionField.svelte`. Preview: генераторы применяются через
|
||||
`executeGenerate` (кнопка Generate), `SchemaFields` рендерит все kinds схемы
|
||||
(slider/number/color/select/ checkbox/dimension). Тесты: 592 passed.
|
||||
25. `color-pair` — **все 4 инструмента переведены** ✔ (`blend-two-png`,
|
||||
`step-colors-png`, `linear-gradient-png` — генераторы в
|
||||
`registry-new/generate.ts`, `two-colors-png` — run в
|
||||
`registry-new/color.ts`). Составной тип во всех видах: вложенный объект
|
||||
`pair: { from, to }` + `field.colorPair`, виджет
|
||||
`kit/fields/schema/ColorPairControl.svelte`, kind `color-pair` в схеме
|
||||
(default/sanitize). Тесты: +4 (596 passed).
|
||||
26. `offset` — **все 4 инструмента переведены** ✔ (`circle-mask-png`,
|
||||
`square-mask-png`, `star-mask-png`, `wavy-mask-png` — run в
|
||||
`registry-new/alpha.ts`). Составной тип во всех видах: вложенный объект
|
||||
`offset: { x, y }` + `field.offset`, виджет
|
||||
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
||||
(default/sanitize). Тесты: +4 (600 passed).
|
||||
27. `position9` — **переведены `add-text-png` и `date-stamp-png`** ✔ (run в
|
||||
`registry-new/text.ts`, оба `domOnly`). Составной тип во всех видах:
|
||||
значение — строка `Position9`, kind `position9` в схеме (default/sanitize по
|
||||
`POSITION9_VALUES`), виджет 3×3 `kit/fields/schema/PositionControl.svelte`.
|
||||
В `ToolEntry` добавлен флаг `domOnly` (превью-executor держит такие
|
||||
инструменты вне worker). `watermark-image-png` — отдельный под-шаг: ему
|
||||
нужен overlay-source (`getOverlay`/store), которого в новом превью пока нет.
|
||||
Тесты: +2 (602 passed). `font-style` и `plate` на этих инструментах сводятся
|
||||
в шаги 28-29.
|
||||
28. `font-style` — **все 3 инструмента переведены** ✔ (`text-to-png` — генератор
|
||||
в `registry-new/generate.ts` (domOnly), `add-text-png`/ `date-stamp-png` —
|
||||
рефакторинг в `registry-new/text.ts`). Составной тип во всех видах:
|
||||
вложенный объект `style: { font, size, bold, color }` + `field.fontStyle`,
|
||||
виджет `kit/fields/schema/FontStyleControl.svelte`, kind `font-style` в
|
||||
схеме (default/sanitize, clamp размера к min/max). Тесты: +1 (603 passed).
|
||||
29. `plate` — **все 2 инструмента переведены** ✔ (`add-text-png`,
|
||||
`date-stamp-png` — рефакторинг в `registry-new/text.ts`). Составной тип во
|
||||
всех видах: вложенный объект `plate: { enabled, color, opacity }` +
|
||||
`field.plate`, виджет `kit/fields/schema/PlateControl.svelte` (чекбокс +
|
||||
цвет + слайдер непрозрачности, деактивируется при выключенной плашке), kind
|
||||
`plate` в схеме (default/sanitize, clamp opacity к 0..100). Покрытие тестов
|
||||
расширено (дефолты/sanitize plate в существующих тестах).
|
||||
30. `gradient` — **переведён** ✔ (linear-gradient-png). Направление градиента —
|
||||
не горизонталь/вертикаль, а **произвольный угол**: новый переиспользуемый
|
||||
виджет `kit/fields/schema/AngleControl.svelte` (слайдер 0..360° + кнопки
|
||||
стандартных углов 0°/90°/180°/270° с активным состоянием). Составной тип
|
||||
`gradient: { from, to, angle }` во всех видах (`field.gradient`, kind
|
||||
`gradient` в схеме — default/sanitize, clamp угла к 0..360, валидация
|
||||
цветов; виджет `kit/fields/schema/GradientControl.svelte` — пара цветов +
|
||||
AngleControl). Рендер по углу — локальный `angleGradient` в
|
||||
`registry-new/generate.ts` (core-`gradientImage` не трогали: он умеет только
|
||||
horizontal/vertical и используется старым UI). Семантика: 0° слева направо,
|
||||
90° сверху вниз, рост угла по часовой (ось Y вниз); нормализация по полному
|
||||
диапазону проекции на ось (180°/270° разворачивают градиент). Тесты:
|
||||
дефолты, направление по углу (0°/90°/180°), sanitize/кламп.
|
||||
|
||||
### Фаза 4 — масштаб UI на остальные инструменты
|
||||
|
||||
31. По мере перевода инструментов в Фазе 2-3 — рендер схемы (из Фазы 1)
|
||||
покрывает их автоматически; составные виджеты (dimension, color-pair,
|
||||
offset, position9, font-style, plate, gradient) — по одному, каждый с ревью.
|
||||
32. Пер-инструмент layout (`schema.layout`) — **реализован** ✔.
|
||||
`ToolSchemaLayout.groups: { title?, cols?, fields[] }` в registry-schema;
|
||||
`SchemaFields.svelte` рендерит группы (подпись + сетка `1..n` колонок,
|
||||
неупомянутые поля — в общей группе в конце, без заголовка). Пилот —
|
||||
`add-text-png` (Text: text+style; Placement: position+margin; Plate). Тесты:
|
||||
schema-layout в registry-schema.test.ts; полные запуски чисты.
|
||||
33. Для каждого переведённого инструмента — UI-макет **расставлен** ✔.
|
||||
Layout-группы заданы там, где группировка содержательна (канвас отдельно от
|
||||
параметров эффекта, фигура отдельно от позиции; составные виджеты — внутри
|
||||
групп целиком):
|
||||
- geometry: fit-on-background (Canvas/Background), change-canvas-size
|
||||
(Canvas/Anchor), resize (Canvas/Scaling), crop (Offset/Crop area);
|
||||
- alpha-маски: circle/square/star/wavy (Shape — с 2 колонками где плотно,
|
||||
Position — offset);
|
||||
- generate: create-empty (Canvas/Fill), linear-gradient (Canvas/Colors),
|
||||
color-spectrum (Canvas/Spectrum), random-colors (Canvas/Random), draw-grid
|
||||
(Canvas/Grid), step-colors (Colors/Output), placeholder
|
||||
(Canvas/Colors/Text), text-to-png (Text/Background/Padding);
|
||||
- text: add-text, date-stamp (Text/Placement/Plate);
|
||||
- filters: randomize-pixels (Blocks), add-noise (Noise/Seed). Инструменты с
|
||||
1–3 простыми полями остались без layout (одна общая группа).
|
||||
|
||||
### Фаза 5 — изоляция старого UI
|
||||
|
||||
34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента) ✔.
|
||||
Отдельного `tool-views.ts` в репо нет: preview сразу строился на
|
||||
`registry-new`. Meta инструмента живёт в `ToolEntry` (`title`,
|
||||
`description`, `category`, `run`/`generate`), layout — в `schema.layout`
|
||||
(шаг 32), рендер — `SchemaToolView`/`SchemaFields`/`SchemaPreview`.
|
||||
Дублирующей системы нет.
|
||||
35. Старый UI **не удаляется**, а выносится в `(old)/`-маршруты и остаётся там
|
||||
временно (посмотреть, как работает, сравнить с новым; старые тесты
|
||||
продолжают проходить):
|
||||
- маршруты `(old)/{+page,demo,list-tools,tools/[id]}` — старый дизайн, тянет
|
||||
`old.css` (не `design2.css`), старый header/footer;
|
||||
- старый `registry.ts`/`registry/` + `ParamDef[]` обслуживают только
|
||||
`(old)/`-инструменты — не удаляются, не рефакторятся;
|
||||
- удаление происходит позже, отдельным решением (когда новый UI покроет все
|
||||
инструменты и ревью завершено).
|
||||
36. **Линтер-изоляция веток** (гарантия, что old и preview не смешиваются) ✔.
|
||||
Кастомный ESLint-плагин `web/eslint-plugins/isolation/no-mixed-imports`
|
||||
Резолвит каждый импорт (и `$lib/...`, и относительные `./`/`../`) до
|
||||
реального файла, классифицирует источник и цель по фактическому пути и
|
||||
ругается на old→new и new→old. Конфигурация (`old`/`new` glob-паттерны,
|
||||
`root`, `alias`) вынесена в настройки правила — единая точка правды:
|
||||
- старое: `routes/(old)/**`, `lib/registry.ts`, `lib/registry/**`,
|
||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||
`lib/components/**` (кроме `kit/`);
|
||||
- новое: `routes/preview/**`, `lib/registry-new/**`, `lib/preview/**`,
|
||||
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`,
|
||||
`lib/components/kit/**`;
|
||||
- общее (разрешено обоим): всё прочее — `core/`, `i18n/`, `theme`,
|
||||
`assets/`, корневой `lib` (`index.ts`, тесты). Достигнутая полная изоляция
|
||||
(одиночные пересечения устранены):
|
||||
- старые пилоты `registry/geometry.ts` (`add-border`) и `registry/alpha.ts`
|
||||
(`add-stroke`) получали `schema` из нового `registry-schema` — убрано; оба
|
||||
инструмента работают в старом UI через `params: ParamDef[]`, в preview —
|
||||
через свои schema-версии в `registry-new/`;
|
||||
- `registry.ts` больше не импортирует `ToolSchema` из `registry-schema`;
|
||||
- preview `list-tools` тянул `TOOL_ICONS` из старого `lib/tools/tool-icons`
|
||||
→ создана копия `lib/preview/tool-icons.ts` (правило копий).
|
||||
37. Перенос всех старых компонентов/библиотек в папки `old/` (**следующее**):
|
||||
переезд не трогает плагин — достаточно дописать один glob-паттерн в
|
||||
настройку правила (например `lib/old/**`, `lib/components/old/**`), а сама
|
||||
проверка работает по фактическим путям автоматически.
|
||||
|
||||
### Как ревьюить каждый шаг
|
||||
|
||||
- Diff **только один инструмент** (или фундамент без инструментов).
|
||||
- Старый `params`/`run`/`defaultParams`/`sanitizeParams` — **не изменены**
|
||||
(кроме добаление `schema`, если это пилот).
|
||||
- Старый UI и старые тесты продолжают работать — запустить `pnpm --dir web test`
|
||||
и `pnpm --dir web exec svelte-check`.
|
||||
- Порядок полей в `interface Params` и в `schema` совпадает; дефолты в сeme
|
||||
равны старым `default` из `params`.
|
||||
- Пилот из Фазы 1 проверяется **руками в preview**: применяется рамка,
|
||||
дефолты/валидация/смена значений работают end-to-end.
|
||||
|
||||
## Поток данных (runtime)
|
||||
|
||||
### Старый pipeline (не трогаем)
|
||||
|
||||
Всё как сейчас: старое `defaultParams`/`sanitizeParams`/executor/chain работают
|
||||
на `ParamDef[]` и `Record<string, unknown>`. Никаких изменений.
|
||||
|
||||
### Новый pipeline (строим рядом)
|
||||
|
||||
- **Хранилище значений** — типизировано на схему; у нового UI значения полей
|
||||
собираются в `Params` по `ToolSchema<P>`.
|
||||
- **defaultParams / sanitizeParams (новые)** — выводятся из `ToolSchema<P>`,
|
||||
дефолты лежат в схеме. Старые функции не трогаем, добавляем новые рядом.
|
||||
- **Executor/worker (новые)** — получает `Record<string, unknown>`, собирает
|
||||
`Params` по схеме, вызывает `run`. Остаётся serializable.
|
||||
- **Pipeline/chain (новый)** — шаги хранят значения, типизируются по схеме.
|
||||
|
||||
## Изменения в UI-компонентах
|
||||
|
||||
- **Старый UI** (`ParamForm.svelte`, группа `(old)/`) — **не трогаем.** Работает
|
||||
на `ParamDef[]` как раньше.
|
||||
- **Новый UI** (kit/`SchemaToolView` + `SchemaFields` + `SchemaPreview`, читает
|
||||
`ToolSchema<P>`) — сейчас рендерит поля по схеме (number/slider/color).
|
||||
- `tool-views.ts` отсутствует — preview/lede/layout уже живут в meta инструмента
|
||||
(`ToolEntry` + `schema.layout`), дублирования нет.
|
||||
|
||||
## Оценка трудозатрат
|
||||
|
||||
| Часть | Сложность | Время |
|
||||
| ----------------------------------------------------- | ------------------------ | ---------------------- |
|
||||
| Typed field builders + toolSchema\<P\> | Средняя | ~2ч |
|
||||
| Новые defaultParams/sanitizeParams под схему | Средняя | ~2-3ч |
|
||||
| ToolEntry\<P\> generic + поле schema (рядом с params) | Низкая-Средняя | ~1-2ч |
|
||||
| Новый executor/worker/pipeline под схему | Средняя | ~2-3ч |
|
||||
| Миграция инструментов на interface Params + схемы | Механическая, но крупная | ~8-12ч |
|
||||
| Новый рендер (kit/ParamControl) + составные виджеты | Средняя | ~4-5ч |
|
||||
| Поглощение tool-views.ts | Средняя | ~1-2ч |
|
||||
| Вынос старого UI в (old)/ + адаптация маршрутов | Средняя-Низкая | ~2-3ч |
|
||||
| **Итого** | | **~22-32ч** (поэтапно) |
|
||||
|
||||
> Оценка выросла по сравнению с ранней версией плана, потому что принят путь
|
||||
> «явный interface Params + схема + общий рендер с пер-инструмент layout» — это
|
||||
> полный рефакторинг pipeline, а не только добавление составных типов.
|
||||
>
|
||||
> Старый UI/`ParamDef[]`/старый pipeline в смету **не входят** — они не
|
||||
> рефакторятся, а продолжают работать на `(old)/`-маршрутах до перехода (затем
|
||||
> убираются отдельным решением — см. шаг 35).
|
||||
|
||||
## Порядок реализации (кратко)
|
||||
|
||||
Детальный пошаговый план с атомарными изменениями и порядком ревью — в разделе
|
||||
«Пошаговый план перевода инструментов» выше. Здесь краткая сводка фаз:
|
||||
|
||||
1. **Фаза 0** — фундамент: builders + `toolSchema<P>` + `ToolEntry<P>.schema` +
|
||||
новые default/sanitize. Ни одного инструмента не изменено. ✔
|
||||
2. **Фаза 1** — **один полноценный рабочий инструмент в preview** («добавить
|
||||
рамку»): schema + рендер + end-to-end применение. Ручная оценка
|
||||
работоспособности решения/сложностей.
|
||||
3. **Фаза 2** — простые/одиночные инструменты (проверенный паттерн Фазы 1), 1
|
||||
инструмент = 1 диф.
|
||||
4. **Фаза 3** — инструменты с составными типами (dimension → color-pair → offset
|
||||
→ position9 → font-style → plate → gradient), пилот каждого типа отдельно.
|
||||
5. **Фаза 4** — масштаб UI на остальные: составные виджеты, пер-инструмент
|
||||
layout (для каждого инструмента — UI-макет).
|
||||
6. **Фаза 5** — изоляция: старый UI на `(old)/`-маршруты (не удаляется, остаётся
|
||||
для ревью); preview на `registry-new` полностью; линтер-изоляция веток
|
||||
(плагин `isolation`), затем перенос старых компонентов/библиотек в папки
|
||||
`old/`.
|
||||
|
||||
## Зависимости
|
||||
|
||||
- **UI сперва доделать** — текущая активная работа; типизация params ожидает
|
||||
готовности нового дизайна.
|
||||
- Ветка redesign (`plan-redesign.md`) — параллельная, учесть при миграции
|
||||
kit/`ParamControl.svelte` и `overlay-store`.
|
||||
- Старый pipeline остаётся рабочим и шippable на всём протяжении перехода
|
||||
(аналогично стратегии `plan-redesign.md`).
|
||||
@@ -1,10 +1,19 @@
|
||||
# План: доводка дизайна до верности (фикс по refs)
|
||||
|
||||
> Статус: к выполнению. Ветка `feat/redesign`.
|
||||
> Источники: `docs/archive/demo-fidelity.md` (архивная копия старого отчёта по
|
||||
> верности, неактуальна), `docs/plan-redesign.md` (общая стратегия, фазы 1–4
|
||||
> выполнены до C16). Актуальный аудит верности — `pnpm refs-audit` (Playwright) →
|
||||
> `web/audit/audit-report.md`.
|
||||
> **СТАТУС: ЗАКРЫТ (2026-09-07).** Актуальный дизайн (`preview/*`) ушёл вперёд
|
||||
> от референсов `refs-html/` — сверка с ними больше не информативна (новые
|
||||
> компоненты `SchemaToolView`/`SchemaPreview`/каталог не имеют рефовой копии).
|
||||
> Аудит против рефа прекращается; качество дизайна дальше гоняется ревью руками
|
||||
> и специализированными проверками (`lint:all` —
|
||||
> stylelint/design-tokens/токен-аудит). Порядок удаления старого и перехода — по
|
||||
> `plan-redesign.md` (C17–C21).
|
||||
|
||||
> Статус: к выполнению. Ветка `feat/redesign`. Источники:
|
||||
> `docs/archive/demo-fidelity.md` (архивная копия старого отчёта по верности,
|
||||
> неактуальна), `docs/plan-redesign.md` (общая стратегия, фазы 1–4 выполнены до
|
||||
> C16). Актуальный аудит верности — `pnpm refs-cdp-audit` /
|
||||
> `pnpm refs-css-audit` / `pnpm refs-dom-audit` → `web/audit/audit-report.md`
|
||||
> (текущий список скриптов — в `web/package.json`, см. `refs-*audit`).
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -12,11 +21,11 @@
|
||||
старый сайт `(old)/` не трогаем до C17.
|
||||
- `refs/` и `refs-html/` приведены к консистентному виду — текущая «правда по
|
||||
пикселям». Механизм синхронизации ref→код не создаётся (см. §9 plan-redesign).
|
||||
- Аудит верности автоматизирован: `pnpm refs-audit` (Playwright, 1440×900) пишет
|
||||
`web/audit/audit-report.md` — light/dark токены + поэлементный дифф стилей;
|
||||
пиксель-дифф не используется. Палитры токенов уже сверены с рефом (light
|
||||
совпадает, dark собран). Темы в `refs-html` переключаются — скрипт инжектится
|
||||
в `scripts/extract-static.mjs`.
|
||||
- Аудит верности автоматизирован: `pnpm refs-cdp-audit` (1440×900,
|
||||
`scripts/audit-cdp.mjs`) пишет `web/audit/audit-report.md` — light/dark
|
||||
токены + поэлементный дифф стилей; пиксель-дифф не используется. Палитры
|
||||
токенов уже сверены с рефом (light совпадает, dark собран). Темы в `refs-html`
|
||||
переключаются — скрипт инжектится в `scripts/extract-static.mjs`.
|
||||
- Исходный отчёт `demo-fidelity.md` перенесён в `docs/archive/` как утративший
|
||||
актуальность; его данные актуализированы ниже (Этап 1).
|
||||
- Зафиксированные решения (подтверждены автором):
|
||||
@@ -28,13 +37,14 @@
|
||||
- Никаких правок старого дизайна `(old)/` и старого `ui/`.
|
||||
- Компоненты пишем в `kit/` (переименование в `ui/` — на C19 по plan-redesign).
|
||||
- Коммиты атомарные (< ~500 строк), по 1–2 компонента; после каждого —
|
||||
`svelte-check` + `lint` + `build` + `refs-audit` (diff% не должен расти без причины).
|
||||
`svelte-check` + `lint` + `build` + `refs-cdp-audit` (diff% не должен расти
|
||||
без причины).
|
||||
- Коммиты делает автор (агент не коммитит).
|
||||
|
||||
## Этап 0 — Рефакторинг (SRP / дизайн-обёртки), без изменения визуала
|
||||
|
||||
Гигантские страницы (`demo` ~513, `tools/[id]` ~451 строк) дробим на
|
||||
компоненты единой ответственности; убираем дублирующиеся inline-стили.
|
||||
Гигантские страницы (`demo` ~513, `tools/[id]` ~451 строк) дробим на компоненты
|
||||
единой ответственности; убираем дублирующиеся inline-стили.
|
||||
|
||||
Новые обёртки в `src/lib/components/kit/`:
|
||||
|
||||
@@ -52,9 +62,8 @@
|
||||
Декомпозиция страниц:
|
||||
|
||||
- `tools/[id]`: вынести `ParamControl` (диспетчер полей по `ParamDef`),
|
||||
`SettingsPanel` (левая панель), `PreviewPanel` (правая панель) —
|
||||
колоцированно в `routes/preview/tools/[id]/`. Страница сжимается до
|
||||
состояния + глёв.
|
||||
`SettingsPanel` (левая панель), `PreviewPanel` (правая панель) — колоцированно
|
||||
в `routes/preview/tools/[id]/`. Страница сжимается до состояния + глёв.
|
||||
- `demo`: переписать на те же обёртки + `WorkspaceHeader` (eyebrow + h1 + lede +
|
||||
file-chip).
|
||||
|
||||
@@ -69,12 +78,11 @@
|
||||
|
||||
## Этап 1 — Верность (по refs-html; статус на момент плана)
|
||||
|
||||
Аудит: `pnpm refs-audit` (Playwright, 1440×900) пишет `web/audit/audit-report.md` —
|
||||
Аудит: `pnpm refs-cdp-audit` (1440×900) пишет `web/audit/audit-report.md` —
|
||||
light/dark токены + поэлементный дифф стилей. Пиксель-дифф не используется.
|
||||
Палитры токенов уже сверены с рефом: light совпадает (`--background`,
|
||||
`--panel`, `--foreground`, `--muted`, `--line`, `--blue`), dark собран (16
|
||||
токенов). Темы в `refs-html` переключаются (скрипт инжектится в
|
||||
`extract-static.mjs`).
|
||||
Палитры токенов уже сверены с рефом: light совпадает (`--background`, `--panel`,
|
||||
`--foreground`, `--muted`, `--line`, `--blue`), dark собран (16 токенов). Темы в
|
||||
`refs-html` переключаются (скрипт инжектится в `extract-static.mjs`).
|
||||
|
||||
Открытые / решённые пункты (исходник — `docs/archive/demo-fidelity.md`):
|
||||
|
||||
@@ -84,26 +92,26 @@ light/dark токены + поэлементный дифф стилей. Пик
|
||||
рендерит ровно один `TopBar`; `demo` свой `.topbar` не рендерит. Подтвердить
|
||||
аудитом/визуально при Этапе 1.
|
||||
- **F1** (решено — плоский фон): наш `--background` уже `#EEF1F4` (как реф);
|
||||
видимое отличие было в blueprint-сетке поверх фона. Фикс: убрать `BlueprintGrid`
|
||||
из `AppShell`.
|
||||
видимое отличие было в blueprint-сетке поверх фона. Фикс: убрать
|
||||
`BlueprintGrid` из `AppShell`.
|
||||
- **F2** (сделано C16): тип шага (`StepCard`) — приглушённый mono-текст, не
|
||||
синий бейдж; индекс шага — синий `#1769D2`.
|
||||
- **F3** (сделано C16): подпись превью-тайла (`PreviewTile`) — синяя `#1769D2`.
|
||||
- **F4** (решено — одна колонка): реализуется в `PreviewStack` (Этап 0),
|
||||
колонка = `1fr`.
|
||||
- **F4** (решено — одна колонка): реализуется в `PreviewStack` (Этап 0), колонка
|
||||
= `1fr`.
|
||||
- **F5** (открыт): вес `MonoLabel` 500 → 400 (как в рефе).
|
||||
- **F6** (открыт): мета-подписи (MetaRow/MetaList, DIMENSIONS/FORMAT/SIZE)
|
||||
fs 10px → 9px.
|
||||
- **F7** (низкий): копирайт/стиль — у нас `+ Add tool` (реф `Add tool`);
|
||||
кнопка `Download result` — сверить с рефом (синяя, ~478×42). Проверить
|
||||
визуально (замеры NOT FOUND — артефакт вложенного чипа размера).
|
||||
- **F6** (открыт): мета-подписи (MetaRow/MetaList, DIMENSIONS/FORMAT/SIZE) fs
|
||||
10px → 9px.
|
||||
- **F7** (низкий): копирайт/стиль — у нас `+ Add tool` (реф `Add tool`); кнопка
|
||||
`Download result` — сверить с рефом (синяя, ~478×42). Проверить визуально
|
||||
(замеры NOT FOUND — артефакт вложенного чипа размера).
|
||||
- **F8** (низкий): `h1` line-height чуть выше рефа (150px vs 115px).
|
||||
- **F9** (открыт): вертикальный ритм — подровнять паддинги секций под реф
|
||||
(pipeline-head и пр.).
|
||||
- **F10** (открыт): внутренние отступы preview-панели (паддинг панели).
|
||||
|
||||
После Этапа 1 — повторный `refs-audit`, зафиксировать остаточный diff (sub-pixel /
|
||||
структурный) и обновить статус пунктов выше прямо в этом файле.
|
||||
После Этапа 1 — повторный `refs-cdp-audit`, зафиксировать остаточный diff
|
||||
(sub-pixel / структурный) и обновить статус пунктов выше прямо в этом файле.
|
||||
|
||||
## Этап 2 — plan-redesign.md (C17–C21), ОТЛОЖЕНО
|
||||
|
||||
@@ -128,4 +136,4 @@ light/dark токены + поэлементный дифф стилей. Пик
|
||||
страница.
|
||||
3. `demo`: переписать на обёртки.
|
||||
|
||||
После каждого коммита: `svelte-check` + `lint` + `build` + `refs-audit`.
|
||||
После каждого коммита: `svelte-check` + `lint` + `build` + `refs-cdp-audit`.
|
||||
@@ -4,65 +4,96 @@
|
||||
|
||||
## 1. Принципы отбора
|
||||
|
||||
- Максимум переиспользования: готовые контракты записей (`run`, `preview`, формат вывода, info-режим) и существующие операции ядра.
|
||||
- Новые механизмы ядра — минимальные, чистые, покрытые тестами до подключения инструментов.
|
||||
- Canvas/DOM-зависимые утилиты (SVG-вход, надписи и водяные знаки, эффекты через фильтры контекста) — вне этого пакета, отдельная волна.
|
||||
- Целевой объём волны: около 37 новых инструментов, каталог вырастает с 11 до ~48.
|
||||
- Максимум переиспользования: готовые контракты записей (`run`, `preview`,
|
||||
формат вывода, info-режим) и существующие операции ядра.
|
||||
- Новые механизмы ядра — минимальные, чистые, покрытые тестами до подключения
|
||||
инструментов.
|
||||
- Canvas/DOM-зависимые утилиты (SVG-вход, надписи и водяные знаки, эффекты через
|
||||
фильтры контекста) — вне этого пакета, отдельная волна.
|
||||
- Целевой объём волны: около 37 новых инструментов, каталог вырастает с 11 до
|
||||
~48.
|
||||
|
||||
## 2. Новые механизмы
|
||||
|
||||
- **Генераторы без входного файла**: у записи появляется признак «источник не нужен»; страница такого инструмента не показывает левую панель и зону перетаскивания — только параметры и результат.
|
||||
- **Текстовый ввод и вывод**: часть утилит принимает вставленный текст вместо файла и выдаёт текстовый результат (панель с полем только для чтения, кнопки «копировать» и «скачать как текст»). Распространяется на base64, data-uri, hex.
|
||||
- **BMP-энкодер в ядре**: 24-битный, чистая функция из пикселей в байты, с тестами на маленьких матрицах (заголовок, порядок каналов BGR, паддинг строки).
|
||||
- **Генераторы без входного файла**: у записи появляется признак «источник не
|
||||
нужен»; страница такого инструмента не показывает левую панель и зону
|
||||
перетаскивания — только параметры и результат.
|
||||
- **Текстовый ввод и вывод**: часть утилит принимает вставленный текст вместо
|
||||
файла и выдаёт текстовый результат (панель с полем только для чтения, кнопки
|
||||
«копировать» и «скачать как текст»). Распространяется на base64, data-uri,
|
||||
hex.
|
||||
- **BMP-энкодер в ядре**: 24-битный, чистая функция из пикселей в байты, с
|
||||
тестами на маленьких матрицах (заголовок, порядок каналов BGR, паддинг
|
||||
строки).
|
||||
|
||||
## 3. Состав пакета
|
||||
|
||||
**Конвертация — 6**
|
||||
`jpg-to-png`, `webp-to-png`, `gif-to-png` (первый кадр), `bmp-to-png`, `ico-to-png` — всё через уже умеющий декодировать ввод, сохранение в PNG; плюс `png-to-bmp` на новом энкодере.
|
||||
**Конвертация — 6** `jpg-to-png`, `webp-to-png`, `gif-to-png` (первый кадр),
|
||||
`bmp-to-png`, `ico-to-png` — всё через уже умеющий декодировать ввод, сохранение
|
||||
в PNG; плюс `png-to-bmp` на новом энкодере.
|
||||
|
||||
**Текстовые представления — 6**
|
||||
`png-to-base64`, `base64-to-png`, `png-to-data-uri`, `data-uri-to-png`, `png-to-hex`, `hex-to-png`.
|
||||
**Текстовые представления — 6** `png-to-base64`, `base64-to-png`,
|
||||
`png-to-data-uri`, `data-uri-to-png`, `png-to-hex`, `hex-to-png`.
|
||||
|
||||
**Цвет — 9**
|
||||
`change-opacity` (множитель прозрачности), `sepia`, `change-hue` (сдвиг тона), `extract-channel` (выбор R/G/B в оттенках серого), `swap-channels` (перестановка парой), `threshold-black-white` (порог яркости), `posterize` (число уровней), `two-colors` (два цвета + порог), `invert-alpha`.
|
||||
**Цвет — 9** `change-opacity` (множитель прозрачности), `sepia`, `change-hue`
|
||||
(сдвиг тона), `extract-channel` (выбор R/G/B в оттенках серого), `swap-channels`
|
||||
(перестановка парой), `threshold-black-white` (порог яркости), `posterize`
|
||||
(число уровней), `two-colors` (два цвета + порог), `invert-alpha`.
|
||||
|
||||
**Прозрачность и холст — 7**
|
||||
`remove-alpha-channel` (подложка белым), `set-alpha-channel` (константная прозрачность), `extract-alpha-mask` (альфа в чёрно-белую маску), `add-padding` (отступ + цвет/прозрачность), `add-border` (толщина + цвет), `round-corners` (радиус), `fit-on-background` (вписать на полотно цвета с полями).
|
||||
**Прозрачность и холст — 7** `remove-alpha-channel` (подложка белым),
|
||||
`set-alpha-channel` (константная прозрачность), `extract-alpha-mask` (альфа в
|
||||
чёрно-белую маску), `add-padding` (отступ + цвет/прозрачность), `add-border`
|
||||
(толщина + цвет), `round-corners` (радиус), `fit-on-background` (вписать на
|
||||
полотно цвета с полями).
|
||||
|
||||
**Композиция — 2**
|
||||
`tile` (повтор по сетке), `center-by-alpha` (центрирование по непрозрачному содержимому).
|
||||
**Композиция — 2** `tile` (повтор по сетке), `center-by-alpha` (центрирование по
|
||||
непрозрачному содержимому).
|
||||
|
||||
**Генераторы — 4**
|
||||
`create-empty` (размер + цвет или прозрачность), `single-color`, `random-noise` (с фиксированным зерном для воспроизводимости), `linear-gradient` (два цвета, направление).
|
||||
**Генераторы — 4** `create-empty` (размер + цвет или прозрачность),
|
||||
`single-color`, `random-noise` (с фиксированным зерном для воспроизводимости),
|
||||
`linear-gradient` (два цвета, направление).
|
||||
|
||||
**Анализ — 3**
|
||||
`is-grayscale`, `is-transparent`, `orientation` (портрет/ландшафт/квадрат) — текстовые сводки в info-режиме.
|
||||
**Анализ — 3** `is-grayscale`, `is-transparent`, `orientation`
|
||||
(портрет/ландшафт/квадрат) — текстовые сводки в info-режиме.
|
||||
|
||||
## 4. Новые операции ядра (все — чистые функции с тестами)
|
||||
|
||||
- Холст: расширение полотна (общая база для padding/border/fit), тайлинг, центр по bounding-box альфы, скругление углов маской радиуса.
|
||||
- Цвет: сепия, сдвиг тона через HSL, множитель альфы, извлечение/перестановка каналов, пороги (яркость и двухцветный), постеризация уровней, инверсия альфы.
|
||||
- Холст: расширение полотна (общая база для padding/border/fit), тайлинг, центр
|
||||
по bounding-box альфы, скругление углов маской радиуса.
|
||||
- Цвет: сепия, сдвиг тона через HSL, множитель альфы, извлечение/перестановка
|
||||
каналов, пороги (яркость и двухцветный), постеризация уровней, инверсия альфы.
|
||||
- Генерация: заливка цветом, шум с зерном, линейный градиент.
|
||||
- Форматы: BMP-энкодер; кодеки текстовых представлений (base64/data-uri/hex в обе стороны).
|
||||
- Форматы: BMP-энкодер; кодеки текстовых представлений (base64/data-uri/hex в
|
||||
обе стороны).
|
||||
- Анализ: проверка монохромности, полной непрозрачности, ориентации.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Механизмы** — генераторный режим страницы, текстовый ввод/вывод, BMP-энкодер. Проверка: `test/check/build`.
|
||||
- **B. Конвертация и текстовые представления** — 12 инструментов. Проверка: ручной прогон каждой конвертации туда-обратно.
|
||||
- **A. Механизмы** — генераторный режим страницы, текстовый ввод/вывод,
|
||||
BMP-энкодер. Проверка: `test/check/build`.
|
||||
- **B. Конвертация и текстовые представления** — 12 инструментов. Проверка:
|
||||
ручной прогон каждой конвертации туда-обратно.
|
||||
- **C. Цвет** — 9 инструментов. Проверка: юнит-тесты операций + визуально.
|
||||
- **D. Прозрачность, холст, композиция** — 9 инструментов. Проверка: юнит-тесты + визуально.
|
||||
- **E. Генераторы и анализ** — 7 инструментов. Проверка: детерминизм шума (одно зерно — одно изображение), своды анализа.
|
||||
- **F. Итог** — полный прогон, смоук всех новых инструментов «настроить → скачать», регресс старых одиннадцати.
|
||||
- **D. Прозрачность, холст, композиция** — 9 инструментов. Проверка:
|
||||
юнит-тесты + визуально.
|
||||
- **E. Генераторы и анализ** — 7 инструментов. Проверка: детерминизм шума (одно
|
||||
зерно — одно изображение), своды анализа.
|
||||
- **F. Итог** — полный прогон, смоук всех новых инструментов «настроить →
|
||||
скачать», регресс старых одиннадцати.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Каталог содержит ~48 рабочих инструментов, каждый доступен страницей с формы из реестра.
|
||||
- Все новые операции ядра покрыты тестами; обратимые конвертации проверены круговым прогоном (например PNG → base64 → PNG даёт те же пиксели).
|
||||
- Генераторы работают без загрузки файла; текстовые представления копируются и скачиваются.
|
||||
- Каталог содержит ~48 рабочих инструментов, каждый доступен страницей с формы
|
||||
из реестра.
|
||||
- Все новые операции ядра покрыты тестами; обратимые конвертации проверены
|
||||
круговым прогоном (например PNG → base64 → PNG даёт те же пиксели).
|
||||
- Генераторы работают без загрузки файла; текстовые представления копируются и
|
||||
скачиваются.
|
||||
- `test/check/build` зелёные.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
- SVG-вход, рисование текста и водяных знаков, эффекты через фильтры контекста, GIF/APNG-энкодинг, пакетная обработка — последующие волны.
|
||||
- Семейства logo/icon/signature/stamp как отдельные страницы — они будут конфигурациями тех же операций после этой волны.
|
||||
- SVG-вход, рисование текста и водяных знаков, эффекты через фильтры контекста,
|
||||
GIF/APNG-энкодинг, пакетная обработка — последующие волны.
|
||||
- Семейства logo/icon/signature/stamp как отдельные страницы — они будут
|
||||
конфигурациями тех же операций после этой волны.
|
||||
|
||||
@@ -4,41 +4,69 @@
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Одиннадцать новых инструментов: свободные трансформации формы (наклон, поворот на произвольный угол, зум, сдвиг), светокоррекция (гамма, автоконтраст, температура, тонирование), виньетка, эффект «артефактов сжатия» и вход SVG. Каталог вырастает с 58 до ~69.
|
||||
Одиннадцать новых инструментов: свободные трансформации формы (наклон, поворот
|
||||
на произвольный угол, зум, сдвиг), светокоррекция (гамма, автоконтраст,
|
||||
температура, тонирование), виньетка, эффект «артефактов сжатия» и вход SVG.
|
||||
Каталог вырастает с 58 до ~69.
|
||||
|
||||
Текстовые инструменты и водяные знаки в волну не входят — им нужен рендер текста на канвасе, это отдельная четвёртая волна.
|
||||
Текстовые инструменты и водяные знаки в волну не входят — им нужен рендер текста
|
||||
на канвасе, это отдельная четвёртая волна.
|
||||
|
||||
## 2. Новое ядро
|
||||
|
||||
- **Аффинные трансформации** (`core/affine.ts`): единая функция обратного отображения — для каждого пикселя результата вычисляется исходная координата матрицей и семплируется билинейно (общий семплер, вынесенный из `resize`). Поверх неё: наклон по осям, поворот на произвольный угол (с расширением холста под новые габариты), зум к центру, сдвиг.
|
||||
- **Свет и цвет** (`core/color.ts` дополнения): гамма-коррекция через LUT, автоконтраст (растяжка по гистограмме яркости с отсечкой хвостов), температура (множители тёплых/холодных каналов), тонирование (умножение RGB на цвет с сохранением яркостного баланса).
|
||||
- **Виньетка** (`core/effects.ts`): радиальное затемнение от центра, сила слайдером; чистая математика без canvas.
|
||||
- **IO-расширения** (`core/io.ts`): декодирование SVG из текста/файла через blob-URL и `Image`; раундтрип «закодировать JPEG с качеством Q → декодировать обратно» для эффекта артефактов сжатия.
|
||||
- **Аффинные трансформации** (`core/affine.ts`): единая функция обратного
|
||||
отображения — для каждого пикселя результата вычисляется исходная координата
|
||||
матрицей и семплируется билинейно (общий семплер, вынесенный из `resize`).
|
||||
Поверх неё: наклон по осям, поворот на произвольный угол (с расширением холста
|
||||
под новые габариты), зум к центру, сдвиг.
|
||||
- **Свет и цвет** (`core/color.ts` дополнения): гамма-коррекция через LUT,
|
||||
автоконтраст (растяжка по гистограмме яркости с отсечкой хвостов), температура
|
||||
(множители тёплых/холодных каналов), тонирование (умножение RGB на цвет с
|
||||
сохранением яркостного баланса).
|
||||
- **Виньетка** (`core/effects.ts`): радиальное затемнение от центра, сила
|
||||
слайдером; чистая математика без canvas.
|
||||
- **IO-расширения** (`core/io.ts`): декодирование SVG из текста/файла через
|
||||
blob-URL и `Image`; раундтрип «закодировать JPEG с качеством Q → декодировать
|
||||
обратно» для эффекта артефактов сжатия.
|
||||
|
||||
## 3. Инструменты — 11
|
||||
|
||||
- **Геометрия:** `skew-png` (углы X/Y), `rotate-free-png` (любой угол, холст расширяется), `zoom-png` (масштаб ≥100 % к центру), `shift-png` (сдвиг со смещением содержимого).
|
||||
- **Фильтры:** `vignette-png` (сила затемнения), `jpeg-artifacts-png` (имитация пережатия).
|
||||
- **Цвет:** `gamma-png` (0.1–3.0), `auto-contrast-png` (без параметров), `temperature-png` (−100 тёплый…+100 холодный или наоборот — подпись честная), `tint-png` (цвет + сила).
|
||||
- **Геометрия:** `skew-png` (углы X/Y), `rotate-free-png` (любой угол, холст
|
||||
расширяется), `zoom-png` (масштаб ≥100 % к центру), `shift-png` (сдвиг со
|
||||
смещением содержимого).
|
||||
- **Фильтры:** `vignette-png` (сила затемнения), `jpeg-artifacts-png` (имитация
|
||||
пережатия).
|
||||
- **Цвет:** `gamma-png` (0.1–3.0), `auto-contrast-png` (без параметров),
|
||||
`temperature-png` (−100 тёплый…+100 холодный или наоборот — подпись честная),
|
||||
`tint-png` (цвет + сила).
|
||||
- **Конвертация:** `svg-to-png` (текст SVG слева, как base64-режим).
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — семплер, аффинный модуль, свет/цвет, эффекты + точные тесты: поворот квадрата на 90° совпадает с существующим `rotate90`, зум ×2 удваивает размеры и центр остаётся центром, гамма 1 — идентичность, автоконтраст растягивает известный диапазон, виньетка симметрична и не трогает центр при нулевой силе. Проверка: `pnpm test/check`.
|
||||
- **B. IO** — SVG-декод и JPEG-раундтрип. Проверка: сборка; живой прогон на этапе D.
|
||||
- **C. Реестр** — одиннадцать записей, иконки, тесты целостности. Проверка: `test/build`, страницы в экспорте.
|
||||
- **A. Ядро** — семплер, аффинный модуль, свет/цвет, эффекты + точные тесты:
|
||||
поворот квадрата на 90° совпадает с существующим `rotate90`, зум ×2 удваивает
|
||||
размеры и центр остаётся центром, гамма 1 — идентичность, автоконтраст
|
||||
растягивает известный диапазон, виньетка симметрична и не трогает центр при
|
||||
нулевой силе. Проверка: `pnpm test/check`.
|
||||
- **B. IO** — SVG-декод и JPEG-раундтрип. Проверка: сборка; живой прогон на
|
||||
этапе D.
|
||||
- **C. Реестр** — одиннадцать записей, иконки, тесты целостности. Проверка:
|
||||
`test/build`, страницы в экспорте.
|
||||
- **D. Итог** — полный прогон, смоук §7.
|
||||
|
||||
## 5. Смоук-чеклист
|
||||
|
||||
1. Наклон X/Y по отдельности и вместе — форма искажается предсказуемо, без чёрных полос при малых углах.
|
||||
1. Наклон X/Y по отдельности и вместе — форма искажается предсказуемо, без
|
||||
чёрных полос при малых углах.
|
||||
2. Поворот на 30° — холст вырос, углы прозрачны, содержимое повернуто.
|
||||
3. Зум 200 % — содержимое увеличено к центру, холст прежнего размера, края обрезаны.
|
||||
3. Зум 200 % — содержимое увеличено к центру, холст прежнего размера, края
|
||||
обрезаны.
|
||||
4. Сдвиг уводит содержимое за край, противоположный край заполняется фоном.
|
||||
5. Гамма 0.5 темнее, 2.0 светлее, 1.0 — без изменений.
|
||||
6. Автоконтраст на тусклом фото вытягивает диапазон.
|
||||
7. Температура ±50 заметно теплеет/холодеет.
|
||||
8. Тонирование красным даёт равномерный оттенок, белые области окрашиваются сильнее всего.
|
||||
8. Тонирование красным даёт равномерный оттенок, белые области окрашиваются
|
||||
сильнее всего.
|
||||
9. Виньетка затемняет только края, центр не трогает.
|
||||
10. Артефакты: качество 10 даёт видимые квадраты JPEG.
|
||||
11. SVG вставленный слева декодируется в результат справа; битый SVG — баннер.
|
||||
@@ -46,8 +74,10 @@
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Одиннадцать инструментов работают и покрыты юнит-тестами ядра (кроме IO-дом-части).
|
||||
- Аффинные операции опираются на общий билинейный семплер; дублирования сэмплинга нет.
|
||||
- Одиннадцать инструментов работают и покрыты юнит-тестами ядра (кроме
|
||||
IO-дом-части).
|
||||
- Аффинные операции опираются на общий билинейный семплер; дублирования
|
||||
сэмплинга нет.
|
||||
- Каталог ~69 инструментов; `test/check/build` зелёные, смоук §5 пройден.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
# План: закрытие EASY/MEDIUM-пробелов из сравнения с onlinepngtools
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН (2026-09-07) — устарел.** Все волны W1–W9 закрыты. Идеи из
|
||||
> раздела «Вне очереди» (region-инструменты, мультифайловый вывод, анимационные,
|
||||
> HARD-серия) перенесены в `backlog.md`.
|
||||
|
||||
> Статус: черновик на ревью
|
||||
|
||||
## Принципы
|
||||
|
||||
- Волны собираются вокруг общего математического ядра: одна волна = один движок + N инструментов на нём.
|
||||
- Каждая волна оформляется отдельным планом при старте; здесь — очередь и состав.
|
||||
- Порядок: сначала кластеры, дающие много инструментов малой кровью, затем одиночные эффекты, MEDIUM-пакеты в конце.
|
||||
- Полный список параметров каждого инструмента живёт в карте инструментов (tools-map.md); здесь только состав и ядро.
|
||||
- Волны собираются вокруг общего математического ядра: одна волна = один
|
||||
движок + N инструментов на нём.
|
||||
- Каждая волна оформляется отдельным планом при старте; здесь — очередь и
|
||||
состав.
|
||||
- Порядок: сначала кластеры, дающие много инструментов малой кровью, затем
|
||||
одиночные эффекты, MEDIUM-пакеты в конце.
|
||||
- Полный список параметров каждого инструмента живёт в карте инструментов
|
||||
(tools-map.md); здесь только состав и ядро.
|
||||
|
||||
## Очередь волн
|
||||
|
||||
### W1. Палитры — ВЫПОЛНЕНА (11 инструментов)
|
||||
|
||||
Ядро: RGB↔HSL + гармонии цветового круга. Вывод: свотч-полотно (генератор).
|
||||
Состав: color-wheel, complementary, monochromatic, analogous, triadic, tetradic, similar-shades, sort-colors.
|
||||
Плюс утилиты смешения тем же ядром: mix-colors, average-color, blend-two, step-between (+4, итого 12).
|
||||
Состав: color-wheel, complementary, monochromatic, analogous, triadic, tetradic,
|
||||
similar-shades, sort-colors. Плюс утилиты смешения тем же ядром: mix-colors,
|
||||
average-color, blend-two, step-between (+4, итого 12).
|
||||
|
||||
### W2. Каналы и пространства — ВЫПОЛНЕНА (6 инструментов)
|
||||
|
||||
Ядро: матрицы преобразования RGB→(HSL/HSV/HSI/CMYK/YCbCr/LAB) + визуализация выбранного компонента серым или окрашенно.
|
||||
Общий select «компонент» + select «режим отображения».
|
||||
Ядро: матрицы преобразования RGB→(HSL/HSV/HSI/CMYK/YCbCr/LAB) + визуализация
|
||||
выбранного компонента серым или окрашенно. Общий select «компонент» + select
|
||||
«режим отображения».
|
||||
|
||||
### W3. Маски по свойствам пикселей — ВЫПОЛНЕНА (7 инструментов)
|
||||
|
||||
Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом).
|
||||
Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color.
|
||||
Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask,
|
||||
unique-color-mask, extract-by-color.
|
||||
|
||||
### W4. Фигурные маски — ВЫПОЛНЕНА (4 инструмента)
|
||||
|
||||
@@ -35,38 +46,52 @@
|
||||
### W5. Геометрия-добивки — ВЫПОЛНЕНА (5 инструментов)
|
||||
|
||||
Ядро: bbox по альфе (trim) переиспользуется тремя инструментами источника.
|
||||
Состав: trim-empty-space (закрывает remove border/space), change-canvas-size, change-aspect-ratio, landscape↔portrait, symmetric-copy.
|
||||
Состав: trim-empty-space (закрывает remove border/space), change-canvas-size,
|
||||
change-aspect-ratio, landscape↔portrait, symmetric-copy.
|
||||
|
||||
### W6. Эффекты лёгкие — ВЫПОЛНЕНА (6 инструментов; color-blocks покрыт pixelate)
|
||||
|
||||
Состав: pixelate, color-blocks, randomize-pixels (seed), add-noise, feather-edges, clean-edges, silhouette.
|
||||
Shadow/glow — сюда же, если потянет этап: оба = размытая альфа + смещение + цвет (ядро blur уже есть).
|
||||
Состав: pixelate, color-blocks, randomize-pixels (seed), add-noise,
|
||||
feather-edges, clean-edges, silhouette. Shadow/glow — сюда же, если потянет
|
||||
этап: оба = размытая альфа + смещение + цвет (ядро blur уже есть).
|
||||
|
||||
### W7. Конвертеры и генераторы — ВЫПОЛНЕНА (11 инструментов)
|
||||
|
||||
PNG↔bytes, PNG↔rgb-values (текстовые результаты), verify-is-png (анализ сигнатуры),
|
||||
text-to-png (domText без входной картинки), emoji-to-png, placeholder-png,
|
||||
color-spectrum, colorful-random (seed), draw-grid.
|
||||
multi-color-gradient — если успеем новый тип параметра «список цветов», иначе перенос.
|
||||
PNG↔bytes, PNG↔rgb-values (текстовые результаты), verify-is-png (анализ
|
||||
сигнатуры), text-to-png (domText без входной картинки), emoji-to-png,
|
||||
placeholder-png, color-spectrum, colorful-random (seed), draw-grid.
|
||||
multi-color-gradient — если успеем новый тип параметра «список цветов», иначе
|
||||
перенос.
|
||||
|
||||
### W8. Цветовые MEDIUM — ВЫПОЛНЕНА (4 инструмента; median-cut + Floyd–Steinberg/Bayer, custom-palette через text-параметр)
|
||||
|
||||
Ядро: квантование (median-cut или k-means).
|
||||
Состав: quantize (k), decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список цветов — нужен тип параметра «список»), dithering (Floyd–Steinberg/Bayer поверх квантования).
|
||||
Ядро: квантование (median-cut или k-means). Состав: quantize (k),
|
||||
decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список
|
||||
цветов — нужен тип параметра «список»), dithering (Floyd–Steinberg/Bayer поверх
|
||||
квантования).
|
||||
|
||||
### W9. Сжатие — ВЫПОЛНЕНА (3 инструмента; optimize/change-quality осознанно не выделены — покрыты пресетами compress и W8)
|
||||
|
||||
Без wasm честное управление размером PNG ограничено: реальный рычаг — квантование (W8) + обрезка метаданных + итеративный подбор под целевой KB.
|
||||
Состав: compress (пресеты усилий), reduce-to-size (целевой KB, бинарный поиск по k), optimize (re-encode), change-quality/low-quality — переосмыслить как пресеты W8; jpeg-artifacts уже покрывает «испортить».
|
||||
Без wasm честное управление размером PNG ограничено: реальный рычаг —
|
||||
квантование (W8) + обрезка метаданных + итеративный подбор под целевой KB.
|
||||
Состав: compress (пресеты усилий), reduce-to-size (целевой KB, бинарный поиск по
|
||||
k), optimize (re-encode), change-quality/low-quality — переосмыслить как пресеты
|
||||
W8; jpeg-artifacts уже покрывает «испортить».
|
||||
|
||||
## Вне очереди
|
||||
|
||||
- Region-инструменты (censor/erase/pixelate-area/blur-area/sharpen-area/reverse-area) — ждут UI выделения области на превью; отдельное решение.
|
||||
- Мультифайловый вывод (split-parts, gif-frames, separate-colors) — ждут механизм «результат = набор файлов».
|
||||
- Анимационные (slow-reveal/fade/scrolling) — выход не PNG; отдельное решение о формате.
|
||||
- Region-инструменты
|
||||
(censor/erase/pixelate-area/blur-area/sharpen-area/reverse-area) — ждут UI
|
||||
выделения области на превью; отдельное решение.
|
||||
- Мультифайловый вывод (split-parts, gif-frames, separate-colors) — ждут
|
||||
механизм «результат = набор файлов».
|
||||
- Анимационные (slow-reveal/fade/scrolling) — выход не PNG; отдельное решение о
|
||||
формате.
|
||||
- HARD: glitch, barcode, signature-extract, handwritten-digital.
|
||||
- Нишевые серии logo/icon/stamp/signature — не копируем.
|
||||
|
||||
## Оценка масштаба
|
||||
|
||||
W1–W7 дают **~47 новых инструментов**, почти все EASY. W8 добавляет ядро квантования (открывает custom-palette/dithering и улучшает W9). Суммарно каталог достигает ~120+ при текущих 71.
|
||||
W1–W7 дают **~47 новых инструментов**, почти все EASY. W8 добавляет ядро
|
||||
квантования (открывает custom-palette/dithering и улучшает W9). Суммарно каталог
|
||||
достигает ~120+ при текущих 71.
|
||||
@@ -4,43 +4,65 @@
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Выдача поиска на главной превращается из списка строк в сетку карточек: слева иконка инструмента, справа название и описание. Инструменты получают два новых поля реестра — `popularity` (приоритет в выдаче) и `icon` (идентификатор иконки). Иконки переиспользуются в каталоге `/list-tools` и в легендах шагов цепочки. Поиск работает по-прежнему: нечёткий скоринг, клавиатура ↑↓ Enter Esc.
|
||||
Выдача поиска на главной превращается из списка строк в сетку карточек: слева
|
||||
иконка инструмента, справа название и описание. Инструменты получают два новых
|
||||
поля реестра — `popularity` (приоритет в выдаче) и `icon` (идентификатор
|
||||
иконки). Иконки переиспользуются в каталоге `/list-tools` и в легендах шагов
|
||||
цепочки. Поиск работает по-прежнему: нечёткий скоринг, клавиатура ↑↓ Enter Esc.
|
||||
|
||||
## 2. Данные реестра
|
||||
|
||||
- **`popularity?: number`** (0–100, по умолчанию 50) — приоритет внутри равного скоринга запроса. Значения проставляются вручную осознанно: базовые операции (обрезка, размер, удаление фона, конвертации JPG/PNG) — 80–95; цветовые и геометрия — 40–70; узкие и генераторы — 10–30.
|
||||
- **`icon?: string`** — имя иконки из набора Lucide. Явный словарь-маппинг для всех инструментов в одном месте; отсутствие записи даёт нейтральную заглушку (Wrench).
|
||||
- Тест целостности расширяется: популярность в диапазоне, иконка — непустая строка из разрешённого набора.
|
||||
- **`popularity?: number`** (0–100, по умолчанию 50) — приоритет внутри равного
|
||||
скоринга запроса. Значения проставляются вручную осознанно: базовые операции
|
||||
(обрезка, размер, удаление фона, конвертации JPG/PNG) — 80–95; цветовые и
|
||||
геометрия — 40–70; узкие и генераторы — 10–30.
|
||||
- **`icon?: string`** — имя иконки из набора Lucide. Явный словарь-маппинг для
|
||||
всех инструментов в одном месте; отсутствие записи даёт нейтральную заглушку
|
||||
(Wrench).
|
||||
- Тест целостности расширяется: популярность в диапазоне, иконка — непустая
|
||||
строка из разрешённого набора.
|
||||
|
||||
## 3. Иконки
|
||||
|
||||
Зависимость `lucide-svelte` (MIT) — один пакет, иконки рендерятся компонентами, дерево не раздувается за счёт выборочного импорта.
|
||||
Зависимость `lucide-svelte` (MIT) — один пакет, иконки рендерятся компонентами,
|
||||
дерево не раздувается за счёт выборочного импорта.
|
||||
|
||||
Маппинг ведётся одним объектом `tool-icons.ts` рядом с реестром: 51 запись вида `'crop-png': Crop`. Подбор по смыслу: обрезка — Crop, размер — Scaling, поворот — RotateCw, отражение — FlipHorizontal2, оттенок — Palette, размытие — Droplets, резкость — Focus, удаление фона — Scissors, анализ — Info и так далее; для семейств конвертаций — FileInput/FileOutput варианты.
|
||||
Маппинг ведётся одним объектом `tool-icons.ts` рядом с реестром: 51 запись вида
|
||||
`'crop-png': Crop`. Подбор по смыслу: обрезка — Crop, размер — Scaling, поворот
|
||||
— RotateCw, отражение — FlipHorizontal2, оттенок — Palette, размытие — Droplets,
|
||||
резкость — Focus, удаление фона — Scissors, анализ — Info и так далее; для
|
||||
семейств конвертаций — FileInput/FileOutput варианты.
|
||||
|
||||
Иконка появляется в трёх местах: карточка выдачи, карточка каталога `/list-tools`, легенда шага цепочки («Шаг N: [icon] Название»).
|
||||
Иконка появляется в трёх местах: карточка выдачи, карточка каталога
|
||||
`/list-tools`, легенда шага цепочки («Шаг N: [icon] Название»).
|
||||
|
||||
## 4. Карточки выдачи
|
||||
|
||||
- Сетка: одна колонка на узком экране, две — начиная со среднего героя.
|
||||
- Композиция карточки: квадрат иконки слева, справа название (полужирное) и однострочное описание с обрезкой.
|
||||
- Композиция карточки: квадрат иконки слева, справа название (полужирное) и
|
||||
однострочное описание с обрезкой.
|
||||
- Состояния hover и активной с клавиатуры — тот же акцент подсветки, что сейчас.
|
||||
- Сортировка выдачи: скоринг запроса ↓, затем популярность ↓, затем название.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Реестр** — поля `popularity` и `icon`, словарь иконок на все 51, расширение тестов целостности. Проверка: `pnpm test/check`.
|
||||
- **B. Карточки поиска** — сетка выдачи с иконками и учётом популярности, клавиатура без изменений. Проверка: визуально, `check/build`.
|
||||
- **C. Распространение** — иконки в каталоге `/list-tools` и легендах звеньев. Проверка: визуально, `build`.
|
||||
- **A. Реестр** — поля `popularity` и `icon`, словарь иконок на все 51,
|
||||
расширение тестов целостности. Проверка: `pnpm test/check`.
|
||||
- **B. Карточки поиска** — сетка выдачи с иконками и учётом популярности,
|
||||
клавиатура без изменений. Проверка: визуально, `check/build`.
|
||||
- **C. Распространение** — иконки в каталоге `/list-tools` и легендах звеньев.
|
||||
Проверка: визуально, `build`.
|
||||
- **D. Итог** — полный прогон и смоук §8.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Пустой запрос на герое показывает популярные первыми после первого символа.
|
||||
2. Запрос «обрез» поднимает карточку обрезки выше прочих совпадений.
|
||||
3. У каждой карточки есть иконка; у большинства — разные, соответствующие смыслу.
|
||||
3. У каждой карточки есть иконка; у большинства — разные, соответствующие
|
||||
смыслу.
|
||||
4. Наведение и стрелки подсвечивают карточку; Enter открывает инструмент.
|
||||
5. Открытый инструмент работает как раньше: параметры в колонках, результат, скачивание.
|
||||
5. Открытый инструмент работает как раньше: параметры в колонках, результат,
|
||||
скачивание.
|
||||
6. Цепочка: легенда каждого шага показывает иконку выбранного инструмента.
|
||||
7. `/list-tools`: карточки категорий с иконками, ссылки работают.
|
||||
8. Регресс: старые сценарии (загрузка файла, цепочка, скачивание) не изменились.
|
||||
@@ -54,7 +76,8 @@
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Автоматический расчёт популярности из статистики использования — значения ручные.
|
||||
- Автоматический расчёт популярности из статистики использования — значения
|
||||
ручные.
|
||||
- Собственный набор SVG-иконок — берём готовый открытый.
|
||||
- Переводы названий и поиск на других языках — отдельная i18n-волна.
|
||||
- Изображения-превью в карточках поиска — только иконка и текст.
|
||||
|
||||
+76
-22
@@ -1,52 +1,106 @@
|
||||
# План: i18n — русский и английский
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский — перевод в словаре; строки инструментов в реестре переведены на английский в рамках этапа C.
|
||||
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.** Базовая локаль — английский (реестр), русский
|
||||
> — перевод в словаре; строки инструментов в реестре переведены на английский в
|
||||
> рамках этапа C.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Сайт работает на двух языках: русский (базовый) и английский. Переключатель в шапке, выбор сохраняется в localStorage, тег `html lang` обновляется на клиенте. Переведено всё: каркас интерфейса, карточки инструментов, цепочки, поиск, тексты ошибок. Поиск находит инструменты по запросу на любом из двух языков независимо от активной локали.
|
||||
Сайт работает на двух языках: русский (базовый) и английский. Переключатель в
|
||||
шапке, выбор сохраняется в localStorage, тег `html lang` обновляется на клиенте.
|
||||
Переведено всё: каркас интерфейса, карточки инструментов, цепочки, поиск, тексты
|
||||
ошибок. Поиск находит инструменты по запросу на любом из двух языков независимо
|
||||
от активной локали.
|
||||
|
||||
Каталог и поведение не меняются: ни новых инструментов, ни изменений логики существующих.
|
||||
Каталог и поведение не меняются: ни новых инструментов, ни изменений логики
|
||||
существующих.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
|
||||
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами (например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в слое исполнителя/UI.
|
||||
- Английский — базовая локаль и фолбэк: строки инструментов в реестре записаны по-английски. Переводы живут в секции tools словарей по ключу id инструмента (title, description, подписи параметров, подписи опций select, тексты-результаты анализаторов); сейчас перевод — русский в ru.ts. Доступ из компонентов только через хелперы toolTitle/toolDescription/paramLabel/optionLabel. Так каждая строка имеет единственный источник: en — реестр, переводы — словари.
|
||||
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на
|
||||
клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц
|
||||
для поисковиков — не в этой волне.
|
||||
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами
|
||||
(например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в
|
||||
слое исполнителя/UI.
|
||||
- Английский — базовая локаль и фолбэк: строки инструментов в реестре записаны
|
||||
по-английски. Переводы живут в секции tools словарей по ключу id инструмента
|
||||
(title, description, подписи параметров, подписи опций select,
|
||||
тексты-результаты анализаторов); сейчас перевод — русский в ru.ts. Доступ из
|
||||
компонентов только через хелперы
|
||||
toolTitle/toolDescription/paramLabel/optionLabel. Так каждая строка имеет
|
||||
единственный источник: en — реестр, переводы — словари.
|
||||
|
||||
## 3. Структура i18n-модуля
|
||||
|
||||
- web/src/lib/i18n/locale.svelte.ts — состояние активной локали (runes), персист в localStorage (ключ locale, guard на SSR), обновление document.documentElement.lang.
|
||||
- web/src/lib/i18n/t.ts — функция перевода с фолбэком на базовый язык и интерполяцией переменных вида {n}.
|
||||
- web/src/lib/i18n/ru.ts и en.ts — словари одного типа Dict; тип экспортируется из ru.ts, en.ts обязан ему соответствовать — пропущенные ключи ловятся на компиляции.
|
||||
- Секции словаря: header, home, catalog, toolPage, chain, sourceCard, resultCard, paramsCard, textInput, textResult, download, infoPanel, dropZone, search, ui (aria кнопок слайдера и пипетки), categories, errors, tools.
|
||||
- web/src/lib/i18n/locale.svelte.ts — состояние активной локали (runes), персист
|
||||
в localStorage (ключ locale, guard на SSR), обновление
|
||||
document.documentElement.lang.
|
||||
- web/src/lib/i18n/t.ts — функция перевода с фолбэком на базовый язык и
|
||||
интерполяцией переменных вида {n}.
|
||||
- web/src/lib/i18n/ru.ts и en.ts — словари одного типа Dict; тип экспортируется
|
||||
из ru.ts, en.ts обязан ему соответствовать — пропущенные ключи ловятся на
|
||||
компиляции.
|
||||
- Секции словаря: header, home, catalog, toolPage, chain, sourceCard,
|
||||
resultCard, paramsCard, textInput, textResult, download, infoPanel, dropZone,
|
||||
search, ui (aria кнопок слайдера и пипетки), categories, errors, tools.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
||||
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в словари: layout (шапка, футер), главная (герой, «вернуть последний», title), каталог, карточки источника/результата/параметров, текстовый вход и результат, «Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText), InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU, ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой результат поиска, страницы инструмента (title, 404). Обёрточные сообщения исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
||||
- C. Контент реестра — переводы 69 инструментов (title/description/labels/options) в tools-секции словаря en; компоненты получают строки через хелперы toolTitle/toolDescription/paramLabel/optionLabel с фолбэком на реестр. Тест целостности: у каждого инструмента есть непустые перевод title/description, полный набор param-ключей и подписи всех опций select; лишние ключи словарей отсутствуют.
|
||||
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е, снятие диакритики через NFD; скоринг против активной и базовой локали сразу, лучший балл побеждает; сортировка совпадений через Intl.Collator активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
|
||||
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
|
||||
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и
|
||||
интерполяцией, словари-скелеты со всеми секциями (заполнены header и
|
||||
categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты:
|
||||
фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
||||
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в
|
||||
словари: layout (шапка, футер), главная (герой, «вернуть последний», title),
|
||||
каталог, карточки источника/результата/параметров, текстовый вход и результат,
|
||||
«Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText),
|
||||
InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU,
|
||||
ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой
|
||||
результат поиска, страницы инструмента (title, 404). Обёрточные сообщения
|
||||
исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
||||
- C. Контент реестра — переводы 69 инструментов
|
||||
(title/description/labels/options) в tools-секции словаря en; компоненты
|
||||
получают строки через хелперы toolTitle/toolDescription/paramLabel/optionLabel
|
||||
с фолбэком на реестр. Тест целостности: у каждого инструмента есть непустые
|
||||
перевод title/description, полный набор param-ключей и подписи всех опций
|
||||
select; лишние ключи словарей отсутствуют.
|
||||
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е,
|
||||
снятие диакритики через NFD; скоринг против активной и базовой локали сразу,
|
||||
лучший балл побеждает; сортировка совпадений через Intl.Collator активной
|
||||
локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской
|
||||
локали и наоборот.
|
||||
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются
|
||||
на коды; исполнитель переводит код в текст активной локали, неизвестные
|
||||
сообщения показываются как есть. Пять тестов, матчащих русские подстроки
|
||||
ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это
|
||||
упрощает ассерты. Русские describe/it в тестах не трогаем.
|
||||
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
|
||||
|
||||
## 5. Смоук-чеклист
|
||||
|
||||
1. Переключение RU/EN в шапке мгновенно меняет язык без перезагрузки; после перезагрузки выбор сохранён.
|
||||
1. Переключение RU/EN в шапке мгновенно меняет язык без перезагрузки; после
|
||||
перезагрузки выбор сохранён.
|
||||
2. Тег html lang соответствует активной локали.
|
||||
3. Главная, каталог, страница инструмента, цепочка из трёх звеньев — везде переведено, без смеси языков.
|
||||
4. Форма параметров: подписи полей и опции select переведены у произвольных пяти инструментов из разных категорий.
|
||||
5. Битый вход (некорректный HEX в тонировании, нулевой размер) показывает ошибку на активном языке.
|
||||
6. Поиск: «повер» находит повороты при русской локали; «rotate» находит их же; при английской локали наоборот. Диакритика и ё не мешают совпадению.
|
||||
3. Главная, каталог, страница инструмента, цепочка из трёх звеньев — везде
|
||||
переведено, без смеси языков.
|
||||
4. Форма параметров: подписи полей и опции select переведены у произвольных пяти
|
||||
инструментов из разных категорий.
|
||||
5. Битый вход (некорректный HEX в тонировании, нулевой размер) показывает ошибку
|
||||
на активном языке.
|
||||
6. Поиск: «повер» находит повороты при русской локали; «rotate» находит их же;
|
||||
при английской локали наоборот. Диакритика и ё не мешают совпадению.
|
||||
7. Числа в панели информации отформатированы по локали.
|
||||
8. Скачивание, пипетка, маски, слайдеры работают одинаково на обеих локалях — функциональность не задета.
|
||||
8. Скачивание, пипетка, маски, слайдеры работают одинаково на обеих локалях —
|
||||
функциональность не задета.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Оба словаря типобезопасны и полны; тест целостности секции tools зелёный.
|
||||
- Ни одного пользовательского русского строки вне словарей (кроме тестовых describe/it).
|
||||
- Ни одного пользовательского русского строки вне словарей (кроме тестовых
|
||||
describe/it).
|
||||
- test/check/build зелёные, смоук пройден.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# План: параметры между исходником и результатом
|
||||
|
||||
> **СТАТУС: УСТАРЕЛ (2026-09-07).** Раскладка описывалась для старого
|
||||
> `ToolPage`; перекрыта редизайном (`plan-redesign.md`) — страницы инструментов
|
||||
> теперь `SchemaToolView`/`SchemaPreview` в новом дизайне. Сама идея «инлайн:
|
||||
> исходник → параметры → результат» живёт в `backlog.md` (№2).
|
||||
|
||||
> Статус: план к выполнению.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Альтернативная раскладка этапа инструмента: три колонки «Исходник | Параметры |
|
||||
Результат» на широких экранах, вертикальный стек на узких. Цель — отдать вариант
|
||||
дизайнеру как отправную точку. Откат к текущей раскладке — замена одной строки в
|
||||
`ToolPage`.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Два взаимозаменяемых компонента этапа с одинаковым набором пропсов; `ToolPage`
|
||||
рендерит один тег. Попробовать новое — поменять компонент в этом теге; вернуть
|
||||
старое — поменять обратно. Никаких флагов и конфигов.
|
||||
- Общая типизация пропсов в одном файле рядом с компонентами; сами карточки
|
||||
(`SourceCard`, `ResultCard`, `TextInputCard`, `ParamsCard`) переиспользуются
|
||||
как есть — вся логика исполнения, маски, пипетки и автозапуска остаётся в
|
||||
`ToolPage` и приходит сверху.
|
||||
- Chain-звенья используют ту же панельную систему «Вход | Параметры | Результат»
|
||||
— общие классы `.pane` / `.pane-legend` / `.pane-params` вынесены в app.css,
|
||||
вертикальные поля в узкой колонке заданы один раз глобально.
|
||||
- Один порог отзывчивости: три колонки от ~75rem, ниже — стек «исходник →
|
||||
параметры → результат». Промежуточные перестроения не придумываем до фидбека
|
||||
дизайнера.
|
||||
|
||||
## 3. Компоненты
|
||||
|
||||
```txt
|
||||
src/lib/components/stage/
|
||||
stage-props.ts # интерфейс StageProps + сборщик дефолтов
|
||||
ToolStageClassic.svelte # текущая разметка .tool-stage, перенесена 1:1 из ToolPage
|
||||
ToolStageInline.svelte # новая сетка [source | params | result]
|
||||
```
|
||||
|
||||
- `ToolStageClassic` — вырезка существующего шаблона со стилями; после
|
||||
извлечения страница должна быть пиксель-в-пиксель прежней.
|
||||
- `ToolStageInline` — grid-колонки
|
||||
`minmax(0, 1fr) | фикс-ширина параметров | minmax(0, 1fr)`; легенда
|
||||
«Параметры» над средней колонкой в том же стиле edge-legend.
|
||||
- Модификаторы состояний через классы на корне компонента: `no-params`
|
||||
(инструменты без параметров → две колонки), `no-source` (генераторы →
|
||||
параметры и результат), текстовый вход до декодирования занимает левую ячейку
|
||||
как сейчас.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Извлечение классики** — перенос разметки и стилей этапа из `ToolPage` в
|
||||
`ToolStageClassic` без изменений поведения; пропсы собираются в одном объекте.
|
||||
Критерий: страница неотличима, diff по логике `ToolPage` нулевой.
|
||||
- **B. Инлайн-раскладка** — `ToolStageInline`, переключение в `ToolPage` на
|
||||
новый компонент, прогон смоука §5. Ревью в браузере; решение дизайнера —
|
||||
потом, код уже позволяет жить в обоих вариантах.
|
||||
|
||||
## 5. Смоук
|
||||
|
||||
1. Замена компонента этапа — одна строка; обе версии собираются без
|
||||
предупреждений.
|
||||
2. Инструмент без параметров (например Автоконтраст): две колонки, пустой
|
||||
середины нет.
|
||||
3. Генератор без исходника: параметры и результат в две колонки.
|
||||
4. Текстовый инструмент до декодирования: вход слева, параметры в центре, пустое
|
||||
состояние результата справа.
|
||||
5. Маска, пипетка, слайдеры в средней колонке; изменение параметра перезапускает
|
||||
инструмент как раньше.
|
||||
6. Узкий экран: стек без горизонтального скролла, порядок исходник → параметры →
|
||||
результат.
|
||||
7. Легенды присутствуют у всех трёх зон в обеих раскладках.
|
||||
|
||||
## 6. Что сознательно не делаем
|
||||
|
||||
- Редизайн визуального языка (цвета, типографика, формы) — материал для
|
||||
дизайнера, не для этого плана.
|
||||
- Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает
|
||||
про исполнение.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Plan: `/preview/list-tools` → design2 (catalog)
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН (2026-09-07) — устарел.** Каталог `/preview/list-tools`
|
||||
> переписан на `CatalogHeader` / `CatalogToolbar` / `CatalogGroup` / `ToolCard`
|
||||
> (kit), поиск и фильтры функциональны. Доводка верности против рефа остаётся в
|
||||
> рамках `plan-design-fix.md`.
|
||||
|
||||
Ветка `feat/redesign`. Цель — привести каталог инструментов к рефу
|
||||
`refs-html/list-tools.html`. Сейчас страница вообще не на design2: по
|
||||
мульти-аудиту `onlyOurs=257, onlyRef=54` (демо-страница — `18/11`).
|
||||
@@ -11,19 +16,19 @@
|
||||
`ToolCard` (kit). Нет поиска, фильтров, счётчика, навигация по категориям
|
||||
сделана через `Panel`-блоки, а не через `.catalog-groups`.
|
||||
- Реф (`list-tools.html`) строит:
|
||||
- `.catalog-page` → `.catalog-head` (`.eyebrow`, `h1` «Tool catalog»,
|
||||
`p` lede, `.catalog-total` «NN TOOLS AVAILABLE» с большим числом),
|
||||
- `.catalog-page` → `.catalog-head` (`.eyebrow`, `h1` «Tool catalog», `p`
|
||||
lede, `.catalog-total` «NN TOOLS AVAILABLE» с большим числом),
|
||||
- `.catalog-toolbar` (`.catalog-search` + `.catalog-filters` с кнопками),
|
||||
- `.catalog-groups` (grid `repeat(2,minmax(0,1fr))`, gap `56px 28px`) →
|
||||
`.catalog-group` (`.group-title` «CAT · NN TOOLS») → `.tool-cards` →
|
||||
`.tool-card`.
|
||||
- `.tool-card` (реф) — `display:grid`, колонки `42px minmax(0,1fr) 24px 18px`,
|
||||
`min-height:106px`, `gap:14px`, `padding:16px`: `.tool-icon` (42×42
|
||||
синий размытый квадрат), `.tool-copy` (`strong` 13px mono + `span` 12px mono
|
||||
muted), `.tool-index` (10px mono, сверху), `.tool-arrow` (появляется при hover).
|
||||
`min-height:106px`, `gap:14px`, `padding:16px`: `.tool-icon` (42×42 синий
|
||||
размытый квадрат), `.tool-copy` (`strong` 13px mono + `span` 12px mono muted),
|
||||
`.tool-index` (10px mono, сверху), `.tool-arrow` (появляется при hover).
|
||||
- Токены `design2.css` уже совпадают с рефом (`--background #eef1f4`,
|
||||
`--panel #f8fafb`, `--foreground #17212b`, `--muted #6d7883`, `--line #cbd3da`,
|
||||
`--blue #1769d2`, `--radius 4px`) — править токены не нужно.
|
||||
`--panel #f8fafb`, `--foreground #17212b`, `--muted #6d7883`,
|
||||
`--line #cbd3da`, `--blue #1769d2`, `--radius 4px`) — править токены не нужно.
|
||||
|
||||
## Шаги
|
||||
|
||||
@@ -32,20 +37,21 @@
|
||||
- grid-колонки `42px minmax(0,1fr) 24px 18px`, `align-items:center`,
|
||||
`gap:14px`, `min-height:106px`, `padding:16px`, `border:1px solid line`,
|
||||
`background:panel`, `border-radius:radius`.
|
||||
- `.tool-icon`: 42×42 `place-items:center`, `background:color-mix(in srgb,
|
||||
var(--blue) 12%, transparent)`, `color:var(--blue)` (реф использует
|
||||
`--panel-strong`; вместо нового токена берём этот `color-mix`).
|
||||
- `.tool-icon`: 42×42 `place-items:center`,
|
||||
`background:color-mix(in srgb, var(--blue) 12%, transparent)`,
|
||||
`color:var(--blue)` (реф использует `--panel-strong`; вместо нового токена
|
||||
берём этот `color-mix`).
|
||||
- `.tool-copy strong`: `600 13px var(--font-mono)`; `span`: `12px/1.5 mono`
|
||||
`muted`.
|
||||
- `.tool-index`: `10px mono muted`, `align-self:start`.
|
||||
- `.tool-arrow`: `color:var(--blue)`, `opacity:0` → `1` + `translate(2px,-2px)`
|
||||
на `.tool-card:hover`.
|
||||
- `.tool-arrow`: `color:var(--blue)`, `opacity:0` → `1` +
|
||||
`translate(2px,-2px)` на `.tool-card:hover`.
|
||||
- DOM-порядок как в рефе: `icon, copy, index, arrow`.
|
||||
- `href` через `resolve` из `$app/paths` (убрать `svelte-ignore`).
|
||||
|
||||
2. **`kit/CatalogHeader.svelte`**: `.eyebrow` «EASY-PNG-TOOLS / CATALOG»,
|
||||
`h1` «Tool catalog», lede; `.catalog-total` с динамическим `TOOLS.length`
|
||||
(реф показывает `32`).
|
||||
2. **`kit/CatalogHeader.svelte`**: `.eyebrow` «EASY-PNG-TOOLS / CATALOG», `h1`
|
||||
«Tool catalog», lede; `.catalog-total` с динамическим `TOOLS.length` (реф
|
||||
показывает `32`).
|
||||
|
||||
3. **`kit/CatalogToolbar.svelte`**: `.catalog-search` (иконка + `<input>`) и
|
||||
`.catalog-filters` (кнопки по категориям). Состояние `query` / `category`
|
||||
@@ -63,8 +69,8 @@
|
||||
- `<CatalogToolbar bind:query bind:category />`;
|
||||
- `.catalog-groups` из отфильтрованных групп (по `CATEGORIES`, пересчитав
|
||||
`tools` с учётом `query`/`category`);
|
||||
- внутри `<CatalogGroup>` — `<ToolCard>` на каждый tool (`href=
|
||||
"/preview/tools/{id}"`, `icon` из `tool-icons`, `index` по порядку,
|
||||
- внутри `<CatalogGroup>` — `<ToolCard>` на каждый tool
|
||||
(`href= "/preview/tools/{id}"`, `icon` из `tool-icons`, `index` по порядку,
|
||||
`title`/`description` из registry);
|
||||
- убрать `Panel`/`PanelHeading` из этого маршрута.
|
||||
|
||||
@@ -72,9 +78,9 @@
|
||||
TRANSPARENCY / COLOR / GEOMETRY / FILTERS / ANALYZE). Наши `CATEGORIES`
|
||||
(`convert/alpha/color/geometry/filters/text/analyze/generate`) — сопоставить
|
||||
(`alpha`→TRANSPARENCY и т.п.); точное сопоставление уточнить по рефу и
|
||||
`i18n/ru.ts`. Фильтры = ALL + те же label'ы. Тексты заголовков/lede — по
|
||||
рефу (англ. «Tool catalog» / «Focused utilities for working with PNG…»),
|
||||
либо оставить RU-вариант, если это сознательное отклонение (зафиксировать).
|
||||
`i18n/ru.ts`. Фильтры = ALL + те же label'ы. Тексты заголовков/lede — по рефу
|
||||
(англ. «Tool catalog» / «Focused utilities for working with PNG…»), либо
|
||||
оставить RU-вариант, если это сознательное отклонение (зафиксировать).
|
||||
|
||||
7. **Адаптив**: перенести правила рефа (`@media max-width:800px`) —
|
||||
`.catalog-groups` → 1fr, `.tool-card` → `38px minmax(0,1fr) 18px`, скрыть
|
||||
@@ -82,16 +88,16 @@
|
||||
|
||||
## Верификация
|
||||
|
||||
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`,
|
||||
`pnpm build` — без ошибок.
|
||||
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`, `pnpm build`
|
||||
— без ошибок.
|
||||
- `pnpm refs-audit` (теперь мульти-маршрутный) → глянуть секцию
|
||||
`/preview/list-tools`: `onlyOurs`/`onlyRef` должны упасть с `257/54`
|
||||
примерно до уровня demo (`18/11`). Остаточный diff (если будет) зафиксировать
|
||||
в этом файле.
|
||||
`/preview/list-tools`: `onlyOurs`/`onlyRef` должны упасть с `257/54` примерно
|
||||
до уровня demo (`18/11`). Остаточный diff (если будет) зафиксировать в этом
|
||||
файле.
|
||||
|
||||
## Примечания
|
||||
|
||||
- `ToolCard` правится на месте — влияет на `preview/+page` и `preview/kit`
|
||||
(там тоже карточки каталога, унификация уместна).
|
||||
- `ToolCard` правится на месте — влияет на `preview/+page` и `preview/kit` (там
|
||||
тоже карточки каталога, унификация уместна).
|
||||
- Старый дизайн (`routes/(old)/**`, `components/search/**`) не трогать.
|
||||
- Коммиты делает автор (агент не коммитит).
|
||||
@@ -4,15 +4,25 @@
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
- Первые операции настоящего алгоритмического уровня: свёртки и заливка связной области.
|
||||
- Витринная фича каталога — удаление однотонного фона без нейросетей: порог по цвету + заливка от краёв изображения + сглаживание границы маски.
|
||||
- Эти операции реально блокируют UI-поток на больших картинках — повод включить давно подготовленный путь: исполнитель `executeStep` переезжает в Web Worker, страницы не меняются вовсе.
|
||||
- Первые операции настоящего алгоритмического уровня: свёртки и заливка связной
|
||||
области.
|
||||
- Витринная фича каталога — удаление однотонного фона без нейросетей: порог по
|
||||
цвету + заливка от краёв изображения + сглаживание границы маски.
|
||||
- Эти операции реально блокируют UI-поток на больших картинках — повод включить
|
||||
давно подготовленный путь: исполнитель `executeStep` переезжает в Web Worker,
|
||||
страницы не меняются вовсе.
|
||||
|
||||
## 2. Инструменты — 3
|
||||
|
||||
- **Удалить фон PNG** (`remove-background-png`, категория «Прозрачность») — параметры: цвет фона, допуск похожести %, режим «только внешние области» (заливка от краёв вместо глобального совпадения), сглаживание границы (проходы мажоритарного фильтра маски). Даёт превью маски через существующий контракт `preview`.
|
||||
- **Размытие PNG** (`blur-png`, новая категория «Фильтры») — гауссово приближение тремя проходами разделяемого бокса; радиус слайдером 1–32 px.
|
||||
- **Резкость PNG** (`sharpen-png`, «Фильтры») — свёртка ядром резкости с регулируемой силой (смесь исходного и обработанного), сила слайдером 0–100 %.
|
||||
- **Удалить фон PNG** (`remove-background-png`, категория «Прозрачность») —
|
||||
параметры: цвет фона, допуск похожести %, режим «только внешние области»
|
||||
(заливка от краёв вместо глобального совпадения), сглаживание границы (проходы
|
||||
мажоритарного фильтра маски). Даёт превью маски через существующий контракт
|
||||
`preview`.
|
||||
- **Размытие PNG** (`blur-png`, новая категория «Фильтры») — гауссово
|
||||
приближение тремя проходами разделяемого бокса; радиус слайдером 1–32 px.
|
||||
- **Резкость PNG** (`sharpen-png`, «Фильтры») — свёртка ядром резкости с
|
||||
регулируемой силой (смесь исходного и обработанного), сила слайдером 0–100 %.
|
||||
|
||||
## 3. Новое ядро (чистые функции, детерминированные, покрытые тестами)
|
||||
|
||||
@@ -21,46 +31,67 @@
|
||||
- разделяемый бокс-блюр и сборка гауссова приближения из трёх проходов;
|
||||
- ядро резкости и смешивание с оригиналом по силе.
|
||||
- `core/background.ts`
|
||||
- построение маски удаления по цветовому расстоянию (та же метрика, что у удаления цвета);
|
||||
- построение маски удаления по цветовому расстоянию (та же метрика, что у
|
||||
удаления цвета);
|
||||
- заливка связной области от всех граничных пикселей (очередь, без рекурсии);
|
||||
- мажоритарное сглаживание маски заданным числом проходов;
|
||||
- сборка результата: альфа 0 у удалённых пикселей; плюс функция маски для превью.
|
||||
- сборка результата: альфа 0 у удалённых пикселей; плюс функция маски для
|
||||
превью.
|
||||
|
||||
## 4. Web Worker
|
||||
|
||||
- Точка исполнения одна — `tools/executor.ts`; меняется только её внутренность.
|
||||
- Воркер-файл импортирует реестр и исполняет операцию по `toolId`; картинка приходит копией (структурный клон), результат возвращается передаваемым буфером.
|
||||
- Ленивая инициализация воркера при первом вызове; если воркер недоступен (старый браузер/тестовая среда) — автоматический откат на прямое исполнение в главном потоке. Обе ветви обязаны давать побайтово одинаковый результат.
|
||||
- Генераторы и текстовые представления остаются в главном потоке — они мгновенные.
|
||||
- Воркер-файл импортирует реестр и исполняет операцию по `toolId`; картинка
|
||||
приходит копией (структурный клон), результат возвращается передаваемым
|
||||
буфером.
|
||||
- Ленивая инициализация воркера при первом вызове; если воркер недоступен
|
||||
(старый браузер/тестовая среда) — автоматический откат на прямое исполнение в
|
||||
главном потоке. Обе ветви обязаны давать побайтово одинаковый результат.
|
||||
- Генераторы и текстовые представления остаются в главном потоке — они
|
||||
мгновенные.
|
||||
- Пипетка и лупа работают с уже готовыми картинками и не зависят от переноса.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — свёртки и фон с тестами на малых матрицах (точные ожидания руками). Проверка: `pnpm test/check`.
|
||||
- **B. Реестр** — категория «Фильтры», три записи, превью маски. Проверка: тесты целостности, визуально.
|
||||
- **C. Web Worker** — перенос исполнителя, отккат-ветвь. Проверка: результаты до/после переключения идентичны байт-в-байт на фиксированной картинке и параметрах; `test/build` зелёные.
|
||||
- **A. Ядро** — свёртки и фон с тестами на малых матрицах (точные ожидания
|
||||
руками). Проверка: `pnpm test/check`.
|
||||
- **B. Реестр** — категория «Фильтры», три записи, превью маски. Проверка: тесты
|
||||
целостности, визуально.
|
||||
- **C. Web Worker** — перенос исполнителя, отккат-ветвь. Проверка: результаты
|
||||
до/после переключения идентичны байт-в-байт на фиксированной картинке и
|
||||
параметрах; `test/build` зелёные.
|
||||
- **D. Итог** — полный прогон и смоук §8.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Размытие: радиус 1 и 32 — плавно усиливается, прозрачные края не темнеют.
|
||||
2. Резкость: сила 0 не меняет картинку; сила 100 заметно подчёркивает края без цветового мусора.
|
||||
2. Резкость: сила 0 не меняет картинку; сила 100 заметно подчёркивает края без
|
||||
цветового мусора.
|
||||
3. Удаление фона на логотипе с однотонным фоном: фон исчез, содержимое цело.
|
||||
4. Режим «только внешние области»: дырка внутри объекта того же цвета остаётся непрозрачной.
|
||||
5. Сглаживание 0/2/4 прохода — граница от жёсткой к мягкой, результат воспроизводим.
|
||||
6. Превью маски показывает белым удаляемую область; скачивание всегда отдаёт результат.
|
||||
7. Во время размытия большой картинки интерфейс остаётся отзывчивым (чип «Пересчёт…», скролл живой).
|
||||
8. Цепочка: размытие → резкость → удаление фона работает; каждый блок скачивает свой промежуток.
|
||||
4. Режим «только внешние области»: дырка внутри объекта того же цвета остаётся
|
||||
непрозрачной.
|
||||
5. Сглаживание 0/2/4 прохода — граница от жёсткой к мягкой, результат
|
||||
воспроизводим.
|
||||
6. Превью маски показывает белым удаляемую область; скачивание всегда отдаёт
|
||||
результат.
|
||||
7. Во время размытия большой картинки интерфейс остаётся отзывчивым (чип
|
||||
«Пересчёт…», скролл живой).
|
||||
8. Цепочка: размытие → резкость → удаление фона работает; каждый блок скачивает
|
||||
свой промежуток.
|
||||
9. Регресс старых инструментов и главной-поиска без изменений поведения.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Три новых инструмента проходят смоук §6; операции ядра покрыты точными юнит-тестами.
|
||||
- Исполнение изображений идёт через воркер там, где он доступен, с гарантированно одинаковым результатом обеих ветвей.
|
||||
- Три новых инструмента проходят смоук §6; операции ядра покрыты точными
|
||||
юнит-тестами.
|
||||
- Исполнение изображений идёт через воркер там, где он доступен, с
|
||||
гарантированно одинаковым результатом обеих ветвей.
|
||||
- `test/check/build` зелёные.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Многопроходное гауссово ядро «по-настоящему» (O(r²)) — трёхпроходного боксового приближения достаточно визуально и оно быстрее.
|
||||
- Многопроходное гауссово ядро «по-настоящему» (O(r²)) — трёхпроходного
|
||||
боксового приближения достаточно визуально и оно быстрее.
|
||||
- Хромакей по нескольким цветам, ИИ-сегментация — следующие волны.
|
||||
- Отмена выполняющегося прогона воркера — устаревшие результаты отбрасываются токеном, сама работа завершается фоном.
|
||||
- Отмена выполняющегося прогона воркера — устаревшие результаты отбрасываются
|
||||
токеном, сама работа завершается фоном.
|
||||
|
||||
@@ -4,17 +4,26 @@
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
Одна пара функций — расширение и сужение непрозрачных областей — открывает целое семейство пользовательских операций: обводка логотипов и иконок, контуры, утолщение и утончение подписей, удаление мелкого мусора и закрытие дырок. Всё это частые запросы в наборах PNG-утилит, а ядро получается компактным и легко покрывается тестами.
|
||||
Одна пара функций — расширение и сужение непрозрачных областей — открывает целое
|
||||
семейство пользовательских операций: обводка логотипов и иконок, контуры,
|
||||
утолщение и утончение подписей, удаление мелкого мусора и закрытие дырок. Всё
|
||||
это частые запросы в наборах PNG-утилит, а ядро получается компактным и легко
|
||||
покрывается тестами.
|
||||
|
||||
## 2. Инструменты — 7 (все в категории «Прозрачность»)
|
||||
|
||||
- **Обвести PNG** (`add-stroke-png`) — кольцо выбранного цвета вокруг непрозрачного содержимого; параметры: цвет, толщина 1–10 px.
|
||||
- **Найти контур PNG** (`find-contour-png`) — линия по краю непрозрачных областей; параметр: толщина линии.
|
||||
- **Обвести PNG** (`add-stroke-png`) — кольцо выбранного цвета вокруг
|
||||
непрозрачного содержимого; параметры: цвет, толщина 1–10 px.
|
||||
- **Найти контур PNG** (`find-contour-png`) — линия по краю непрозрачных
|
||||
областей; параметр: толщина линии.
|
||||
- **Утолщить PNG** (`make-thicker-png`) — расширение содержимого на N пикселей.
|
||||
- **Утончить PNG** (`make-thinner-png`) — сужение содержимого на N пикселей.
|
||||
- **Жёсткие края PNG** (`harden-alpha-png`) — бинаризация альфы по порогу: полупрозрачность уходит, остаётся два состояния.
|
||||
- **Убрать мусор PNG** (`despeckle-alpha-png`) — открытие: удаляет одиночные полупрозрачные пиксели и мелкие крапинки.
|
||||
- **Закрыть дыры PNG** (`close-holes-png`) — закрытие: заполняет одиночные прозрачные точки внутри объекта.
|
||||
- **Жёсткие края PNG** (`harden-alpha-png`) — бинаризация альфы по порогу:
|
||||
полупрозрачность уходит, остаётся два состояния.
|
||||
- **Убрать мусор PNG** (`despeckle-alpha-png`) — открытие: удаляет одиночные
|
||||
полупрозрачные пиксели и мелкие крапинки.
|
||||
- **Закрыть дыры PNG** (`close-holes-png`) — закрытие: заполняет одиночные
|
||||
прозрачные точки внутри объекта.
|
||||
|
||||
Все операции применяются к альфа-каналу; RGB пикселей сохраняется.
|
||||
|
||||
@@ -23,49 +32,64 @@
|
||||
Чистые функции над бинарной маской и над альфа-каналом:
|
||||
|
||||
- `buildAlphaMask(img)` — 1 там, где альфа > 0.
|
||||
- `dilateMask(mask, w, h, radius, shape)` — расширение; `shape`: квадрат или диск (для обводок диск даёт ровную круглую линию).
|
||||
- `dilateMask(mask, w, h, radius, shape)` — расширение; `shape`: квадрат или
|
||||
диск (для обводок диск даёт ровную круглую линию).
|
||||
- `erodeMask(...)` — зеркальная операция.
|
||||
- `dilateImage / erodeImage(img, radiusPx)` — применяют маскирование к альфе изображения, RGB сохраняется.
|
||||
- `dilateImage / erodeImage(img, radiusPx)` — применяют маскирование к альфе
|
||||
изображения, RGB сохраняется.
|
||||
- Сборщики:
|
||||
- обводка: `dilate(mask) − mask` → новое кольцо заливается цветом;
|
||||
- контур: `mask − erode(mask)` → линия;
|
||||
- открытие: `erode → dilate`; закрытие: `dilate → erode`.
|
||||
- `hardenAlpha(img, thresholdPercent)` — отдельно от морфологии, но той же волной.
|
||||
- `hardenAlpha(img, thresholdPercent)` — отдельно от морфологии, но той же
|
||||
волной.
|
||||
|
||||
Структурирующий элемент при радиусе r — диск радиуса r (расстояние до центра ≤ r); квадрат не используем, чтобы углы обводок не были угловатыми.
|
||||
Структурирующий элемент при радиусе r — диск радиуса r (расстояние до центра ≤
|
||||
r); квадрат не используем, чтобы углы обводок не были угловатыми.
|
||||
|
||||
## 4. Web Worker
|
||||
|
||||
Ничего не требуется: новые операции попадают в реестр, исполнитель автоматически исполняет их в воркере. Тесты ядра идут мимо воркера (чистые функции).
|
||||
Ничего не требуется: новые операции попадают в реестр, исполнитель автоматически
|
||||
исполняет их в воркере. Тесты ядра идут мимо воркера (чистые функции).
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — `morphology.ts` + точные тесты на матрицах 5×5: расширение диска, сужение, открытие убирает одиночный пиксель, закрытие закрывает одиночную дыру, кольцо обводки нужного цвета и толщины. Проверка: `pnpm test/check`.
|
||||
- **B. Реестр** — семь записей, тест целостности обновляется (51 → 58). Проверка: `test/build`, визуально.
|
||||
- **A. Ядро** — `morphology.ts` + точные тесты на матрицах 5×5: расширение
|
||||
диска, сужение, открытие убирает одиночный пиксель, закрытие закрывает
|
||||
одиночную дыру, кольцо обводки нужного цвета и толщины. Проверка:
|
||||
`pnpm test/check`.
|
||||
- **B. Реестр** — семь записей, тест целостности обновляется (51 → 58).
|
||||
Проверка: `test/build`, визуально.
|
||||
- **C. Итог** — полный прогон, смоук §7.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Семь новых инструментов работают на прозрачных PNG: обводка, контур, толще/тоньше, жёсткие края, убрать мусор, закрыть дыры.
|
||||
- Семь новых инструментов работают на прозрачных PNG: обводка, контур,
|
||||
толще/тоньше, жёсткие края, убрать мусор, закрыть дыры.
|
||||
- Все операции детерминированы и покрыты юнит-тестами с ручными ожиданиями.
|
||||
- Исполнение автоматически идёт в воркере.
|
||||
- `test/check/build` зелёные, смоук §7 пройден.
|
||||
|
||||
## 7. Смоук-чеклист
|
||||
|
||||
1. Обводка толщиной 3 вокруг круглого стикера — ровное кольцо выбранного цвета, без угловатости.
|
||||
2. Толщина 1 — тонкая аккуратная линия; 10 — толстая, форма содержимого не искажается сверх роста.
|
||||
1. Обводка толщиной 3 вокруг круглого стикера — ровное кольцо выбранного цвета,
|
||||
без угловатости.
|
||||
2. Толщина 1 — тонкая аккуратная линия; 10 — толстая, форма содержимого не
|
||||
искажается сверх роста.
|
||||
3. Контур показывает только край содержимого линией.
|
||||
4. Толще/тоньше на тонкой надписи — штрихи растут/сжимаются без разрывов при умеренных значениях.
|
||||
4. Толще/тоньше на тонкой надписи — штрихи растут/сжимаются без разрывов при
|
||||
умеренных значениях.
|
||||
5. Жёсткие края: полупрозрачность исчезает, силуэт сохраняется.
|
||||
6. Убрать мусор убирает одиночные точки-крапинки, не трогая крупные области.
|
||||
7. Закрыть дыры заполняет одиночные прозрачные точки внутри логотипа.
|
||||
8. Цепочка: обводка после удаления фона работает; каждое звено скачивает свой промежуток.
|
||||
8. Цепочка: обводка после удаления фона работает; каждое звено скачивает свой
|
||||
промежуток.
|
||||
9. Регресс остальных инструментов без изменений.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Морфология по яркости для непрозрачных сканов — волна фокусируется на альфе прозрачных PNG.
|
||||
- Морфология по яркости для непрозрачных сканов — волна фокусируется на альфе
|
||||
прозрачных PNG.
|
||||
- Выбор структурирующего элемента пользователем — всегда диск.
|
||||
- Feather/свечение краёв — отдельная будущая задача поверх размытия.
|
||||
- Произвольные SVG-маски и градиентные обводки.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# План: дожать перевод инструментов в preview и переезд ветки `old/`
|
||||
|
||||
> **СТАТУС: ЗАКРЫТ (2026-09-07).** Перевод каталога завершён — 121 из 125
|
||||
> инструментов в `registry-new`; 4 хвоста отложены решениями и перенесены в
|
||||
> `backlog.md` (`watermark-image-png` → overlay-райз, `png-info` → exif-райз,
|
||||
> `reduce-to-size-png` → download-райз, `compress-png` → закрыт дубликатом).
|
||||
> Дальнейшие «хвосты плана» — переезд старых компонентов в папки `old/`,
|
||||
> закрытие долгов и сбор фидбека — ~уже учитываются в `plan-composite-params.md`
|
||||
> (шаги 36–37) и `plan-design-fix.md`, поэтому план закрыт, а не продолжен.
|
||||
|
||||
> Архивная копия, ниже — состояние на момент закрытия.
|
||||
|
||||
> Статус: **переведено 121 из 125; остальные 4 — отложены/закрыты решениями.**
|
||||
> Текущее состояние: **121 инструмент переведён в `registry-new`**, старый UI
|
||||
> уже живёт на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте (плагин
|
||||
> `isolation/no-mixed-imports`). Здесь — что сделать, чтобы:
|
||||
>
|
||||
> 1. в preview был **весь** каталог инструментов (плюс закрыты известные долги);
|
||||
> 2. физически перенести старую ветку в папки `old/` (шаг 37 основного плана);
|
||||
> 3. после переезда провести детальную проверку в реальных условиях и собрать
|
||||
> отзывы пользователей (правки поверх — отдельными задачами).
|
||||
>
|
||||
> Порядок: перевод остатка → починить известные долги → переезд в `old/` →
|
||||
> пользовательская проверка/фидбек → (позже, отдельным решением) удаление
|
||||
> старого кода.
|
||||
|
||||
## Текущая картина (факты)
|
||||
|
||||
- `registry.ts` + `registry/` — **125 инструментов**; `registry-new/` — **121**.
|
||||
Не переведены только: `png-info` (отложен, хвост-фича с exif), `compress-png`
|
||||
и `reduce-to-size-png` (закрыто решениями, см. ниже), `watermark-image-png`
|
||||
(overlay — единственный реальный остаток).
|
||||
- Preview (`/preview`) показывает переведённые; старый UI работает на
|
||||
`/`-маршрутах (группа `(old)/`): `+page`, `demo`, `list-tools`, `tools/[id]`.
|
||||
Переведено всё кроме четырёх хвостов: color 18, filters 3, geometry 13, alpha
|
||||
13 (+watermark-image в остатке), generate 10 (mix/sort), analyze 12 (6 масок +
|
||||
verify + 5 вердиктов), convert 14 (jpg/webp/bmp + 5 png→texт + 6 text→png),
|
||||
text `watermark-tile-png`.
|
||||
- Плагин `isolation/no-mixed-imports` (`web/eslint-plugins/isolation/`)
|
||||
полностью разделяет ветки: старый `lib/registry.ts|/registry/**`,
|
||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||
`lib/components/**`(кроме `kit/`) vs новый `routes/preview/**`,
|
||||
`lib/registry-new/**`, `lib/preview/**`, `lib/registry-schema.ts`,
|
||||
`lib/components/kit/**`. Общее: `core/`, `i18n/`, `theme`, `assets/`, корневой
|
||||
`lib`.
|
||||
- Правило копий (из основного плана): файл, используемый `(old)`, не трогаем —
|
||||
делаем копию в `lib/preview/` (пример: `lib/preview/tool-icons.ts`).
|
||||
- Известные долги (предсуществующие, не создавались нами): ошибка
|
||||
`ToolCard.svelte:19:36` (svelte-check), фейл `palette.test.ts` (ENOENT
|
||||
`src/app.css` — из-за него `pnpm test` красный при 607 passed), ~335
|
||||
design-tokens ошибок `lint:all` в старых kit-компонентах, a11y-warning в
|
||||
`ColorField.svelte`, пустой ruleset в `Toggle.svelte`, неиспользуемые токены в
|
||||
`preview.css`.
|
||||
|
||||
## Скоуп перевода: переведено всё переводимое (отложено 4 хвоста)
|
||||
|
||||
Каталог дожат до предела текущих решений: переведено 121 из 125. Оставшиеся 4
|
||||
инструмента **отложены осознанно** (не делаем сейчас):
|
||||
|
||||
- **`watermark-image-png` (alpha)** — **отложен**. Нужна overlay-механика
|
||||
(второе изображение-знак через `getOverlay`/store + параметры
|
||||
scale/opacity/position/margin/aspect); решается отдельно, вместе с вопросом,
|
||||
как второй источник изображения вписывается в schema-driven preview.
|
||||
- **`png-info`** — **отложен осознанно**: будет серьёзно дорабатываться
|
||||
отдельным райзом (exif-теги, редактирование, структурированный вывод), для
|
||||
него нужен свой отдельный случай в UI, не «ещё один text/verdict».
|
||||
- **`compress-png`** — **закрыт как дубликат** `decrease-color-count-png`:
|
||||
пресеты сжатия 192/96/44 добавлены прямо в схему `decrease-color-count`.
|
||||
- **`reduce-to-size-png`** — **отложен** до достройки кнопки Download (backlog
|
||||
№7, «экспорт с лимитом размера»).
|
||||
|
||||
### Переведено в этом проходе (text-механика)
|
||||
|
||||
- **Типы:** `ToolEntry.input: "file" | "text" | "none"`,
|
||||
`ToolEntry.result: "image" | "text" | "verdict"`, `OutputFormat` (mime/ext/
|
||||
qualityParamId) для download.
|
||||
- **Executor:** `executeFromText` / `executeToText` / `executeTextToText`
|
||||
(прямые, без worker), экспорт в `preview/executor/index.ts`.
|
||||
- **UI:** `SchemaTextSource` (textarea + Render), `SchemaTextResult` (кол-блок с
|
||||
Copy/Download .txt или бейдж-вердикт по префиксу Yes/No), интеграция в
|
||||
`SchemaPreview`/`SchemaToolView` (режимы file/text/generate, рендер
|
||||
image/text/verdict, скрытие image-download для text-результата).
|
||||
- **Инструменты:** convert 5×png→text (base64/data-uri/hex/bytes/rgb-values),
|
||||
6×text→png (base64/data-uri/hex/bytes/rgb-values/svg, +width), analyze
|
||||
verify-is-png (text→text) и 5 вердиктов (grayscale/file-size/transparent/
|
||||
orientation). Доработка: `base64ToBytes` сужен до `Uint8Array<ArrayBuffer>`
|
||||
(совместимость с `decodeBytes`).
|
||||
- **Гейты:** eslint 0, prettier чист, svelte-check — только предсуществующий
|
||||
ToolCard, тесты — только предсуществующий фейл `palette.test.ts`.
|
||||
|
||||
## Известные долги перед переездом (завести tasks и закрыть)
|
||||
|
||||
По AGENTS.md техдолг «чинится только по заведённым tasks, не игнорируется»:
|
||||
`lint:all` (~335 design-tokens в kit), неиспользуемые токены preview.css, a11y
|
||||
`ColorField`, пустой ruleset `Toggle`. Плюс два, мешающих зелёной проверке
|
||||
до/после переезда: svelte-check error `ToolCard.svelte:19:36` и фейл
|
||||
`palette.test.ts` (ENOENT `src/app.css`). После их закрытия целевое состояние
|
||||
проверок перед переездом: `pnpm --dir web exec svelte-check` → 0 errors,
|
||||
`pnpm --dir web test` → зелёный, `pnpm --dir web lint:all` → только задокумент.
|
||||
ированный остаток (или 0).
|
||||
|
||||
## Шаги перевода остатка (порядок)
|
||||
|
||||
1. **Простые пачки** — **выполнено**: color (18) → filters (3) → geometry (13) →
|
||||
alpha (13/14, кроме watermark-image) → generate (10, включая mix/sort через
|
||||
kind `colors`) → analyze-маски (6) → text `watermark-tile-png`.
|
||||
2. **Text-механика** — **выполнено**: типы `input`/`result` + output-формат,
|
||||
executor `executeFromText`/`toText`/`textToText`, UI
|
||||
`SchemaTextSource`/`SchemaTextResult` + интеграция в preview, перевод
|
||||
конвертеров convert (5 png→text + 6 text→png) и analyze (verify + 5
|
||||
вердиктов). `png-info` отложен отдельным райзом (exif).
|
||||
3. **Шаг про overlay не делается** — `watermark-image-png` **отложен решением**
|
||||
(см. «Скоуп перевода» выше), отдельным райзом вместе с вопросом второго
|
||||
источника изображения в schema-driven preview.
|
||||
4. После категории — `schema.layout` для сгруппированных инструментов (шаг 32-33
|
||||
паттерн) и ручная проверка в `/preview`.
|
||||
|
||||
## Переезд в `old/` (шаг 37 основного плана)
|
||||
|
||||
> Скоп-сдвиг: «Scoped-пути двигаются вместе с папками» — обновляется
|
||||
> `eslint.config.js` (isolation-паттерны и design-tokens glob), не код правила.
|
||||
|
||||
1. **Что переезжает:** `lib/registry.ts`, `lib/registry/**`,
|
||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||
`lib/components/**` (кроме `kit/`) → папки `old/` (например
|
||||
`lib/old/registry …`, `lib/old/tools`, `lib/old/components`) — то есть всё
|
||||
старое в одном поддереве. `routes/(old)/` остаётся как есть (уже отделено).
|
||||
2. **Обновить конфиг плагина** `isolation/no-mixed-imports`: старые glob-ы
|
||||
`lib/registry/**…` заменить на `lib/old/**` (+ `lib/old/components/**` и
|
||||
т.п.), «новое» и «общее» не меняются. Проверить, что правило по-прежнему
|
||||
разделяет (тест фолс-позитивов: временный импорт old↔new ловится).
|
||||
3. **Импорты внутри старого кода** — относительные пути остаются рабочими (папки
|
||||
двигаются целиком); поправить только то, что ссылалось на `$lib/...` в другом
|
||||
стиле. Старые тесты (`registry.test.ts` и др.) переезжают вместе с
|
||||
исходниками и продолжают проходить.
|
||||
4. **Гейты переезда:** `svelte-check` 0 errors, `pnpm --dir web test` зелёный,
|
||||
`prettier --check .` чистый, `lint:all` = задокументированный остаток, обе
|
||||
ветки открываются (старая `/`, новая `/preview`).
|
||||
|
||||
## Проверка и сбор отзывов после переезда
|
||||
|
||||
1. **Регресс-чеклист по каталогу** — пройти все инструменты в `/preview`:
|
||||
дефолты совпадают со старыми, валидация/клампы поведения не меняют, результат
|
||||
эквивалентен старому UI (сверить на одинаковых входах).
|
||||
2. **Тяжёлые кейсы руками:** генераторы, маски, font-style/plate/gradient на
|
||||
всем наборе, text-source, output-формат. (`watermark-image` — позже, после
|
||||
overlay-райза.)
|
||||
3. **Сбор фидбека** — отдельные задачи на каждый найденный фикс; чеклист
|
||||
зафиксировать в этом документе по мере находок (или ссылкой на tasks).
|
||||
4. Сверка каталога с `tools-map.md` (покрытие/дедупликация
|
||||
`convert-png-to-jpg`/`webp`), при необходимости — обновить cards/мета.
|
||||
5. **Решение об удалении старого** — после завершения ревью и фидбека, отдельным
|
||||
шагом: тогда чистятся `lib/old/**`, `routes/(old)/**`, `old.css`, `exports`,
|
||||
изоляционный плагин (glob-ы больше не нужны) и открываются «правило
|
||||
копий»-дубли.
|
||||
|
||||
## Как ревьюить каждый шаг (гайды)
|
||||
|
||||
- Диф на **один инструмент** (или фундамент без инструментов) < ~500 строк.
|
||||
- Старый `params`/`run`/`defaultParams`/`sanitizeParams` не изменены; в
|
||||
`registry-new` копия логики + `schema` (дефолты равны старым).
|
||||
- Старый UI и старые тесты продолжают работать: `pnpm --dir web test`,
|
||||
`pnpm --dir web exec svelte-check`.
|
||||
- Новый инструмент проверяется **руками в `/preview`** end-to-end (применение +
|
||||
сброс дефолтов + смена значений).
|
||||
- После переезда каждый move-шаг гоняет 4 гейта (см. выше).
|
||||
|
||||
## Оценка трудозатрат
|
||||
|
||||
| Часть | Сложность | Оценка |
|
||||
| ---------------------------------------------------------------------- | -------------- | ------------------------- |
|
||||
| Простые инструменты (color/filters/geometry/alpha/generate/маски/text) | Низкая-Средняя | **выполнено** (62 шт) |
|
||||
| Text-механика + конвертеры/вердикты | Средняя | **выполнено** (16 шт) |
|
||||
| watermark-image / png-info / reduce-to-size (отложены решениями) | Высокая | отложено, отдельные райзы |
|
||||
| Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч |
|
||||
| Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч |
|
||||
| Проверка/фидбек + правки | Зависит | ~3-6ч |
|
||||
| **Итого** | | **~9-18ч** по фазам |
|
||||
|
||||
> Оценки ориентировочные; особые случаи — с ревью решения, объём финальных
|
||||
> правок по фидбеку не предсказуем.
|
||||
@@ -4,36 +4,63 @@
|
||||
|
||||
## 1. Целевое поведение
|
||||
|
||||
1. «Процентные» параметры (яркость, контраст, качество JPEG/WebP, порог похожести) — слайдеры с числовым значением рядом; точные координаты и размеры (resize/crop) остаются полями ввода.
|
||||
2. У цветовых параметров появляется кнопка-переключатель «Пипетка»: активна → клик по превью исходника снимает цвет пикселя и подставляет его в поле цвета; после выбора режим гаснет.
|
||||
3. В режиме пипетки возле курсора видна лупа: увеличенный блок пикселей вокруг точки с перекрестием и подписью hex текущего пикселя; клик выбирает центр перекрестия.
|
||||
4. Активная пипетка видна на превью перекрестием курсора; повторный клик по кнопке отменяет режим без выбора.
|
||||
5. Всё работает поверх текущего процесса: авто-применение с задержкой, токен запусков, маски — без изменений.
|
||||
1. «Процентные» параметры (яркость, контраст, качество JPEG/WebP, порог
|
||||
похожести) — слайдеры с числовым значением рядом; точные координаты и размеры
|
||||
(resize/crop) остаются полями ввода.
|
||||
2. У цветовых параметров появляется кнопка-переключатель «Пипетка»: активна →
|
||||
клик по превью исходника снимает цвет пикселя и подставляет его в поле цвета;
|
||||
после выбора режим гаснет.
|
||||
3. В режиме пипетки возле курсора видна лупа: увеличенный блок пикселей вокруг
|
||||
точки с перекрестием и подписью hex текущего пикселя; клик выбирает центр
|
||||
перекрестия.
|
||||
4. Активная пипетка видна на превью перекрестием курсора; повторный клик по
|
||||
кнопке отменяет режим без выбора.
|
||||
5. Всё работает поверх текущего процесса: авто-применение с задержкой, токен
|
||||
запусков, маски — без изменений.
|
||||
|
||||
## 2. Слайдеры
|
||||
|
||||
- В реестре новый тип параметра `'slider'` с обязательными `min`/`max`; валидируется как число (дефолт в диапазоне, минимум меньше максимума).
|
||||
- Форма маппит его на готовый `SliderField` из `ui/`; санитизация параметров обрабатывает slider так же, как number.
|
||||
- Переводятся: яркость и контраст, качество JPEG, качество WebP, порог похожести. Остальные инструменты — без изменений.
|
||||
- В реестре новый тип параметра `'slider'` с обязательными `min`/`max`;
|
||||
валидируется как число (дефолт в диапазоне, минимум меньше максимума).
|
||||
- Форма маппит его на готовый `SliderField` из `ui/`; санитизация параметров
|
||||
обрабатывает slider так же, как number.
|
||||
- Переводятся: яркость и контраст, качество JPEG, качество WebP, порог
|
||||
похожести. Остальные инструменты — без изменений.
|
||||
|
||||
## 3. Пипетка и лупа
|
||||
|
||||
- В ядро добавляется чистая функция `rgbToHex(r, g, b)` → строка вида `#rrggbb`, с юнит-тестами; чтение пикселя — из уже отрисованного canvas превью исходника через `getImageData(x, y, 1, 1)`.
|
||||
- `Preview.svelte` получает опциональный режим пипетки: пропсы «активна» и колбэк «цвет выбран»; курсор перекрестие, клик вычисляет координату пикселя с учётом разницы CSS-размера canvas и его внутренней ширины.
|
||||
- **Лупа** — компонент `PipetteLoupe.svelte`: канвас около 110×110 px у курсора (позиция fixed, `pointer-events: none`); рисует блок 15×15 пикселей из исходника с увеличением ×8 без сглаживания, перекрестие в центре, подпись hex текущего пикселя. Координаты блока клампятся у краёв изображения; лупа скрыта вне превью и вне режима пипетки. Тач-устройства: прямой тап выбирает пиксель без лупы.
|
||||
- Проводка: кнопка у каждого цветового поля в карточке параметров → состояние «активное цветовое поле» живёт в оркестраторе → выбор цвета записывает hex в это поле и выключает режим.
|
||||
- Пипетка работает только по левой панели (исходнику): удаляемый цвет и подложка берутся с оригинала.
|
||||
- В ядро добавляется чистая функция `rgbToHex(r, g, b)` → строка вида `#rrggbb`,
|
||||
с юнит-тестами; чтение пикселя — из уже отрисованного canvas превью исходника
|
||||
через `getImageData(x, y, 1, 1)`.
|
||||
- `Preview.svelte` получает опциональный режим пипетки: пропсы «активна» и
|
||||
колбэк «цвет выбран»; курсор перекрестие, клик вычисляет координату пикселя с
|
||||
учётом разницы CSS-размера canvas и его внутренней ширины.
|
||||
- **Лупа** — компонент `PipetteLoupe.svelte`: канвас около 110×110 px у курсора
|
||||
(позиция fixed, `pointer-events: none`); рисует блок 15×15 пикселей из
|
||||
исходника с увеличением ×8 без сглаживания, перекрестие в центре, подпись hex
|
||||
текущего пикселя. Координаты блока клампятся у краёв изображения; лупа скрыта
|
||||
вне превью и вне режима пипетки. Тач-устройства: прямой тап выбирает пиксель
|
||||
без лупы.
|
||||
- Проводка: кнопка у каждого цветового поля в карточке параметров → состояние
|
||||
«активное цветовое поле» живёт в оркестраторе → выбор цвета записывает hex в
|
||||
это поле и выключает режим.
|
||||
- Пипетка работает только по левой панели (исходнику): удаляемый цвет и подложка
|
||||
берутся с оригинала.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
- **A. Слайдеры** — тип в реестре, перевод четырёх инструментов, тесты реестра и санитизации, тест `rgbToHex`. Проверка: `pnpm test/check`, визуально слайдеры.
|
||||
- **B. Пипетка** — режим в Preview, проводка через карточки до полей цвета. Проверка: смоук §6 пп. 5–7.
|
||||
- **B2. Лупа** — компонент лупы поверх режима пипетки. Проверка: смоук §6 пп. 8–10.
|
||||
- **A. Слайдеры** — тип в реестре, перевод четырёх инструментов, тесты реестра и
|
||||
санитизации, тест `rgbToHex`. Проверка: `pnpm test/check`, визуально слайдеры.
|
||||
- **B. Пипетка** — режим в Preview, проводка через карточки до полей цвета.
|
||||
Проверка: смоук §6 пп. 5–7.
|
||||
- **B2. Лупа** — компонент лупы поверх режима пипетки. Проверка: смоук §6 пп.
|
||||
8–10.
|
||||
- **C. Итог** — полный прогон `test/check/build`.
|
||||
|
||||
## 5. Изменения по файлам
|
||||
|
||||
- `registry.ts` — тип `'slider'` в `ParamDef`, перевод четырёх записей, ветка в `sanitizeParams`.
|
||||
- `registry.ts` — тип `'slider'` в `ParamDef`, перевод четырёх записей, ветка в
|
||||
`sanitizeParams`.
|
||||
- `ParamForm.svelte` — ветка slider → `SliderField`.
|
||||
- `core/color.ts` — `rgbToHex`.
|
||||
- `Preview.svelte` — режим пипетки (курсор, клик, колбэк).
|
||||
@@ -44,21 +71,29 @@
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Яркость/контраст — слайдеры со значением справа, результат обновляется сам при перетаскивании.
|
||||
1. Яркость/контраст — слайдеры со значением справа, результат обновляется сам
|
||||
при перетаскивании.
|
||||
2. Качество JPEG/WebP — слайдер от 1 до 100.
|
||||
3. Порог похожести — слайдер от 0 до 100.
|
||||
4. Resize и crop остались числовыми полями.
|
||||
5. Удаление цвета: «Пипетка» → клик по красной области исходника → поле цвета получил hex этого пикселя, режим погас, результат пересчитался.
|
||||
6. Повторный клик по кнопке или выбор без подтверждения — режим снят, цвет не изменился.
|
||||
5. Удаление цвета: «Пипетка» → клик по красной области исходника → поле цвета
|
||||
получил hex этого пикселя, режим погас, результат пересчитался.
|
||||
6. Повторный клик по кнопке или выбор без подтверждения — режим снят, цвет не
|
||||
изменился.
|
||||
7. Подложка JPEG: пипетка проставляет цвет подложки тем же способом.
|
||||
8. В режиме пипетки лупа следует за курсором, перекрестие показывает пиксель под ним, подпись hex совпадает с выбранным при клике.
|
||||
9. У края картинки блок лупи корректно клампится — за границей тянется чёрный/прозрачный фон, выбор по-прежнему точен.
|
||||
10. Курсор ушёл с превью или режим выключен — лупа скрыта; клики по кнопкам под превью не перехватываются лупой.
|
||||
11. Слайдеры и пипетка дружат с быстрыми правками: одна финальная версия результата, чип «Пересчёт…» на месте.
|
||||
8. В режиме пипетки лупа следует за курсором, перекрестие показывает пиксель под
|
||||
ним, подпись hex совпадает с выбранным при клике.
|
||||
9. У края картинки блок лупи корректно клампится — за границей тянется
|
||||
чёрный/прозрачный фон, выбор по-прежнему точен.
|
||||
10. Курсор ушёл с превью или режим выключен — лупа скрыта; клики по кнопкам под
|
||||
превью не перехватываются лупой.
|
||||
11. Слайдеры и пипетка дружат с быстрыми правками: одна финальная версия
|
||||
результата, чип «Пересчёт…» на месте.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Все процентные параметры — слайдеры; числовые поля остались только там, где нужен точный ввод.
|
||||
- Все процентные параметры — слайдеры; числовые поля остались только там, где
|
||||
нужен точный ввод.
|
||||
- Цвет любого color-параметра можно взять кликом с исходника.
|
||||
- `test/check/build` зелёные, чеклист §6 пройден.
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# План: тестирование preview перед промоушеном в главную
|
||||
|
||||
> Статус: **реализация завершена**. Playwright-набор в `web/e2e/` (108 тестов:
|
||||
> 104 прошли, 4 `test.fixme` — известные баги №1–4 ниже), ручной чек-лист —
|
||||
> `docs/checklist-manual-testing.md`. План ветки preview
|
||||
> (`web/src/routes/preview/**`, регистр `lib/registry-new/`, 121 инструмент)
|
||||
> перед шагом C17 из `docs/plan-redesign.md` (переезд preview → реальные
|
||||
> маршруты).
|
||||
>
|
||||
> Охват: **документация плана** — реализация тестов (Playwright-раннер, сами
|
||||
> тесты) делается отдельными задачами после ревью этого документа.
|
||||
>
|
||||
> Приоритет: **отсутствие падений/ошибок** > пиксельная корректность. Дизайн
|
||||
> доделываем по ходу, визуальная сверка — ручная и вторичная.
|
||||
>
|
||||
> Команды для проверки в процессе:
|
||||
>
|
||||
> - `pnpm --dir web test` — существующий Vitest (42 файла, 121 инструмент
|
||||
> частично покрыт юнит-тестами).
|
||||
> - `pnpm --dir web test:e2e` — Playwright (`web/e2e/`), сборка +
|
||||
> `scripts/serve-static.mjs` на 4173.
|
||||
> - `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` — проверка
|
||||
> типов.
|
||||
> - `pnpm --dir web lint:all` — ESLint + stylelint + токен-аудит.
|
||||
> - `pnpm --dir web build` — статическая сборка (adapter-static).
|
||||
|
||||
## 0. Что тестируем
|
||||
|
||||
Preview-ветка — клиентское SPA на SvelteKit (adapter-static, всё в браузере,
|
||||
никакого API). Структура:
|
||||
|
||||
| Маршрут | Что это |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `/preview` | Workspace: поиск + сетка `ToolCard` (топ-24), «Open last project» (stub), футер |
|
||||
| `/preview/kit` | Витрина всех kit-компонентов (light/dark) |
|
||||
| `/preview/list-tools` | Каталог: 8 групп, 121 инструмент, фильтр по категории, поиск |
|
||||
| `/preview/tools/[id]` | Страница инструмента: `SchemaToolView` (настройки + live preview), prerendered на все 121 id |
|
||||
| `/preview/tools/xxx` (несуществующий) | 404 → `EmptyState` «Tool not found» |
|
||||
|
||||
Инструменты: **121**, категории CONVERT / TRANSPARENCY (alpha) / COLOR /
|
||||
GEOMETRY / FILTERS / TEXT / ANALYZE / GENERATE. Режимы инструмента:
|
||||
|
||||
- **file** (transform): upload → `run` → картинка → download;
|
||||
- **text** (convert-source): ввод текста → `runFromText` → картинка, либо
|
||||
`textToText` → текстовый результат;
|
||||
- **verdict** (`result: "verdict"`, file): `toText` → текстовый вердикт;
|
||||
- **text-output** (`result: "text"`, file): `toText` → текстовый результат;
|
||||
- **generate**: без входа, `executeGenerate` → картинка.
|
||||
|
||||
Исполнение: `preview/executor` — worker с fallback на main-thread (domOnly / нет
|
||||
Worker / ошибка worker). Auto-run с debounce 200 мс.
|
||||
|
||||
## 1. Функциональные блоки для проверки
|
||||
|
||||
### A. Инфраструктура и навигация
|
||||
|
||||
1. Все 4 маршрута + 404 открываются без console errors, uncaught exceptions и
|
||||
failed requests.
|
||||
2. Поиск на `/preview` — фильтрация по названию/описанию, пустой результат →
|
||||
аккуратное empty-state.
|
||||
3. Каталог `/preview/list-tools` — 8 групп, счётчик 121, фильтр категории,
|
||||
иконки не падают.
|
||||
4. Тема light/dark и язык RU/EN переключаются на превью-страницах без регресса
|
||||
рендера.
|
||||
5. 404 на несуществующий id инструмента → EmptyState, без краша.
|
||||
6. Prerender: `pnpm --dir web build` проходит; все 121 страницы
|
||||
`/preview/tools/[id]` генерируются (проверка `entries()`/`getTool`).
|
||||
|
||||
### B. Конвейер инструмента (file-инструменты)
|
||||
|
||||
7. Upload → авто-run → результатный тайл появился, поле ошибки пустое.
|
||||
8. Download — событие download, имя `<toolId>.<ext>`, MIME соответствует
|
||||
`output`.
|
||||
9. Reset — возвращает дефолты, сбрасывает результат и ошибку.
|
||||
10. Смена параметра → авто-перезапуск (debounce 200 мс) без «залипания»
|
||||
running-стейта.
|
||||
11. Worker-исполнение и fallback-исполнение оба дают результат.
|
||||
|
||||
### C. Текстовые инструменты и вердикты
|
||||
|
||||
12. Text-source: ввод → текстовый результат или картинка; copy/download .txt.
|
||||
13. Вердикты (analyze): корректный вердикт на PNG с альфой, на PNG без альфы, на
|
||||
не-PNG входе → без падений.
|
||||
14. Пустой text-source → run пропускается без ошибки.
|
||||
|
||||
### D. Генераторы
|
||||
|
||||
15. Каждый генератор по дефолтам создаёт картинку; смена размеров/цвета
|
||||
перегенерирует; канва ожидаемого размера.
|
||||
|
||||
## 2. Репрезентативная выборка по категориям (для «нет падений»)
|
||||
|
||||
Полный прогон всех 121 — опционально (C/all); минимальный чек — по группам:
|
||||
|
||||
| Категория | Кол-во | Охват для «нет падений» |
|
||||
| ------------ | ------ | --------------------------------------------------------------------------------- |
|
||||
| CONVERT | 14 | convert-png-to-jpg, convert-png-to-webp, png-to-base64, base64-to-png, svg-to-png |
|
||||
| TRANSPARENCY | 20 | remove-background, round-corners, add-stroke, remove-color-from, circle-mask |
|
||||
| COLOR | 25 | grayscale, invert, black-and-white, quantize, dithering, png-to-hsl |
|
||||
| GEOMETRY | 18 | resize, crop, rotate, flip, add-border, tile |
|
||||
| FILTERS | 8 | blur, sharpen, pixelate, add-noise |
|
||||
| TEXT | 3 | add-text, date-stamp, watermark-tile |
|
||||
| ANALYZE | 12 | extract-color-from, show-transparent-pixels, verify-is-png, png-is-transparent |
|
||||
| GENERATE | 21 | create-empty, linear-gradient, two-colors, placeholder, text-to-png |
|
||||
|
||||
## 3. Playwright (авто) против ручного — разбиение
|
||||
|
||||
### ✅ Playwright — детерминированные сквозные потоки и «нет падений»
|
||||
|
||||
- Все маршруты + 404: открытие, отсутствие console errors / uncaught exceptions
|
||||
/ failed requests (слушатели `page.on('console'/'pageerror')`).
|
||||
- Поиск и каталог: фильтрация, счётчики, переходы.
|
||||
- Upload (`setInputFiles` с фикстурой PNG) → авто-результат → Download (ожидание
|
||||
`download` события, проверка имени/расширения/suggestedFilename).
|
||||
- Reset / смена параметра → перегенерация, running не «залипает».
|
||||
- Тема light/dark и язык RU/EN.
|
||||
- Текстовые инструменты: ввод → текстовый результат → копия/скачивание.
|
||||
- Вердикты на подготовленных фикстурах (PNG с альфой, PNG без альфы,
|
||||
JPG/некартинка).
|
||||
- Генераторы: дефолты → результат появился, размер канвы ожидаемый.
|
||||
- 404 и empty-state.
|
||||
- Worker и fallback: сравнить оба пути рендера (инжект отключения Worker).
|
||||
|
||||
### 🔄 Playwright + ручная сверка
|
||||
|
||||
- Пиксельная корректность результата (сравнение `<canvas>` через
|
||||
`toDataURL`/тест-per-pixel) — по приоритету вторично; эталоны задаются
|
||||
вручную, делать отдельной задачей.
|
||||
- Реальные пользовательские картинки (не фикстуры) — вручную.
|
||||
|
||||
### ✋ Ручное тестирование
|
||||
|
||||
- **Краевые PNG:** большие (мегапиксели), 1×1, анимация, палитровые,
|
||||
чекерборд/полупрозрачность, повреждённые файлы → нет зависаний, понятные
|
||||
сообщения об ошибке.
|
||||
- **Края параметров:** экстремальные значения, NaN/пустые поля, инвалидные цвета
|
||||
в плашках/градиентах → корректная санитизация (silent repair).
|
||||
- **Производительность/отзывчивость:** задержка авто-реранов на слайдерах, нет
|
||||
«залипания» UI, скролл при длинных настройках.
|
||||
- **Визуальный слой дизайна** (доделываем по ходу): сетки, выравнивание, отступы
|
||||
на брейкпоинтах mobile/tablet/desktop (640/800/1100).
|
||||
- **Буфер обмена** (copy) в реальном браузере.
|
||||
- **Drag-n-drop** файла на dropzone (UX-ощущения).
|
||||
- **Разные браузеры:** Firefox/Safari-нюансы (webkit в Playwright опционален).
|
||||
- **Субъективная корректность:** dithering, two-colors, watermark, текст
|
||||
не-ASCII/кириллица в add-text/date-stamp/watermark-tile.
|
||||
|
||||
## 4. Критерии приёмки (доступ в главную)
|
||||
|
||||
1. Все 4 маршрута + 404 открываются без console errors и исключений.
|
||||
2. Репрезентативный прогон по 8 категориям: ни один инструмент не падает и не
|
||||
показывает поле ошибки.
|
||||
3. Upload → результат → download завершается для file-инструментов;
|
||||
текст/вердикты/генераторы дают корректный output-тип.
|
||||
4. `pnpm --dir web test`, `pnpm --dir web lint:all`, `pnpm --dir web build`
|
||||
зелёные.
|
||||
5. Тема и язык переключаются без регрессов.
|
||||
|
||||
## 5. Оценка покрытия
|
||||
|
||||
- **~60–70%** «нет падений/ошибок» закрывается Playwright детерминированно
|
||||
(маршруты, сквозные потоки, отсутствие ошибок).
|
||||
- **~30–40%** — руками: краевые PNG/параметры, реальные пользовательские файлы,
|
||||
буфер обмена, кросс-браузеры, визуальный/производительный слой, субъективная
|
||||
корректность.
|
||||
|
||||
## 6. Шаги реализации (после ревью)
|
||||
|
||||
1. ✅ Playwright-раннер: `web/playwright.config.ts` (webServer на
|
||||
`pnpm build + `scripts/serve-static.mjs` `--port
|
||||
4173`), папка `web/e2e/`, npm-скрипт `test:e2e`.
|
||||
2. ✅ Фикстуры PNG (генерируются в рантайме, `web/e2e/helpers/fixtures.ts`): с
|
||||
альфой, без альфы, 1×1, большой, повреждённый, ландшафт.
|
||||
3. ✅ E2E по блокам A–D и секции 3 (авто-часть): `navigation.spec.ts`,
|
||||
`catalog.spec.ts`, `pipeline.spec.ts`, `text-and-verdicts.spec.ts`,
|
||||
`tools-smoke.spec.ts` (матрица ~71 инструмента), `generators.spec.ts`,
|
||||
`known-issues.spec.ts` (fixme).
|
||||
4. ✅ Ручной чек-лист из секции 3 (ручная часть) —
|
||||
`docs/checklist-manual-testing.md` (разделы A–G, включая известные баги).
|
||||
5. ✅ Найденные баги (зафиксированы как `test.fixme` + чек-лист §G):
|
||||
1. генераторы (21/121) не имеют кнопки «Generate» и полей схемы — результат
|
||||
через UI недостижим;
|
||||
2. `resize-png` с дефолтом 0×0 → `errors.resizeSize` при любом входе;
|
||||
3. `crop-png` с дефолтом 0×0 → `errors.cropSize` при любом входе;
|
||||
4. ключи ошибок показываются сырыми (`errors.*`) вместо переведённого текста
|
||||
из `en.ts`/`ru.ts`. Исправления — отдельными атомарными задачами по
|
||||
конвенции AGENTS.md (после правок: `lint:all`, `test:e2e`).
|
||||
|
||||
## 7. Итоговое состояние на момент сдачи
|
||||
|
||||
- `pnpm --dir web test:e2e` — **104 passed, 4 skipped (fixme), 0 failed**.
|
||||
- `pnpm --dir web lint:all` и `svelte-check` — зелёные.
|
||||
- Визуальный слой, краевые PNG/параметры, кросс-браузеры — остаются на ручной
|
||||
прогон по `docs/checklist-manual-testing.md`.
|
||||
@@ -4,41 +4,78 @@
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Четыре инструмента новой категории «Текст»: надпись на изображении, дата-штамп, защитная плитка (повторяющийся полупрозрачный текст под углом), водяной знак картинкой. Каталог вырастает с 69 до 73. Это первые операции, рисующие через canvas 2D — класс perceptual по роадмапу: точные пиксельные тесты на них не пишутся, юнит-тестами покрывается чистая математика раскладки.
|
||||
Четыре инструмента новой категории «Текст»: надпись на изображении, дата-штамп,
|
||||
защитная плитка (повторяющийся полупрозрачный текст под углом), водяной знак
|
||||
картинкой. Каталог вырастает с 69 до 73. Это первые операции, рисующие через
|
||||
canvas 2D — класс perceptual по роадмапу: точные пиксельные тесты на них не
|
||||
пишутся, юнит-тестами покрывается чистая математика раскладки.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Вся геометрия — чистые функции без DOM: якорь из 9-позиционной сетки → координаты, перенос текста по ширине с инъекцией измерителя, раскладка плитки с углом и шагом. Canvas-вызовы — тонкий слой рядом (`core/textdraw.ts`), в юнит-тесты не попадает.
|
||||
- Один движок рендера текста на все три текстовых инструмента: шрифт из системного набора (sans/serif/mono), размер, цвет, жирность, прозрачность, поворот. Различия инструментов — только в пресетах параметров и количестве слоёв (одна надпись vs плитка).
|
||||
- Дата-штамп — надпись с автотекстом: строка формата (`YYYY-MM-DD hh:mm`) разворачивается в текущую дату в момент запуска; свой мини-форматтер без зависимостей.
|
||||
- Водяной знак картинкой — первый двухисточниковый инструмент: вторая картинка грузится отдельной зоной в этапе, живёт только в состоянии страницы и сознательно не сохраняется ни в workspace-pipeline, ни в экспорте JSON; после восстановления цепочки знак нужно выбрать заново. Ограничение фиксируем в описании инструмента.
|
||||
- Новая категория `text` встаёт после «Фильтров»; названия в обоих словарях, порядок в CATEGORIES один.
|
||||
- Вся геометрия — чистые функции без DOM: якорь из 9-позиционной сетки →
|
||||
координаты, перенос текста по ширине с инъекцией измерителя, раскладка плитки
|
||||
с углом и шагом. Canvas-вызовы — тонкий слой рядом (`core/textdraw.ts`), в
|
||||
юнит-тесты не попадает.
|
||||
- Один движок рендера текста на все три текстовых инструмента: шрифт из
|
||||
системного набора (sans/serif/mono), размер, цвет, жирность, прозрачность,
|
||||
поворот. Различия инструментов — только в пресетах параметров и количестве
|
||||
слоёв (одна надпись vs плитка).
|
||||
- Дата-штамп — надпись с автотекстом: строка формата (`YYYY-MM-DD hh:mm`)
|
||||
разворачивается в текущую дату в момент запуска; свой мини-форматтер без
|
||||
зависимостей.
|
||||
- Водяной знак картинкой — первый двухисточниковый инструмент: вторая картинка
|
||||
грузится отдельной зоной в этапе, живёт только в состоянии страницы и
|
||||
сознательно не сохраняется ни в workspace-pipeline, ни в экспорте JSON; после
|
||||
восстановления цепочки знак нужно выбрать заново. Ограничение фиксируем в
|
||||
описании инструмента.
|
||||
- Новая категория `text` встаёт после «Фильтров»; названия в обоих словарях,
|
||||
порядок в CATEGORIES один.
|
||||
|
||||
## 3. Инструменты
|
||||
|
||||
- `add-text-png` — параметры: текст (строка), размер (слайдер 8–200), цвет, шрифт (select), жирность (чекбокс), позиция (select 3×3: углы/стороны/центр), отступ (слайдер). Опциональная подложка: цвет + прозрачность плашки за текстом.
|
||||
- `date-stamp-png` — тот же набор минус текст, плюс формат даты; дефолт — нижний правый угол.
|
||||
- `watermark-tile-png` — текст, размер, цвет, прозрачность (0–100), угол (-90…90), шаг по X/Y; плитка заполняет весь холст.
|
||||
- `watermark-image-png` — второй источник (загрузка PNG с альфой), масштаб знака (% от ширины холста), прозрачность, позиция 3×3, отступ.
|
||||
- `add-text-png` — параметры: текст (строка), размер (слайдер 8–200), цвет,
|
||||
шрифт (select), жирность (чекбокс), позиция (select 3×3: углы/стороны/центр),
|
||||
отступ (слайдер). Опциональная подложка: цвет + прозрачность плашки за
|
||||
текстом.
|
||||
- `date-stamp-png` — тот же набор минус текст, плюс формат даты; дефолт — нижний
|
||||
правый угол.
|
||||
- `watermark-tile-png` — текст, размер, цвет, прозрачность (0–100), угол
|
||||
(-90…90), шаг по X/Y; плитка заполняет весь холст.
|
||||
- `watermark-image-png` — второй источник (загрузка PNG с альфой), масштаб знака
|
||||
(% от ширины холста), прозрачность, позиция 3×3, отступ.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Фундамент** — категория `text` (categories + словари + порядок), `core/textdraw.ts`: anchor9, wrap, tileLayout с юнит-тестами на математике (координаты углов/центра, переносы слов, плотность плитки), тонкий canvas-модуль `domText.ts` (без тестов). Реестр пока пуст в категории.
|
||||
- **B. Надпись и дата** — `add-text-png`, `date-stamp-png` на общем движке; иконки, переводы, записи реестра. Смоук: обе локали, тёмная тема (плашка читаема), длинный текст переносится и не вылезает за край.
|
||||
- **C. Плитка** — `watermark-tile-png`; смоук: отрицательные и нулевой угол, прозрачность 0/50/100, мелкий шаг не подвешивает страницу на больших холстах (ограничение количества плиток капом).
|
||||
- **D. Знак картинкой** — двухисточниковый UX: вторая drop-зона в этапе (компонент рядом с SourceCard, виден только у этого инструмента), масштаб/позиция/отступ; ограничение про сохранение — в описании и подсказке. Этап режется без ущерба остальным, если решим отложить.
|
||||
- **A. Фундамент** — категория `text` (categories + словари + порядок),
|
||||
`core/textdraw.ts`: anchor9, wrap, tileLayout с юнит-тестами на математике
|
||||
(координаты углов/центра, переносы слов, плотность плитки), тонкий
|
||||
canvas-модуль `domText.ts` (без тестов). Реестр пока пуст в категории.
|
||||
- **B. Надпись и дата** — `add-text-png`, `date-stamp-png` на общем движке;
|
||||
иконки, переводы, записи реестра. Смоук: обе локали, тёмная тема (плашка
|
||||
читаема), длинный текст переносится и не вылезает за край.
|
||||
- **C. Плитка** — `watermark-tile-png`; смоук: отрицательные и нулевой угол,
|
||||
прозрачность 0/50/100, мелкий шаг не подвешивает страницу на больших холстах
|
||||
(ограничение количества плиток капом).
|
||||
- **D. Знак картинкой** — двухисточниковый UX: вторая drop-зона в этапе
|
||||
(компонент рядом с SourceCard, виден только у этого инструмента),
|
||||
масштаб/позиция/отступ; ограничение про сохранение — в описании и подсказке.
|
||||
Этап режется без ущерба остальным, если решим отложить.
|
||||
- **E. Итог** — полный прогон, смоук §6, архивация плана.
|
||||
|
||||
## 5. Критерии готовности
|
||||
|
||||
- 73 инструмента; вся математика раскладки покрыта юнит-тестами; canvas-слой изолирован в одном модуле.
|
||||
- test/check/build зелёные; новые инструменты работают в цепочке как обычные шаги (кроме источника знака — см. ограничение).
|
||||
- 73 инструмента; вся математика раскладки покрыта юнит-тестами; canvas-слой
|
||||
изолирован в одном модуле.
|
||||
- test/check/build зелёные; новые инструменты работают в цепочке как обычные
|
||||
шаги (кроме источника знака — см. ограничение).
|
||||
|
||||
## 6. Что сознательно не делаем
|
||||
|
||||
- Выбор произвольных пользовательских шрифтов (файлами) и веб-шрифтов — только системный набор.
|
||||
- Кернинг/линии/многострочное редактирование с абзацами — один абзац с автопереносом.
|
||||
- Выбор произвольных пользовательских шрифтов (файлами) и веб-шрифтов — только
|
||||
системный набор.
|
||||
- Кернинг/линии/многострочное редактирование с абзацами — один абзац с
|
||||
автопереносом.
|
||||
- Сохранение картинки-знака в пайплайне и её передача между сессиями.
|
||||
- Текст по контуру, градиентные заливки текста.
|
||||
|
||||
+25
-10
@@ -4,30 +4,44 @@
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Переключатель light/dark в шапке рядом с языковым. Выбор в localStorage (`theme`), первый визит — по `prefers-color-scheme`. Тёмная палитра — переопределение токенов селектором `[data-theme='dark']`; компоненты не правятся.
|
||||
Переключатель light/dark в шапке рядом с языковым. Выбор в localStorage
|
||||
(`theme`), первый визит — по `prefers-color-scheme`. Тёмная палитра —
|
||||
переопределение токенов селектором `[data-theme='dark']`; компоненты не
|
||||
правятся.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Два состояния, без «авто»: ключ хранит только явный выбор, отсутствие ключа = системная тема.
|
||||
- Анти-вспышка: инлайн-скрипт в `app.html` до гидрации ставит `data-theme` из localStorage или `matchMedia`; пререндеренная разметка остаётся светлой.
|
||||
- `color-scheme: dark` на корне — нативные контролы (input[type=color], range, select, скроллбары) темнеют без правок.
|
||||
- Модуль `theme.svelte.ts` по образцу locale: состояние, персист, применение атрибута. Стартовую установку делает скрипт из `app.html`, модуль синхронизирует runes-состояние после гидрации.
|
||||
- Два состояния, без «авто»: ключ хранит только явный выбор, отсутствие ключа =
|
||||
системная тема.
|
||||
- Анти-вспышка: инлайн-скрипт в `app.html` до гидрации ставит `data-theme` из
|
||||
localStorage или `matchMedia`; пререндеренная разметка остаётся светлой.
|
||||
- `color-scheme: dark` на корне — нативные контролы (input[type=color], range,
|
||||
select, скроллбары) темнеют без правок.
|
||||
- Модуль `theme.svelte.ts` по образцу locale: состояние, персист, применение
|
||||
атрибута. Стартовую установку делает скрипт из `app.html`, модуль
|
||||
синхронизирует runes-состояние после гидрации.
|
||||
|
||||
## 3. Палитра
|
||||
|
||||
- Переопределяем: `--bg`, `--surface`, `--border`, `--text`, `--text-muted`.
|
||||
- Акцент `#2563eb` не меняется; `--accent-hover` — светлее для тёмного фона.
|
||||
- `--danger` осветлить для читаемости.
|
||||
- `--check-a`/`--check-b` шахматки — приглушённые серые: паттерн прозрачности остаётся узнаваемым, но не слепит.
|
||||
- `--check-a`/`--check-b` шахматки — приглушённые серые: паттерн прозрачности
|
||||
остаётся узнаваемым, но не слепит.
|
||||
- `--shadow-card` — почти чёрная с низкой альфой вместо синей.
|
||||
- Все `color-mix(… var(--surface))` пересчитаются сами — проверить визуально легенды, recalc-бейдж, drag-оверлеи.
|
||||
- Все `color-mix(… var(--surface))` пересчитаются сами — проверить визуально
|
||||
легенды, recalc-бейдж, drag-оверлеи.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Инфраструктура** — `theme.svelte.ts`, скрипт в `app.html`, переключатель в шапке, черновая тёмная палитра. Тесты: персист выбора, игнор мусора в localStorage, применение атрибута (стаб document).
|
||||
- **B. Палитра** — выверка значений: герой, каталог, страница инструмента, цепочка, баннер ошибки, маска, текстовые инструменты; фокус-ринги и hover; обе локали.
|
||||
- **A. Инфраструктура** — `theme.svelte.ts`, скрипт в `app.html`, переключатель
|
||||
в шапке, черновая тёмная палитра. Тесты: персист выбора, игнор мусора в
|
||||
localStorage, применение атрибута (стаб document).
|
||||
- **B. Палитра** — выверка значений: герой, каталог, страница инструмента,
|
||||
цепочка, баннер ошибки, маска, текстовые инструменты; фокус-ринги и hover; обе
|
||||
локали.
|
||||
|
||||
## 5. Смоук
|
||||
|
||||
@@ -43,4 +57,5 @@ Grep: ни одного цвета вне токенов в компонента
|
||||
|
||||
## 7. Не делаем
|
||||
|
||||
Три позиции в переключателе; пользовательские цветовые схемы; автосмену по времени суток; отдельную тему для превью изображений.
|
||||
Три позиции в переключателе; пользовательские цветовые схемы; автосмену по
|
||||
времени суток; отдельную тему для превью изображений.
|
||||
|
||||
@@ -4,57 +4,87 @@
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
- Страница инструмента (`ToolPage.svelte`) разрослась: состояние процесса, загрузка файлов, вставка из буфера и разметка трёх зон живут в одном файле.
|
||||
- Назревают два требования к процессу: параметры применяются автоматически без кнопки; у части утилит на экране показывается одно — визуализация фильтра/маски, а скачивается (и в будущем передаётся следующему инструменту) другое — результат.
|
||||
- Страница инструмента (`ToolPage.svelte`) разрослась: состояние процесса,
|
||||
загрузка файлов, вставка из буфера и разметка трёх зон живут в одном файле.
|
||||
- Назревают два требования к процессу: параметры применяются автоматически без
|
||||
кнопки; у части утилит на экране показывается одно — визуализация
|
||||
фильтра/маски, а скачивается (и в будущем передаётся следующему инструменту)
|
||||
другое — результат.
|
||||
|
||||
## 2. Контракт «результат и маска» в реестре
|
||||
|
||||
- `run(img, params)` — как сейчас: возвращает итоговое изображение. Его скачивают и будут передавать дальше по цепочке.
|
||||
- Запись реестра получает опциональное поле `preview?(img, params)` — возвращает картинку для показа на экране (маску/фильтр).
|
||||
- Если у записи есть `preview`, в карточке результата появляется переключатель «Показать маску». Включён — на превью маска, выключен (по умолчанию) — результат. Скачивание всегда отдаёт результат `run`, независимо от положения переключателя.
|
||||
- Первая реализация — удаление цвета: в ядро добавляется чистая функция `colorMask(img, hex, tolerance)` — чёрно-белая маска, где удаляемые пиксели белые; с юнит-тестами; `preview` записи вызывает её.
|
||||
- `run(img, params)` — как сейчас: возвращает итоговое изображение. Его
|
||||
скачивают и будут передавать дальше по цепочке.
|
||||
- Запись реестра получает опциональное поле `preview?(img, params)` — возвращает
|
||||
картинку для показа на экране (маску/фильтр).
|
||||
- Если у записи есть `preview`, в карточке результата появляется переключатель
|
||||
«Показать маску». Включён — на превью маска, выключен (по умолчанию) —
|
||||
результат. Скачивание всегда отдаёт результат `run`, независимо от положения
|
||||
переключателя.
|
||||
- Первая реализация — удаление цвета: в ядро добавляется чистая функция
|
||||
`colorMask(img, hex, tolerance)` — чёрно-белая маска, где удаляемые пиксели
|
||||
белые; с юнит-тестами; `preview` записи вызывает её.
|
||||
|
||||
## 3. Декомпозиция страницы
|
||||
|
||||
Новая папка `components/tool/`; карточки — чисто отображательные, состояние живёт только в оркестраторе:
|
||||
Новая папка `components/tool/`; карточки — чисто отображательные, состояние
|
||||
живёт только в оркестраторе:
|
||||
|
||||
- **`SourceCard.svelte`** — левая половина сцены: пусто → зона перетаскивания; загружено → превью исходника под оверлеем замены с кнопкой «Заменить изображение».
|
||||
- **`ResultCard.svelte`** — правая половина: пустые состояния, превью результата или сводка, чип «Пересчёт…», кнопка скачивания, при наличии `preview` — переключатель маски.
|
||||
- **`ParamsCard.svelte`** — форма параметров и подпись «Изменения применяются автоматически»; для инструментов без параметров не выводится.
|
||||
- **`ToolPage.svelte`** остаётся оркестратором: состояние (исходник, результат, сводка, статус, ошибка, значения), декодирование файлов, вставка из буфера, сброс, токен запусков и эффект с задержкой. Ожидаемо худеет примерно вдвое.
|
||||
- **`SourceCard.svelte`** — левая половина сцены: пусто → зона перетаскивания;
|
||||
загружено → превью исходника под оверлеем замены с кнопкой «Заменить
|
||||
изображение».
|
||||
- **`ResultCard.svelte`** — правая половина: пустые состояния, превью результата
|
||||
или сводка, чип «Пересчёт…», кнопка скачивания, при наличии `preview` —
|
||||
переключатель маски.
|
||||
- **`ParamsCard.svelte`** — форма параметров и подпись «Изменения применяются
|
||||
автоматически»; для инструментов без параметров не выводится.
|
||||
- **`ToolPage.svelte`** остаётся оркестратором: состояние (исходник, результат,
|
||||
сводка, статус, ошибка, значения), декодирование файлов, вставка из буфера,
|
||||
сброс, токен запусков и эффект с задержкой. Ожидаемо худеет примерно вдвое.
|
||||
|
||||
Карточки переиспользуются будущим многошаговым режимом.
|
||||
|
||||
## 4. Реактивные параметры (в этом же проходе)
|
||||
|
||||
- Кнопки «Применить» больше нет ни у одного инструмента.
|
||||
- Изменение параметра запускает вычисление через 300 мс после последнего изменения; каждое новое изменение перезапускает таймер.
|
||||
- Токен запусков: если во время вычисления параметры поменялись, устаревший ответ отбрасывается.
|
||||
- Загрузка и замена файла считают сразу и подавляют ровно один авто-запуск эффекта.
|
||||
- Изменение параметра запускает вычисление через 300 мс после последнего
|
||||
изменения; каждое новое изменение перезапускает таймер.
|
||||
- Токен запусков: если во время вычисления параметры поменялись, устаревший
|
||||
ответ отбрасывается.
|
||||
- Загрузка и замена файла считают сразу и подавляют ровно один авто-запуск
|
||||
эффекта.
|
||||
- Ошибка пересчёта — баннер сверху, прежний результат не сбрасывается.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Декомпозиция** — перенос разметки в три карточки один к одному, поведение не меняется. Проверка: `check/build`, визуально идентично.
|
||||
- **B. Реактивность** — токен, задержка, чип пересчёта, минус кнопка. Проверка: смоук §6 пп. 1–6.
|
||||
- **C. Маска** — типы реестра, `colorMask` с тестами, переключатель в карточке результата, пример на удалении цвета. Проверка: тесты + смоук §6 пп. 7–9.
|
||||
- **A. Декомпозиция** — перенос разметки в три карточки один к одному, поведение
|
||||
не меняется. Проверка: `check/build`, визуально идентично.
|
||||
- **B. Реактивность** — токен, задержка, чип пересчёта, минус кнопка. Проверка:
|
||||
смоук §6 пп. 1–6.
|
||||
- **C. Маска** — типы реестра, `colorMask` с тестами, переключатель в карточке
|
||||
результата, пример на удалении цвета. Проверка: тесты + смоук §6 пп. 7–9.
|
||||
- **D. Итог** — полный прогон `test/check/build`.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Загрузка файла → результат появляется сразу.
|
||||
2. Изменить параметр → около 0.3 с и результат обновился сам.
|
||||
3. Быстро изменить яркость несколько раз → одна финальная версия, промежуточные не мелькают.
|
||||
3. Быстро изменить яркость несколько раз → одна финальная версия, промежуточные
|
||||
не мелькают.
|
||||
4. Во время пересчёта чип виден, предыдущий результат не исчезает.
|
||||
5. Инструмент без параметров и информационная страница ведут себя как раньше.
|
||||
6. Некорректные параметры (обрезка вне картинки) → баннер ошибки, прежний результат цел.
|
||||
7. Переключатель «Показать маску» у удаления цвета: включён — белым по чёрному видно, что удалится; выключен — обычный полупрозрачный результат.
|
||||
8. С включённой маской скачивание всё равно сохраняет прозрачный результат, а не маску.
|
||||
6. Некорректные параметры (обрезка вне картинки) → баннер ошибки, прежний
|
||||
результат цел.
|
||||
7. Переключатель «Показать маску» у удаления цвета: включён — белым по чёрному
|
||||
видно, что удалится; выключен — обычный полупрозрачный результат.
|
||||
8. С включённой маской скачивание всё равно сохраняет прозрачный результат, а не
|
||||
маску.
|
||||
9. Замена исходника возвращает переключатель маски в выключенное состояние.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- `ToolPage` содержит только оркестрацию состояния и эффектов; вся разметка панелей — в `components/tool/*`.
|
||||
- `ToolPage` содержит только оркестрацию состояния и эффектов; вся разметка
|
||||
панелей — в `components/tool/*`.
|
||||
- Кнопки «Применить» нет ни на одной странице.
|
||||
- Скачивание всегда соответствует `run`; экран может показывать `preview`.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
@@ -62,5 +92,7 @@
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Вынос вычислений в веб-воркер; настраиваемая задержка — фиксированные 300 мс.
|
||||
- Редактор масок (кисть, слои, история) — только автогенерация из параметров инструмента.
|
||||
- Передача результата между инструментами — придёт вместе с многошаговым режимом позже.
|
||||
- Редактор масок (кисть, слои, история) — только автогенерация из параметров
|
||||
инструмента.
|
||||
- Передача результата между инструментами — придёт вместе с многошаговым режимом
|
||||
позже.
|
||||
|
||||
@@ -4,48 +4,89 @@
|
||||
>
|
||||
> **Что сделано по этапам:**
|
||||
>
|
||||
> - A — общая MIME-валидация `isSupportedImage` + `unsupportedImageMessage` в `core/io.ts`; `DropZone` переведён на них ✔
|
||||
> - B — постоянная двухпанельная раскладка: одна карточка-«сцена» с равными половинами «Исходник | Результат», кнопки внутри своих половин, параметры — отдельной карточкой ниже; контент сайта растянут на всю ширину окна ✔
|
||||
> - C — Ctrl+V: `<svelte:window onpaste>`, поиск image-item в буфере, общая валидация; текстовые вставки не затрагиваются ✔
|
||||
> - D — drag-замена: компонент `DropOverlay` с вуалью-подсказкой и счётчиком `dragenter/dragleave` против мерцания; тот же счётчик в `DropZone` ✔
|
||||
> - E — верификация: тесты 85/85, svelte-check 0 ошибок, статический билд с 11 страницами; ручной смоук-чеклист §5 пройден ✔
|
||||
> - A — общая MIME-валидация `isSupportedImage` + `unsupportedImageMessage` в
|
||||
> `core/io.ts`; `DropZone` переведён на них ✔
|
||||
> - B — постоянная двухпанельная раскладка: одна карточка-«сцена» с равными
|
||||
> половинами «Исходник | Результат», кнопки внутри своих половин, параметры —
|
||||
> отдельной карточкой ниже; контент сайта растянут на всю ширину окна ✔
|
||||
> - C — Ctrl+V: `<svelte:window onpaste>`, поиск image-item в буфере, общая
|
||||
> валидация; текстовые вставки не затрагиваются ✔
|
||||
> - D — drag-замена: компонент `DropOverlay` с вуалью-подсказкой и счётчиком
|
||||
> `dragenter/dragleave` против мерцания; тот же счётчик в `DropZone` ✔
|
||||
> - E — верификация: тесты 85/85, svelte-check 0 ошибок, статический билд с 11
|
||||
> страницами; ручной смоук-чеклист §5 пройден ✔
|
||||
>
|
||||
> **Отступления от исходного плана в ходе реализации:** вместо пропа `overlay` у `DropZone` сделан отдельный компонент-обёртка `DropOverlay` (drag-события ловит контент под курсором); раскладка доработана по ревью — одна карточка равных половин вместо двух независимых колонок, сайт без ограничения ширины.
|
||||
> **Отступления от исходного плана в ходе реализации:** вместо пропа `overlay` у
|
||||
> `DropZone` сделан отдельный компонент-обёртка `DropOverlay` (drag-события
|
||||
> ловит контент под курсором); раскладка доработана по ревью — одна карточка
|
||||
> равных половин вместо двух независимых колонок, сайт без ограничения ширины.
|
||||
|
||||
## 1. Целевое поведение
|
||||
|
||||
1. **Постоянная двухпанельная раскладка** страницы инструмента — отдельного состояния «страница загрузки» больше нет:
|
||||
- **Слева — исходник**, панель есть всегда. Не загружено → пустое дроп-поле (drag&drop или клик). Загружено → превью исходника.
|
||||
- **Справа — результат**: до загрузки — `EmptyState` «Результат появится здесь»; во время обработки — `EmptyState` «Обработка…»; после — превью результата (для info-инструмента — `InfoPanel`).
|
||||
2. **Ctrl+V** — картинка из буфера обмена вставляется как исходник с любого места страницы.
|
||||
3. **Замена перетаскиванием** — новый файл, брошенный на левую панель с загруженной картинкой, заменяет исходник (с оверлеем-подсказкой во время перетаскивания).
|
||||
4. **Кнопка «Заменить»** под превью исходника (переносится туда текущая «Загрузить другое изображение»).
|
||||
1. **Постоянная двухпанельная раскладка** страницы инструмента — отдельного
|
||||
состояния «страница загрузки» больше нет:
|
||||
- **Слева — исходник**, панель есть всегда. Не загружено → пустое дроп-поле
|
||||
(drag&drop или клик). Загружено → превью исходника.
|
||||
- **Справа — результат**: до загрузки — `EmptyState` «Результат появится
|
||||
здесь»; во время обработки — `EmptyState` «Обработка…»; после — превью
|
||||
результата (для info-инструмента — `InfoPanel`).
|
||||
2. **Ctrl+V** — картинка из буфера обмена вставляется как исходник с любого
|
||||
места страницы.
|
||||
3. **Замена перетаскиванием** — новый файл, брошенный на левую панель с
|
||||
загруженной картинкой, заменяет исходник (с оверлеем-подсказкой во время
|
||||
перетаскивания).
|
||||
4. **Кнопка «Заменить»** под превью исходника (переносится туда текущая
|
||||
«Загрузить другое изображение»).
|
||||
|
||||
На мобильной ширине панели складываются в столбец: исходник сверху, результат снизу.
|
||||
На мобильной ширине панели складываются в столбец: исходник сверху, результат
|
||||
снизу.
|
||||
|
||||
## 2. Изменения по файлам
|
||||
|
||||
Новые компоненты не вводим — `EmptyState`, `Preview`, `DropZone` покрывают всё. Меняем три файла:
|
||||
Новые компоненты не вводим — `EmptyState`, `Preview`, `DropZone` покрывают всё.
|
||||
Меняем три файла:
|
||||
|
||||
- **`core/io.ts`** — экспорт `isSupportedImage(file): boolean`: общая MIME-проверка на базе `ACCEPTED_IMAGE_TYPES`.
|
||||
- **`DropZone.svelte`** — переходит на `isSupportedImage`; новый опциональный проп `overlay` — режим «невидимая зона приёма поверх контента» для замены перетаскиванием.
|
||||
- **`ToolPage.svelte`** — постоянная сетка из двух панелей; `<svelte:window onpaste>`; drag-оверлей левой панели; кнопки замены/сброса переезжают в левую панель; правая панель — три состояния через `EmptyState`.
|
||||
- **`core/io.ts`** — экспорт `isSupportedImage(file): boolean`: общая
|
||||
MIME-проверка на базе `ACCEPTED_IMAGE_TYPES`.
|
||||
- **`DropZone.svelte`** — переходит на `isSupportedImage`; новый опциональный
|
||||
проп `overlay` — режим «невидимая зона приёма поверх контента» для замены
|
||||
перетаскиванием.
|
||||
- **`ToolPage.svelte`** — постоянная сетка из двух панелей;
|
||||
`<svelte:window onpaste>`; drag-оверлей левой панели; кнопки замены/сброса
|
||||
переезжают в левую панель; правая панель — три состояния через `EmptyState`.
|
||||
|
||||
## 3. Этапы и проверки
|
||||
|
||||
- **A. Общая валидация** — `isSupportedImage` в `io.ts`, `DropZone` переведён на неё. Проверка: `pnpm check`.
|
||||
- **B. Раскладка** — две постоянные колонки в ToolPage; правая панель через EmptyState; кнопки уходят в левую панель; обёртка `{#if !source}` на всю страницу убирается. Проверка: визуальный осмотр всех инструментов, `check/build`.
|
||||
- **C. Ctrl+V** — `onpaste` на окне → поиск image-item в `clipboardData` → тот же `handleFile`; не-изображения игнорируются молча. Проверка: ручной смоук со скриншотом из буфера.
|
||||
- **D. Drag-замена** — оверлей DropZone(overlay) при dragover на левой панели; drop → `handleFile` нового файла. Проверка: ручной смоук «бросить файл на загруженный исходник».
|
||||
- **E. Итог** — полный ручной смоук-чеклист (§5). Проверка: `test/check/build` зелёные.
|
||||
- **A. Общая валидация** — `isSupportedImage` в `io.ts`, `DropZone` переведён на
|
||||
неё. Проверка: `pnpm check`.
|
||||
- **B. Раскладка** — две постоянные колонки в ToolPage; правая панель через
|
||||
EmptyState; кнопки уходят в левую панель; обёртка `{#if !source}` на всю
|
||||
страницу убирается. Проверка: визуальный осмотр всех инструментов,
|
||||
`check/build`.
|
||||
- **C. Ctrl+V** — `onpaste` на окне → поиск image-item в `clipboardData` → тот
|
||||
же `handleFile`; не-изображения игнорируются молча. Проверка: ручной смоук со
|
||||
скриншотом из буфера.
|
||||
- **D. Drag-замена** — оверлей DropZone(overlay) при dragover на левой панели;
|
||||
drop → `handleFile` нового файла. Проверка: ручной смоук «бросить файл на
|
||||
загруженный исходник».
|
||||
- **E. Итог** — полный ручной смоук-чеклист (§5). Проверка: `test/check/build`
|
||||
зелёные.
|
||||
|
||||
## 4. Нюансы и решения
|
||||
|
||||
- **Мерцание dragleave** — классическая проблема оверлеев: используем счётчик `dragenter/dragleave`, оверлей скрывается только когда счётчик обнулился и произошёл `drop`/`dragend`.
|
||||
- **Paste при фокусе в поле параметров** — обрабатываем всегда: если в буфере именно изображение, пользователь очевидно хочет заменить исходник; текстовые вставки обработчик не трогает.
|
||||
- **Форматы буфера** — скриншоты приходят как PNG, часть приложений отдаёт JPEG/WebP; все проходят общую `isSupportedImage`. GIF/BMP из буфера редки, но тоже допустимы.
|
||||
- **Info-инструменты** — ведут себя так же: слева исходник, справа после загрузки появляется `InfoPanel`.
|
||||
- **Состояние ошибки** — баннер остаётся сверху страницы; неудачная замена не сбрасывает старый исходник.
|
||||
- **Мерцание dragleave** — классическая проблема оверлеев: используем счётчик
|
||||
`dragenter/dragleave`, оверлей скрывается только когда счётчик обнулился и
|
||||
произошёл `drop`/`dragend`.
|
||||
- **Paste при фокусе в поле параметров** — обрабатываем всегда: если в буфере
|
||||
именно изображение, пользователь очевидно хочет заменить исходник; текстовые
|
||||
вставки обработчик не трогает.
|
||||
- **Форматы буфера** — скриншоты приходят как PNG, часть приложений отдаёт
|
||||
JPEG/WebP; все проходят общую `isSupportedImage`. GIF/BMP из буфера редки, но
|
||||
тоже допустимы.
|
||||
- **Info-инструменты** — ведут себя так же: слева исходник, справа после
|
||||
загрузки появляется `InfoPanel`.
|
||||
- **Состояние ошибки** — баннер остаётся сверху страницы; неудачная замена не
|
||||
сбрасывает старый исходник.
|
||||
|
||||
## 5. Чеклист ручного смоука
|
||||
|
||||
@@ -54,7 +95,8 @@
|
||||
3. Ctrl+V со скриншотом → исходник заменился, результат пересчитался.
|
||||
4. Drag файла на загруженную левую панель → оверлей виден, после drop — замена.
|
||||
5. Отмена перетаскивания (Esc / мимо) → оверлей скрылся, исходник не изменился.
|
||||
6. Бросок неподдерживаемого файла (например .txt) → баннер ошибки, старый исходник цел.
|
||||
6. Бросок неподдерживаемого файла (например .txt) → баннер ошибки, старый
|
||||
исходник цел.
|
||||
7. Кнопка «Заменить» открывает выбор файла.
|
||||
8. png-info: работает та же схема, справа сводка.
|
||||
9. Мобильная ширина: панели в столбец, порядок исходник → результат.
|
||||
@@ -62,7 +104,8 @@
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- На странице инструмента нет состояния «только дропзона на всю страницу» — раскладка двухпанельная всегда.
|
||||
- На странице инструмента нет состояния «только дропзона на всю страницу» —
|
||||
раскладка двухпанельная всегда.
|
||||
- Загрузка работает тремя способами: клик, drag&drop (включая замену), Ctrl+V.
|
||||
- Ошибочные файлы и отмена перетаскивания не портят текущий исходник.
|
||||
- `test/check/build` зелёные; чеклист §5 пройден.
|
||||
@@ -71,4 +114,5 @@
|
||||
|
||||
- Множественная загрузка / пакетная обработка — вне скоупа этого плана.
|
||||
- Камера/скриншот-инструменты захвата — только буфер обмена.
|
||||
- Прогресс-бары декодирования — файлы читаются быстро, достаточно индикации «Обработка…».
|
||||
- Прогресс-бары декодирования — файлы читаются быстро, достаточно индикации
|
||||
«Обработка…».
|
||||
|
||||
@@ -1,75 +1,130 @@
|
||||
# План: главная страница как рабочая область с цепочкой
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.** Заменяет идею отдельной страницы рабочего пространства.
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.** Заменяет идею отдельной страницы рабочего
|
||||
> пространства.
|
||||
|
||||
## 1. Модель
|
||||
|
||||
1. **Главная страница — это рабочая область.** Без выбранного инструмента на ней большой поиск: начните вводить название — список отфильтровывается; выбор разворачивает инструмент прямо здесь, без перехода.
|
||||
2. Развернутый инструмент выглядит как привычная страница: слева исходник, справа результат со своей кнопкой скачивания, параметры — в отдельной карточке под сценой.
|
||||
3. Под результатом рядом с кнопкой скачивания — кнопка **«⛓ Chain with…»**: добавляет пустую секцию, внутри которой такой же поиск; выбор заполняет секцию следующим инструментом. Так цепочка растёт сколько угодно.
|
||||
4. Каждая заполненная секция цепи — полноценная: свои параметры, своё превью, своя кнопка скачивания промежуточного результата. Крестик убирает секцию; кнопка «✂ Remove chain» снимает всю цепь разом (без подтверждения).
|
||||
5. Чекбокс «Показать маску» живёт в карточке параметров базового инструмента и влияет только на его превью: в цепь всегда передаётся сам результат.
|
||||
6. Сетка-каталог инструментов переезжает на отдельную страницу `/list-tools` — для SEO и визуального поиска. Основная работа происходит на главной; в шапке появляются ссылки на обе.
|
||||
7. Прямые адреса `/tools/<id>` сохраняются: тот же развернутый инструмент для глубоких ссылок.
|
||||
8. Цепочка переживает перезагрузку (автосохранение в localStorage). Экспорт/импорт файлом отложен до появления CLI/wasm-части.
|
||||
1. **Главная страница — это рабочая область.** Без выбранного инструмента на ней
|
||||
большой поиск: начните вводить название — список отфильтровывается; выбор
|
||||
разворачивает инструмент прямо здесь, без перехода.
|
||||
2. Развернутый инструмент выглядит как привычная страница: слева исходник,
|
||||
справа результат со своей кнопкой скачивания, параметры — в отдельной
|
||||
карточке под сценой.
|
||||
3. Под результатом рядом с кнопкой скачивания — кнопка **«⛓ Chain with…»**:
|
||||
добавляет пустую секцию, внутри которой такой же поиск; выбор заполняет
|
||||
секцию следующим инструментом. Так цепочка растёт сколько угодно.
|
||||
4. Каждая заполненная секция цепи — полноценная: свои параметры, своё превью,
|
||||
своя кнопка скачивания промежуточного результата. Крестик убирает секцию;
|
||||
кнопка «✂ Remove chain» снимает всю цепь разом (без подтверждения).
|
||||
5. Чекбокс «Показать маску» живёт в карточке параметров базового инструмента и
|
||||
влияет только на его превью: в цепь всегда передаётся сам результат.
|
||||
6. Сетка-каталог инструментов переезжает на отдельную страницу `/list-tools` —
|
||||
для SEO и визуального поиска. Основная работа происходит на главной; в шапке
|
||||
появляются ссылки на обе.
|
||||
7. Прямые адреса `/tools/<id>` сохраняются: тот же развернутый инструмент для
|
||||
глубоких ссылок.
|
||||
8. Цепочка переживает перезагрузку (автосохранение в localStorage).
|
||||
Экспорт/импорт файлом отложен до появления CLI/wasm-части.
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
- Страница инструмента уже собрана как самодостаточный компонент, принимающий запись реестра, — он и становится встраиваемым блоком. Маршруты `/tools/[id]` и главная используют один и тот же компонент: первая — по адресу из URL, вторая — по выбору из поиска.
|
||||
- Состояние главной: выбран ли инструмент; если да — рендерится блок инструмента, целиком владеющий своим исходником, параметрами, результатом и цепочкой.
|
||||
- Страница инструмента уже собрана как самодостаточный компонент, принимающий
|
||||
запись реестра, — он и становится встраиваемым блоком. Маршруты `/tools/[id]`
|
||||
и главная используют один и тот же компонент: первая — по адресу из URL,
|
||||
вторая — по выбору из поиска.
|
||||
- Состояние главной: выбран ли инструмент; если да — рендерится блок
|
||||
инструмента, целиком владеющий своим исходником, параметрами, результатом и
|
||||
цепочкой.
|
||||
- Смена инструмента на главной (через повторный поиск) сбрасывает блок целиком.
|
||||
- Цепочка хранится массивом шагов; у пустого звена инструмент ещё не выбран — такие звенья не сохраняются в localStorage и не участвуют в прогоне.
|
||||
- Исполнение — последовательная свёртка: выход каждого звена — вход следующего; результаты всех звеньев доступны их секциям. Общий авто-раннер даёт токен отмены и задержку ~300 мс.
|
||||
- Цепочка хранится массивом шагов; у пустого звена инструмент ещё не выбран —
|
||||
такие звенья не сохраняются в localStorage и не участвуют в прогоне.
|
||||
- Исполнение — последовательная свёртка: выход каждого звена — вход следующего;
|
||||
результаты всех звеньев доступны их секциям. Общий авто-раннер даёт токен
|
||||
отмены и задержку ~300 мс.
|
||||
|
||||
## 3. Переиспользование и доработки существующего
|
||||
|
||||
Готовое берётся как есть: `SourceCard`, `Preview`, `ParamForm`, `DownloadButton`, `EmptyState`, модуль пайплайна, авто-раннер, `PNG_OUTPUT`.
|
||||
Готовое берётся как есть: `SourceCard`, `Preview`, `ParamForm`,
|
||||
`DownloadButton`, `EmptyState`, модуль пайплайна, авто-раннер, `PNG_OUTPUT`.
|
||||
|
||||
Доработки:
|
||||
|
||||
- **Блок страницы инструмента** — расширяется секцией цепочки: список заполненных звеньев, каждое со своими параметрами, превью и скачиванием, плюс пустые звенья с поиском. Кнопки «⛓» и «✂ Remove chain» — рядом со скачиванием базового результата.
|
||||
- **`ParamsCard`** — опциональный блок «Показать маску» (флаг preview у инструмента + двусторонний флаг).
|
||||
- **Карточка результата** — упрощается: получает готовую картинку, маска уходит в карточку параметров.
|
||||
- **Существующая главная-каталог** — переезжает на маршрут `/list-tools` почти без изменений.
|
||||
- **Блок страницы инструмента** — расширяется секцией цепочки: список
|
||||
заполненных звеньев, каждое со своими параметрами, превью и скачиванием, плюс
|
||||
пустые звенья с поиском. Кнопки «⛓» и «✂ Remove chain» — рядом со скачиванием
|
||||
базового результата.
|
||||
- **`ParamsCard`** — опциональный блок «Показать маску» (флаг preview у
|
||||
инструмента + двусторонний флаг).
|
||||
- **Карточка результата** — упрощается: получает готовую картинку, маска уходит
|
||||
в карточку параметров.
|
||||
- **Существующая главная-каталог** — переезжает на маршрут `/list-tools` почти
|
||||
без изменений.
|
||||
|
||||
Удаляется: черновой маршрут рабочего пространства и его компоненты списка/селектора — вместо них новые, под вертикальную модель.
|
||||
Удаляется: черновой маршрут рабочего пространства и его компоненты
|
||||
списка/селектора — вместо них новые, под вертикальную модель.
|
||||
|
||||
## 4. Новые компоненты
|
||||
|
||||
- **`ToolSearch.svelte`** — поиск с нечётким совпадением: поле ввода, отфильтрованный список (регистронезависимая подпоследовательность по названию и описанию, приоритет совпадению с начала), клавиатура ↑↓ Enter Esc; два размера — герой на главной и компактный внутри пустого звена. Предлагает только инструменты-трансформеры.
|
||||
- **`ChainStep.svelte`** — секция одного звена: пустое звено показывает компактный поиск; заполненное — заголовок с номером и крестиком, форму параметров, превью результата, кнопку скачивания.
|
||||
- **`ChainBar.svelte`** — строка под результатом базового инструмента: «⛓ Chain with…» и «✂ Remove chain» (видна только при непустой цепи).
|
||||
- **`ToolSearch.svelte`** — поиск с нечётким совпадением: поле ввода,
|
||||
отфильтрованный список (регистронезависимая подпоследовательность по названию
|
||||
и описанию, приоритет совпадению с начала), клавиатура ↑↓ Enter Esc; два
|
||||
размера — герой на главной и компактный внутри пустого звена. Предлагает
|
||||
только инструменты-трансформеры.
|
||||
- **`ChainStep.svelte`** — секция одного звена: пустое звено показывает
|
||||
компактный поиск; заполненное — заголовок с номером и крестиком, форму
|
||||
параметров, превью результата, кнопку скачивания.
|
||||
- **`ChainBar.svelte`** — строка под результатом базового инструмента: «⛓ Chain
|
||||
with…» и «✂ Remove chain» (видна только при непустой цепи).
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Поиск и главная** — `ToolSearch`, герой поиска на главной, встраивание блока инструмента, перенос каталога на `/list-tools`, ссылки в шапке. Проверка: `check/build`, визуально.
|
||||
- **B. Цепочка** — состояние цепи в блоке инструмента, пустые звенья с поиском, прогон, промежуточные превью и скачивания, ошибки звеньев. Проверка: смоук §8 пп. 1–6.
|
||||
- **C. Маска и упрощение** — блок маски в `ParamsCard`, упрощение карточки результата. Проверка: смоук §8 п. 7.
|
||||
- **D. Хранение и итог** — автосохранение и восстановление цепочки, удаление чернового маршрута рабочего пространства, полный регресс каталога, `test/check/build`.
|
||||
- **A. Поиск и главная** — `ToolSearch`, герой поиска на главной, встраивание
|
||||
блока инструмента, перенос каталога на `/list-tools`, ссылки в шапке.
|
||||
Проверка: `check/build`, визуально.
|
||||
- **B. Цепочка** — состояние цепи в блоке инструмента, пустые звенья с поиском,
|
||||
прогон, промежуточные превью и скачивания, ошибки звеньев. Проверка: смоук §8
|
||||
пп. 1–6.
|
||||
- **C. Маска и упрощение** — блок маски в `ParamsCard`, упрощение карточки
|
||||
результата. Проверка: смоук §8 п. 7.
|
||||
- **D. Хранение и итог** — автосохранение и восстановление цепочки, удаление
|
||||
чернового маршрута рабочего пространства, полный регресс каталога,
|
||||
`test/check/build`.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. На главной — большое поле поиска; ввод фильтрует список по мере набора; Enter выбирает подсвеченный.
|
||||
2. Выбранный инструмент разворачивается на той же странице: исходник, параметры, результат.
|
||||
3. «⛓ Chain with…» добавляет пустую секцию с компактным поиском; выбор заполняет её.
|
||||
1. На главной — большое поле поиска; ввод фильтрует список по мере набора; Enter
|
||||
выбирает подсвеченный.
|
||||
2. Выбранный инструмент разворачивается на той же странице: исходник, параметры,
|
||||
результат.
|
||||
3. «⛓ Chain with…» добавляет пустую секцию с компактным поиском; выбор заполняет
|
||||
её.
|
||||
4. У каждой секции своё превью и своё скачивание промежуточного результата.
|
||||
5. Крестик убирает секцию; «✂ Remove chain» снимает все звенья разом.
|
||||
6. Ошибка звена — баннер «Шаг N (имя): …»; последние успешные результаты секций не исчезают.
|
||||
7. «Показать маску» переключает превью базового инструмента; в цепь идёт результат, скачивание базового отдаёт результат, не маску.
|
||||
8. Перезагрузка восстанавливает цепочку заполненных звеньев; пустые звенья при этом исчезают.
|
||||
6. Ошибка звена — баннер «Шаг N (имя): …»; последние успешные результаты секций
|
||||
не исчезают.
|
||||
7. «Показать маску» переключает превью базового инструмента; в цепь идёт
|
||||
результат, скачивание базового отдаёт результат, не маску.
|
||||
8. Перезагрузка восстанавливает цепочку заполненных звеньев; пустые звенья при
|
||||
этом исчезают.
|
||||
9. Быстрые правки подряд — один финальный пересчёт всей вертикали.
|
||||
10. `/list-tools` открывает старый каталог по категориям; прямые адреса `/tools/<id>` работают.
|
||||
10. `/list-tools` открывает старый каталог по категориям; прямые адреса
|
||||
`/tools/<id>` работают.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Работа начинается с главной: поиск → инструмент → цепочка звеньями, каждый со своим превью и скачиванием.
|
||||
- Отдельной страницы рабочего пространства нет; каталог доступен на `/list-tools`; прямые ссылки инструментов работают.
|
||||
- Работа начинается с главной: поиск → инструмент → цепочка звеньями, каждый со
|
||||
своим превью и скачиванием.
|
||||
- Отдельной страницы рабочего пространства нет; каталог доступен на
|
||||
`/list-tools`; прямые ссылки инструментов работают.
|
||||
- Пустые звенья не попадают в сохранение и не ломают прогон.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Ветвления и прицепывание от середины цепи — новое звено всегда добавляется в конец.
|
||||
- Ветвления и прицепывание от середины цепи — новое звено всегда добавляется в
|
||||
конец.
|
||||
- Пипетка и маски внутри звеньев цепи — они остаются фичей базового инструмента.
|
||||
- Экспорт и импорт цепочки файлом — отложено до появления CLI/wasm-части.
|
||||
- Несколько параллельных инструментов на главной одновременно — один активный блок за раз.
|
||||
- Несколько параллельных инструментов на главной одновременно — один активный
|
||||
блок за раз.
|
||||
|
||||
+165
-12
@@ -1,22 +1,175 @@
|
||||
# Backlog
|
||||
|
||||
## Мелочи всякие
|
||||
|
||||
- [ ] сгенерировать или найти файлы для ручной проверки `A. Краевые PNG-файлы`
|
||||
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
||||
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
||||
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть
|
||||
в кнопке Download
|
||||
- [ ] Кнопка download должна сразу писать какой формат сейчас, быть с селектором
|
||||
формата и подсвечивать предупреждение если формат не совместим (альфа
|
||||
канал)
|
||||
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
||||
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
||||
шрифты с кириллицей? сделать галочку "только с кириллицей"???
|
||||
- [ ] Посмотреть темную тему - слишком темная???
|
||||
- [ ] на картинке результате при работе дергается высота надписи (высота иконки)
|
||||
- [ ] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно)
|
||||
[баг в хроме](https://issues.chromium.org/issues/556160936)
|
||||
`Uncaught TypeError: Cannot read properties of undefined (reading 'startTime')`
|
||||
`at et.reportAllChanges (anonymous:2:19429)`
|
||||
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
||||
- [ ] не работают генераторы картинок (create-empty-png)
|
||||
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
|
||||
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
|
||||
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность
|
||||
(range?)
|
||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
||||
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
||||
значения
|
||||
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
|
||||
изменено
|
||||
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
||||
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
||||
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
||||
- [ ] обновить страницу kit или сделать storybook
|
||||
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
|
||||
удаление, добавление инструментов. переход один инструмент - pipeline без
|
||||
перезагрузки
|
||||
|
||||
## Переезд
|
||||
|
||||
- [ ] составить план переноса preview -> корень, старый корень -> old
|
||||
- [ ] добавить i18n в preview
|
||||
|
||||
## Баги preview
|
||||
|
||||
Зарегистрированы при написании e2e (`web/e2e/`, `pnpm --dir web test:e2e`) и
|
||||
ручного чек-листа — см. `docs/checklist-manual-testing.md` (раздел G). Каждый
|
||||
зафиксирован как `test.fixme`. После фикса бага: убрать соответствующий
|
||||
`test.fixme` и отметить пункт G в чек-листе как пройденный.
|
||||
|
||||
1. **Генераторы без кнопки «Generate»** — все 21 инструмент категории GENERATE
|
||||
открываются, но в UI нет кнопки Generate и полей схемы (только RU/EN/Reset);
|
||||
результат через UI недостижим. В `SchemaToolView.svelte` `isGenerator`
|
||||
определяется как `tool.generate && !tool.run`, кнопка рисуется только если
|
||||
`ongenerate` прокинут, а для генераторов с дефолтным `input: "file"` — нет.
|
||||
Ожидание: поля схемы + кнопка «Generate», результат-картинка по дефолтам.
|
||||
Fixme: `web/e2e/generators.spec.ts`. Кнопки и не должно быть, все процессы
|
||||
должны запускаться автоматически
|
||||
2. **resize-png с дефолтами падает** — схема по умолчанию `size: 0×0` +
|
||||
`keepAspect: true` → при загрузке ЛЮБОГО PNG сразу `errors.resizeSize`
|
||||
(alert), результата нет. `registry-new/geometry.ts`. Ожидание: осмысленный
|
||||
дефолт-размер (например, исходный) либо кнопка, а не авто-ошибка.
|
||||
3. **crop-png с дефолтами падает** — аналогично: `width: 0, height: 0` → сразу
|
||||
`errors.cropSize`. `registry-new/geometry.ts`. Ожидание: базовый кроп по
|
||||
умолчанию либо внятное предзаполнение.
|
||||
4. **Сырые ключи ошибок в UI** — alert на tool-странице показывает сырой ключ
|
||||
i18n (`errors.resizeSize`), а не сообщение из `en.ts` / `ru.ts` (там
|
||||
`resizeSize: "Width and/or height must be positive"`). Проверяется на
|
||||
resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке.
|
||||
Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»).
|
||||
|
||||
## Идеи
|
||||
|
||||
1. **Менять первый инструмент цепочки** — оценка M.
|
||||
Сейчас базовый инструмент зафиксирован при открытии страницы; хочется заменить его на другой без разборки цепочки: шаги и их параметры сохраняются, вход перечитывается. Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔ file).
|
||||
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
|
||||
зафиксирован при открытии страницы; хочется заменить его на другой без
|
||||
разборки цепочки: шаги и их параметры сохраняются, вход перечитывается.
|
||||
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
|
||||
file).
|
||||
|
||||
2. **UI-эксперимент: параметры между исходником и результатом** — оценка S/M.
|
||||
Раскладка «Исходник → Параметры → Результат» в один ряд вместо параметров отдельным блоком снизу. Проверить на узких экранах; возможно за флагом/A-B, чтобы сравнить с текущей.
|
||||
Ждет нового дизайна
|
||||
Раскладка «Исходник → Параметры → Результат» в один ряд вместо параметров
|
||||
отдельным блоком снизу. Проверить на узких экранах; возможно за флагом/A-B,
|
||||
чтобы сравнить с текущей. Перекрыто редизайном: старый план
|
||||
(`docs/archive/plan-inline-params.md`) закрыт, страницы инструментов теперь
|
||||
`SchemaToolView`/`SchemaPreview`. Эксперимент переосмыслить уже для нового
|
||||
этапа инструментов (сетка source|params|result), если понадобится.
|
||||
|
||||
3. **Сворачивать инструменты в chain** — оценка S.
|
||||
Тоггл сворачивания звена до заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости помнить в workspace-pipeline.
|
||||
3. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
|
||||
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
|
||||
помнить в workspace-pipeline.
|
||||
|
||||
4. **Несколько цепочек: сохранение и загрузка** — оценка M.
|
||||
Именованные цепочки в localStorage (список, создать/переименовать/удалить), быстрое переключение. Формат экспортного JSON расширить опциональным полем имени, старые файлы читаются как безымянные.
|
||||
4. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
|
||||
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
|
||||
Формат экспортного JSON расширить опциональным полем имени, старые файлы
|
||||
читаются как безымянные.
|
||||
|
||||
5. **Batch-обработка архивов выбранным chain** — оценка L.
|
||||
Вход — zip-архив картинок (распаковка в браузере), прогон текущего или выбранного сохранённого chain по каждому файлу, сборка результата обратно в zip для скачивания. Зависит от п.4 (выбор цепочки) и от экспортного формата pipeline JSON; прогресс и ошибки — пофайлово.
|
||||
5. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
|
||||
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
|
||||
chain по каждому файлу, сборка результата обратно в zip для скачивания.
|
||||
Зависит от п.4 (выбор цепочки) и от экспортного формата pipeline JSON;
|
||||
прогресс и ошибки — пофайлово.
|
||||
|
||||
6. **Избранные инструменты (fav tools)** — оценка S/M.
|
||||
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке страницы инструмента; список id в localStorage. Избранное показывается отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к popularity при скоринге).
|
||||
6. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
|
||||
карточках каталога, результатах поиска и в шапке страницы инструмента; список
|
||||
id в localStorage. Избранное показывается отдельной секцией сверху каталога и
|
||||
поднимается в выдаче поиска (бонус к popularity при скоринге).
|
||||
|
||||
7. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
|
||||
— оценка M/L. Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др.
|
||||
форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого
|
||||
— бесшовное внедрение выбора формата/качества прямо в кнопку/диалог
|
||||
**Download**: юзер скачивает результат в нужном формате (mime/ext/quality), а
|
||||
pipeline при этом не усложняется лишним звеном. **Сделано (2026-09):**
|
||||
механическая часть реализована в preview — `ToolEntry.output`
|
||||
(`OutputFormat`: mime/ext/qualityParamId) в `registry-new/types.ts`;
|
||||
`SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены;
|
||||
`png-to-bmp` переведён сюда же). **Осталось (UX-райз «Download»):**
|
||||
- выбор формата/качества прямо в кнопке/диалоге Download для любого
|
||||
результата (не отдельными конвертерами);
|
||||
- экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png`
|
||||
(целевой KB, бинарный поиск по k из квантования). Проработать вместе с
|
||||
достройкой download-флоу нового UI.
|
||||
|
||||
8. **`watermark-image-png` (водяной знак картинкой)** — оценка M. Отложен из
|
||||
миграции (`archive/plan-migrate-remaining.md`). Нужна overlay-механика:
|
||||
второй источник изображения (getOverlay/store), параметры scale/opacity/
|
||||
position/margin/aspect. Решается вместе с вопросом, как второй источник
|
||||
картинки вписывается в schema-driven preview (`SchemaToolView`).
|
||||
|
||||
9. **`png-info` (детальная информация о PNG)** — оценка M/L. Отложен из
|
||||
миграции. Отдельный райз: exif-теги, редактирование, структурированный вывод;
|
||||
нужен свой отдельный случай в UI, не «ещё один text/verdict».
|
||||
|
||||
10. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
|
||||
reverse-area) — ждут UI выделения области на превью. Из
|
||||
`archive/plan-gap-waves.md` («вне очереди»).
|
||||
|
||||
11. **Мультифайловый вывод** (split-parts, gif-frames, separate-colors) — ждут
|
||||
механизма «результат = набор файлов». Из `archive/plan-gap-waves.md`.
|
||||
|
||||
12. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
|
||||
решение о формате. Из `archive/plan-gap-waves.md`.
|
||||
|
||||
13. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
|
||||
`archive/plan-gap-waves.md`.
|
||||
|
||||
14. **Переезд старых файлов в папки `old/`** — оценка M. Шаг 37 закрытого
|
||||
`archive/plan-composite-params.md` (и `archive/plan-migrate-remaining.md`):
|
||||
перенос `lib/registry.ts`, `lib/registry/**`, `lib/registry-helpers.ts`,
|
||||
`lib/categories.ts`, `lib/tools/**`, `lib/components/**` (кроме `kit/`) в
|
||||
`lib/old/**`; в настройке isolation-плагина — один glob-паттерн
|
||||
(`lib/old/**`), «новое»/«общее» не меняются. Предшествует C17
|
||||
(`plan-redesign.md`, Шаг 5). Гейты: svelte-check 0, тесты зелёные, prettier
|
||||
чист, `lint:all` = задокументированный остаток, обе ветки открываются.
|
||||
|
||||
15. **Техдолг (чинить по заведённым задачам, не игнорировать правила)**:
|
||||
- svelte-check error `ToolCard.svelte:19:36` (`goto(string)`).
|
||||
- фейл `palette.test.ts` (ENOENT `src/app.css` — из-за него `pnpm test`
|
||||
красный, хотя тесты 607 passed).
|
||||
- `lint:all`: ~335 design-tokens ошибок в старых kit-компонентах;
|
||||
неиспользуемые токены preview.css; a11y-warning `ColorField`; пустой
|
||||
ruleset `Toggle.svelte`. Целевое состояние перед переездом `old/` (см.
|
||||
№14): svelte-check 0 errors, `pnpm test` зелёный, `lint:all` — только
|
||||
задокументированный остаток.
|
||||
|
||||
16. **Контраст текста на акцентных кнопках — пересмотреть после готовности
|
||||
сайта**. Текст `--color-background` на фоне `--color-main`/`--color-accent`
|
||||
(тон 48) даёт контраст ~4.1–4.3 (light) — ниже AA 4.5 для обычного текста;
|
||||
`palette.test.ts` держит для кнопок порог 4.0 (AA large-text) осознанно, как
|
||||
компромисс текущего дизайна. После полного завершения редизайна пересмотреть
|
||||
тона (например поднять tone кнопок до ~55) и вернуть порог 4.5. Также
|
||||
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
|
||||
light-danger тон 48 = 4.25 на границе.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Чек-лист ручного тестирования preview
|
||||
|
||||
> Вспомогательный документ к `docs/archive/plan-testing-preview.md` и e2e-тестам
|
||||
> (`web/e2e/`, `pnpm --dir web test:e2e`). Автоматизированная часть закрывает
|
||||
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
|
||||
>
|
||||
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
|
||||
> → `node scripts/serve-static.mjs`).
|
||||
>
|
||||
> Критерий приёмки большинства пунктов: **нет зависит, нет «залипания»,
|
||||
> сообщения об ошибках человекочитаемые**.
|
||||
|
||||
## A. Краевые PNG-файлы
|
||||
|
||||
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
|
||||
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
|
||||
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
|
||||
remove-background, feather-edges, clean-edges дают корректные края.
|
||||
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется.
|
||||
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
||||
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
||||
pipeline.spec.ts).
|
||||
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
|
||||
размер, прозрачность).
|
||||
|
||||
## B. Края параметров
|
||||
|
||||
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate — не
|
||||
«залипает» running.
|
||||
- [ ] Пустые/NaN поля (напр. width/height) — санитизация (silent repair), нет
|
||||
красного alert-текста в виде `errors.*`.
|
||||
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
|
||||
именные цвета) — silent repair или понятная ошибка.
|
||||
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет
|
||||
(кейс из плана п.14).
|
||||
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
||||
|
||||
## C. Буфер обмена и drag-n-drop
|
||||
|
||||
- [ ] В реальном браузере кнопка copy на текстовом результате (png-to-base64 и
|
||||
т.п.) кладёт текст в буфер (в Playwright не тестируем из-за permissions).
|
||||
- [ ] Drag-n-drop файла на dropzone страницы инструмента (не только файл-диалог)
|
||||
— UX-ощущения и корректность.
|
||||
|
||||
## D. Производительность и отзывчивость
|
||||
|
||||
- [ ] Долгие авто-рераны на больших картинках (jpeg-artifacts, dithering,
|
||||
quantize на 4К) — UI не фризит, канселяция/дебаунс срабатывает.
|
||||
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
|
||||
залипаний, панели не прыгают.
|
||||
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
|
||||
(утечек blob-URL/bitmap быть не должно).
|
||||
|
||||
## E. Визуальный слой (доделываем по ходу)
|
||||
|
||||
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
|
||||
desktop) на всех 4 маршрутах.
|
||||
- [ ] Тема light/dark: контраст вердиктов (Yes/No), статус «LIVE PREVIEW»,
|
||||
мета-инфо бликов не даёт.
|
||||
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
|
||||
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
|
||||
|
||||
## F. Субъективная корректность
|
||||
|
||||
- [ ] dithering / two-colors / quantize — результат «по ощущениям» соответствует
|
||||
описанию.
|
||||
- [ ] watermark-tile / add-text / date-stamp на кириллице — рендер текста
|
||||
корректный (не «кракозябры»).
|
||||
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
||||
|
||||
## Как долго
|
||||
|
||||
- Полный прогон: ~40 минут (все разделы, включая мегапиксели и 4К) в двух
|
||||
браузерах.
|
||||
- Быстрый смоук (< 15 мин): A (кроме мегапикселей), B (выборочно), C, F
|
||||
выборочно, G обязательно.
|
||||
@@ -1,49 +0,0 @@
|
||||
# План: параметры между исходником и результатом
|
||||
|
||||
> Статус: план к выполнению.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Альтернативная раскладка этапа инструмента: три колонки «Исходник | Параметры | Результат» на широких экранах, вертикальный стек на узких. Цель — отдать вариант дизайнеру как отправную точку. Откат к текущей раскладке — замена одной строки в `ToolPage`.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Два взаимозаменяемых компонента этапа с одинаковым набором пропсов; `ToolPage` рендерит один тег. Попробовать новое — поменять компонент в этом теге; вернуть старое — поменять обратно. Никаких флагов и конфигов.
|
||||
- Общая типизация пропсов в одном файле рядом с компонентами; сами карточки (`SourceCard`, `ResultCard`, `TextInputCard`, `ParamsCard`) переиспользуются как есть — вся логика исполнения, маски, пипетки и автозапуска остаётся в `ToolPage` и приходит сверху.
|
||||
- Chain-звенья используют ту же панельную систему «Вход | Параметры | Результат» — общие классы `.pane` / `.pane-legend` / `.pane-params` вынесены в app.css, вертикальные поля в узкой колонке заданы один раз глобально.
|
||||
- Один порог отзывчивости: три колонки от ~75rem, ниже — стек «исходник → параметры → результат». Промежуточные перестроения не придумываем до фидбека дизайнера.
|
||||
|
||||
## 3. Компоненты
|
||||
|
||||
```txt
|
||||
src/lib/components/stage/
|
||||
stage-props.ts # интерфейс StageProps + сборщик дефолтов
|
||||
ToolStageClassic.svelte # текущая разметка .tool-stage, перенесена 1:1 из ToolPage
|
||||
ToolStageInline.svelte # новая сетка [source | params | result]
|
||||
```
|
||||
|
||||
- `ToolStageClassic` — вырезка существующего шаблона со стилями; после извлечения страница должна быть пиксель-в-пиксель прежней.
|
||||
- `ToolStageInline` — grid-колонки `minmax(0, 1fr) | фикс-ширина параметров | minmax(0, 1fr)`; легенда «Параметры» над средней колонкой в том же стиле edge-legend.
|
||||
- Модификаторы состояний через классы на корне компонента: `no-params` (инструменты без параметров → две колонки), `no-source` (генераторы → параметры и результат), текстовый вход до декодирования занимает левую ячейку как сейчас.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Извлечение классики** — перенос разметки и стилей этапа из `ToolPage` в `ToolStageClassic` без изменений поведения; пропсы собираются в одном объекте. Критерий: страница неотличима, diff по логике `ToolPage` нулевой.
|
||||
- **B. Инлайн-раскладка** — `ToolStageInline`, переключение в `ToolPage` на новый компонент, прогон смоука §5. Ревью в браузере; решение дизайнера — потом, код уже позволяет жить в обоих вариантах.
|
||||
|
||||
## 5. Смоук
|
||||
|
||||
1. Замена компонента этапа — одна строка; обе версии собираются без предупреждений.
|
||||
2. Инструмент без параметров (например Автоконтраст): две колонки, пустой середины нет.
|
||||
3. Генератор без исходника: параметры и результат в две колонки.
|
||||
4. Текстовый инструмент до декодирования: вход слева, параметры в центре, пустое состояние результата справа.
|
||||
5. Маска, пипетка, слайдеры в средней колонке; изменение параметра перезапускает инструмент как раньше.
|
||||
6. Узкий экран: стек без горизонтального скролла, порядок исходник → параметры → результат.
|
||||
7. Легенды присутствуют у всех трёх зон в обеих раскладках.
|
||||
|
||||
## 6. Что сознательно не делаем
|
||||
|
||||
- Редизайн визуального языка (цвета, типографика, формы) — материал для дизайнера, не для этого плана.
|
||||
- Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает про исполнение.
|
||||
+198
-180
@@ -1,38 +1,41 @@
|
||||
# План: переезд на новый дизайн (refs) — параллельная сборка
|
||||
|
||||
> Статус: план к выполнению. Шаги 5–6 (C17–C21) **отложены** до вывода нового
|
||||
> дизайна на приемлемый уровень верности — см. `docs/plan-design-fix.md`
|
||||
> (Этапы 0–1: рефакторинг + верность по `demo-fidelity.md`).
|
||||
> Статус: план к выполнению. Шаги 5–6 (C17–C21) остаются **следующим этапом**.
|
||||
> Они больше не привязаны к «верности против рефа»:
|
||||
> `docs/archive/plan-design-fix.md` закрыт (2026-09-07, дизайн ушёл вперёд от
|
||||
> `refs-html/`), аудит против рефа не информативен и прекращён. Дальнейшее
|
||||
> качество — ручное ревью + `lint:all`. Шаг 37 (переезд старых файлов в папки
|
||||
> `old/`) вынесен в `backlog.md`.
|
||||
>
|
||||
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
||||
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
||||
>
|
||||
> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка
|
||||
> общих компонентов на месте с алиасами токенов) — **параллельная сборка**:
|
||||
> новый дизайн строится отдельно, изолируется от старого, проверяется
|
||||
> целиком, затем становится основным и старый удаляется. Старое приложение
|
||||
> остаётся рабочим и шippable на всём протяжении перехода.
|
||||
> новый дизайн строится отдельно, изолируется от старого, проверяется целиком,
|
||||
> затем становится основным и старый удаляется. Старое приложение остаётся
|
||||
> рабочим и шippable на всём протяжении перехода.
|
||||
|
||||
## 0. Почему меняем стратегию
|
||||
|
||||
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые
|
||||
токены как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие
|
||||
компоненты редактируются вживую → регресс старого UI во время работы,
|
||||
новый дизайн нельзя принять изолированно.
|
||||
- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые
|
||||
страницы рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем
|
||||
его основным, (5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги.
|
||||
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые токены
|
||||
как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие компоненты
|
||||
редактируются вживую → регресс старого UI во время работы, новый дизайн нельзя
|
||||
принять изолированно.
|
||||
- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые страницы
|
||||
рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем его основным,
|
||||
(5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги.
|
||||
- **Главный технический риск и его решение.** Сейчас `web/src/app.css`
|
||||
импортируется в **корневом** `web/src/routes/+layout.svelte:2` и потому
|
||||
грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать
|
||||
новый CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без
|
||||
наследования**: (а) убрать `import '../app.css'` из корневого layout;
|
||||
(б) старые маршруты обернуть группой `(old)/` с собственным layout,
|
||||
импортирующим `app.css`; (в) новые маршруты (`preview/*`) — собственный
|
||||
layout, импортирующий `design2.css`. SvelteKit делает code-splitting CSS
|
||||
по маршрутам, а стили компонентов скоупятся сами — поэтому глобальные
|
||||
стили каждого дизайна живут только в своей ветке, и префикс-обёртка
|
||||
`.ds2` не нужна. Старые `ui/` и страницы не трогаются до фазы 5.
|
||||
грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать новый
|
||||
CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без
|
||||
наследования**: (а) убрать `import '../app.css'` из корневого layout; (б)
|
||||
старые маршруты обернуть группой `(old)/` с собственным layout, импортирующим
|
||||
`app.css`; (в) новые маршруты (`preview/*`) — собственный layout,
|
||||
импортирующий `design2.css`. SvelteKit делает code-splitting CSS по маршрутам,
|
||||
а стили компонентов скоупятся сами — поэтому глобальные стили каждого дизайна
|
||||
живут только в своей ветке, и префикс-обёртка `.ds2` не нужна. Старые `ui/` и
|
||||
страницы не трогаются до фазы 5.
|
||||
|
||||
## 1. Источники и их оценка
|
||||
|
||||
@@ -55,10 +58,11 @@
|
||||
`panel-heading`, `setting-group`, `settings-footer`, `settings-panel`,
|
||||
`segmented`, `status`, `step-card` (`step-index`/`step-heading`/`step-type`/
|
||||
`step-body`/`drag`), `color-field` (`swatch`/`native-color`),
|
||||
`preview-toolbar`/`checker` (`tile-canvas`/`remover-canvas`/`comparison-grid`),
|
||||
`result-meta`/`meta-row`, `tool-card`/`image-card` (`tool-icon`/`tool-index`/
|
||||
`tool-copy`), `code-block`, `icon-btn`, `download-btn`, `reset-btn`, `lede`,
|
||||
`auto-note`, `label`, `toggle`/`toggle-group`, `direction-grid`, `gradient-bar`.
|
||||
`preview-toolbar`/`checker`
|
||||
(`tile-canvas`/`remover-canvas`/`comparison-grid`), `result-meta`/`meta-row`,
|
||||
`tool-card`/`image-card` (`tool-icon`/`tool-index`/ `tool-copy`),
|
||||
`code-block`, `icon-btn`, `download-btn`, `reset-btn`, `lede`, `auto-note`,
|
||||
`label`, `toggle`/`toggle-group`, `direction-grid`, `gradient-bar`.
|
||||
- **Дыры в refs-html:** нет состояний empty/error/loading, нет глобальных
|
||||
модалок/тултипов, i18n не представлен (все лейблы — английские брендовые).
|
||||
Решения — см. §9.
|
||||
@@ -66,9 +70,9 @@
|
||||
## 2. Токены (фундамент нового дизайна)
|
||||
|
||||
Живут в `src/lib/styles/design2.css` как обычный `:root { … }` (плюс
|
||||
`[data-theme='dark'] { … }`). Файл грузится **только** layout'ом новой
|
||||
ветки (§5), поэтому глобально не конфликтует со старым `app.css`. Маппинг
|
||||
старых токенов `web/src/app.css` → новые:
|
||||
`[data-theme='dark'] { … }`). Файл грузится **только** layout'ом новой ветки
|
||||
(§5), поэтому глобально не конфликтует со старым `app.css`. Маппинг старых
|
||||
токенов `web/src/app.css` → новые:
|
||||
|
||||
| Старый | Новый | Light | Dark |
|
||||
| -------------- | -------------- | --------- | ------------------------ |
|
||||
@@ -88,45 +92,45 @@
|
||||
- Радиусы: один `--radius: 4px` (мягкость старой темы уходит).
|
||||
- Шрифты: `IBM Plex Sans` + `IBM Plex Mono`, self-host через `@fontsource`
|
||||
(offline-first, CDN нельзя). Mono — рабочий шрифт для лейблов/метаданных/
|
||||
цифр; Sans — заголовки и текст. Задаются на `.app-shell` (корень новой
|
||||
ветки), чтобы не зависеть от старых глобальных правил `body`.
|
||||
- Фон-сетка (32px blueprint-grid) и шахматка канвасов — утилиты `.page-grid`
|
||||
и `.checker-canvas` внутри `.ds2`, не токены.
|
||||
- Тёмная тема — на `[data-theme='dark']` (инлайн-скрипт уже есть в
|
||||
`app.html`); класс `.dark-mode` из рефа не переносим, используем наш
|
||||
механизм. На переходный период **никаких алиасов старых токенов** (старые
|
||||
страницы сами живут на своём `app.css`).
|
||||
цифр; Sans — заголовки и текст. Задаются на `.app-shell` (корень новой ветки),
|
||||
чтобы не зависеть от старых глобальных правил `body`.
|
||||
- Фон-сетка (32px blueprint-grid) и шахматка канвасов — утилиты `.page-grid` и
|
||||
`.checker-canvas` внутри `.ds2`, не токены.
|
||||
- Тёмная тема — на `[data-theme='dark']` (инлайн-скрипт уже есть в `app.html`);
|
||||
класс `.dark-mode` из рефа не переносим, используем наш механизм. На
|
||||
переходный период **никаких алиасов старых токенов** (старые страницы сами
|
||||
живут на своём `app.css`).
|
||||
|
||||
## 3. Базовые примитивы дизайн-системы (все — компоненты)
|
||||
|
||||
Правило фазы 1: **любой повторяющийся паттерн — компонент**, даже «просто
|
||||
div с двумя стилями». Никакой копипасты классов между страницами. Каждый
|
||||
примитив ниже — отдельный `.svelte` в `src/lib/components/kit/`.
|
||||
Правило фазы 1: **любой повторяющийся паттерн — компонент**, даже «просто div с
|
||||
двумя стилями». Никакой копипасты классов между страницами. Каждый примитив ниже
|
||||
— отдельный `.svelte` в `src/lib/components/kit/`.
|
||||
|
||||
- `MonoLabel` — микро-лейбл: mono 10px, uppercase, letter-spacing .1em,
|
||||
muted; слот-модификатор accent (синие eyebrow/version).
|
||||
- `StatusDot` / `StatusLine` — точка `--success` + подпись
|
||||
(«AUTO PIPELINE», «LIVE PREVIEW»).
|
||||
- `MonoLabel` — микро-лейбл: mono 10px, uppercase, letter-spacing .1em, muted;
|
||||
слот-модификатор accent (синие eyebrow/version).
|
||||
- `StatusDot` / `StatusLine` — точка `--success` + подпись («AUTO PIPELINE»,
|
||||
«LIVE PREVIEW»).
|
||||
- `Panel` / `PanelHeading` / `SettingGroup` / `SettingsFooter` — панель с
|
||||
1px-бордером, составные части с разделителями.
|
||||
- `CheckerCanvas` — рабочая поверхность превью: тёмная подложка + шахматка.
|
||||
- `MetaRow` / `MetaList` — пары «CAPTION значение».
|
||||
- `Segmented` — сегмент-переключатель (язык, тип градиента, пресеты).
|
||||
- `Badge` — тег типа шага (BACKGROUND/TRANSFORM/STYLE), чип «✓ AUTO».
|
||||
- `StepCard` — нумерованная карточка этапа: индекс `01`, drag-handle,
|
||||
Badge типа, заголовок, кнопка удаления; слот для параметров.
|
||||
- `StepCard` — нумерованная карточка этапа: индекс `01`, drag-handle, Badge
|
||||
типа, заголовок, кнопка удаления; слот для параметров.
|
||||
- `ToolCard` — карточка каталога (icon, index, title, copy, arrow).
|
||||
- `ImageCard` / `PreviewTile` — label сверху + canvas-превью.
|
||||
- `AppShell` — корневой контейнер `.app-shell` + `.page-grid` (обёртка
|
||||
новой ветки маршрутов); задаёт шрифты и фон нового дизайна.
|
||||
- `AppShell` — корневой контейнер `.app-shell` + `.page-grid` (обёртка новой
|
||||
ветки маршрутов); задаёт шрифты и фон нового дизайна.
|
||||
- `BlueprintGrid` — фоновая сетка (используется в `AppShell`).
|
||||
- `IconButton`, `Icon` (обёртка `lucide-svelte`, 16–17px, muted).
|
||||
|
||||
## 4. Минимальный UI-kit (`src/lib/components/kit/`)
|
||||
|
||||
Контролы, поверх примитивов §3. API проектируем сразу под новый дизайн
|
||||
(обратной совместимости со старым `ui/` не нужно — старые страницы его
|
||||
продолжают использовать).
|
||||
Контролы, поверх примитивов §3. API проектируем сразу под новый дизайн (обратной
|
||||
совместимости со старым `ui/` не нужно — старые страницы его продолжают
|
||||
использовать).
|
||||
|
||||
| Компонент | Назначение |
|
||||
| ------------------------- | ----------------------------------------------------------------- |
|
||||
@@ -154,33 +158,33 @@ div с двумя стилями». Никакой копипасты класс
|
||||
- **Корневой layout `src/routes/+layout.svelte`** перестаёт импортировать
|
||||
`../app.css`. В нём остаётся только общая логика (init темы/locale) и
|
||||
нейтральный `<div>{children}</div>` без глобальных стилей дизайна.
|
||||
- **Старая ветка `src/routes/(old)/+layout.svelte`** импортирует `app.css`
|
||||
и содержит текущую шапку/подвал (разметку из нынешнего корневого layout).
|
||||
Все старые страницы переносятся внутрь `(old)/` (группа в скобках не
|
||||
меняет URL): `(old)/+page.svelte`, `(old)/demo/...`,
|
||||
`(old)/list-tools/...`, `(old)/tools/[id]/...`.
|
||||
- **Новая ветка `src/routes/preview/+layout.svelte`** импортирует
|
||||
`design2.css` и рендерит `<AppShell>`. Сюда же — новые страницы (§6).
|
||||
- Благодаря code-splitting CSS в SvelteKit: на старых маршрутах грузится
|
||||
только `app.css`, на `preview/*` — только `design2.css`. Скоуп стилей
|
||||
- **Старая ветка `src/routes/(old)/+layout.svelte`** импортирует `app.css` и
|
||||
содержит текущую шапку/подвал (разметку из нынешнего корневого layout). Все
|
||||
старые страницы переносятся внутрь `(old)/` (группа в скобках не меняет URL):
|
||||
`(old)/+page.svelte`, `(old)/demo/...`, `(old)/list-tools/...`,
|
||||
`(old)/tools/[id]/...`.
|
||||
- **Новая ветка `src/routes/preview/+layout.svelte`** импортирует `design2.css`
|
||||
и рендерит `<AppShell>`. Сюда же — новые страницы (§6).
|
||||
- Благодаря code-splitting CSS в SvelteKit: на старых маршрутах грузится только
|
||||
`app.css`, на `preview/*` — только `design2.css`. Скоуп стилей
|
||||
Svelte-компонентов добавляет второй уровень изоляции. Никаких `.ds2`.
|
||||
- Переключатель для ручной проверки: временный флаг `previewDesign` в
|
||||
`localStorage` + кнопка в старой шапке («Preview v2»), ведущая на
|
||||
`/preview`. На фазе 4 флаг убираем.
|
||||
`localStorage` + кнопка в старой шапке («Preview v2»), ведущая на `/preview`.
|
||||
На фазе 4 флаг убираем.
|
||||
|
||||
> Почему не `(new)`-группа с теми же путями вместо `preview/*`: две группы
|
||||
> не могут одновременно определять один и тот же URL (`(old)/+page.svelte`
|
||||
> и `(new)/+page.svelte` оба → `/` = конфликт маршрутов). Поэтому новая
|
||||
> ветка живёт под префиксом `preview/`, а на фазе 4 файлы переносятся на
|
||||
> реальные пути и группа `(old)` удаляется.
|
||||
> Почему не `(new)`-группа с теми же путями вместо `preview/*`: две группы не
|
||||
> могут одновременно определять один и тот же URL (`(old)/+page.svelte` и
|
||||
> `(new)/+page.svelte` оба → `/` = конфликт маршрутов). Поэтому новая ветка
|
||||
> живёт под префиксом `preview/`, а на фазе 4 файлы переносятся на реальные пути
|
||||
> и группа `(old)` удаляется.
|
||||
>
|
||||
> Это совпадает с рекомендованным в документации SvelteKit паттерном:
|
||||
> «put your entire app inside one or more groups except the routes that
|
||||
> should not inherit the common layouts» — старое целиком в `(old)`, новое
|
||||
> снаружи. Синтаксис `@`-breakout (`+layout@.svelte`, `+page@.svelte`) здесь
|
||||
> не требуется: «выломаться» можно лишь до корня, а нам нужно, чтобы именно
|
||||
> у корня НЕ было `app.css`; если позже захотим держать новые маршруты внутри
|
||||
> группы, `@` пригодится для сброса родительского layout.
|
||||
> Это совпадает с рекомендованным в документации SvelteKit паттерном: «put your
|
||||
> entire app inside one or more groups except the routes that should not inherit
|
||||
> the common layouts» — старое целиком в `(old)`, новое снаружи. Синтаксис
|
||||
> `@`-breakout (`+layout@.svelte`, `+page@.svelte`) здесь не требуется:
|
||||
> «выломаться» можно лишь до корня, а нам нужно, чтобы именно у корня НЕ было
|
||||
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
|
||||
> пригодится для сброса родительского layout.
|
||||
|
||||
## 6. Маршрутизация параллельных страниц
|
||||
|
||||
@@ -200,55 +204,50 @@ src/routes/preview/list-tools/+page.svelte
|
||||
src/routes/preview/tools/[id]/+page.svelte
|
||||
```
|
||||
|
||||
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы
|
||||
не изменяются (только перемещены в `(old)/`).
|
||||
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты
|
||||
(move-файлы), группа `(old)/` удаляется, `design2.css` подключается в
|
||||
корневом `+layout.svelte` (как новый основной слой), временный флаг/кнопка
|
||||
убираются.
|
||||
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы не
|
||||
изменяются (только перемещены в `(old)/`).
|
||||
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты (move-файлы),
|
||||
группа `(old)/` удаляется, `design2.css` подключается в корневом
|
||||
`+layout.svelte` (как новый основной слой), временный флаг/кнопка убираются.
|
||||
|
||||
## 7. Фазы выполнения (1–5 по запросу)
|
||||
|
||||
Каждая фаза заканчивается зелёными `test`/`check`/`build` и ревью в браузере
|
||||
(обе темы) против `refs-html/`.
|
||||
|
||||
- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все
|
||||
примитивы §3 и контролы §4 как отдельные компоненты. Критерий:
|
||||
демо-страница kit'а (`/preview` или временная) показывает все состояния
|
||||
(light/dark, hover/disabled, все варианты Button/Segmented/Badge);
|
||||
unit-тесты форм не падают; **старые страницы визуально не изменились**
|
||||
(изоляция работает).
|
||||
- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все примитивы
|
||||
§3 и контролы §4 как отдельные компоненты. Критерий: демо-страница kit'а
|
||||
(`/preview` или временная) показывает все состояния (light/dark,
|
||||
hover/disabled, все варианты Button/Segmented/Badge); unit-тесты форм не
|
||||
падают; **старые страницы визуально не изменились** (изоляция работает).
|
||||
|
||||
- **Фаза 2 — Новые страницы.** Под `preview/*` (§6): воркспейс, demo,
|
||||
list-tools, tool-page (двухпанельная раскладка из рефа: settings-panel +
|
||||
preview-panel, step-cards для chain). Критерий: разметка соответствует
|
||||
`refs-html/index.html`, `gradient.html`, `background-remover.html`,
|
||||
`list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает
|
||||
в новом стиле; старые маршруты продолжают работать как раньше.
|
||||
`list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает в
|
||||
новом стиле; старые маршруты продолжают работать как раньше.
|
||||
|
||||
- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран,
|
||||
800px), скриншоты против `refs-html/`, прогон старого тест-сьюта для
|
||||
доказательства отсутствия регресса, RU/EN-локализация новых лейблов.
|
||||
Критерий: визуальное совпадение с рефом, зелёные тесты, записанные
|
||||
скриншоты приняты.
|
||||
- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран, 800px),
|
||||
скриншоты против `refs-html/`, прогон старого тест-сьюта для доказательства
|
||||
отсутствия регресса, RU/EN-локализация новых лейблов. Критерий: визуальное
|
||||
совпадение с рефом, зелёные тесты, записанные скриншоты приняты.
|
||||
|
||||
- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*` →
|
||||
реальные маршруты; `design2.css` подключается в корневом `+layout.svelte`;
|
||||
удаление preview-группы и временного флага/кнопки. Старые страницы теперь
|
||||
не используются (мёртвый код), но физически ещё в репо.
|
||||
Критерий: реальные маршруты отдают новый дизайн, тесты зелёные, билд без
|
||||
предупреждений.
|
||||
- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*` → реальные
|
||||
маршруты; `design2.css` подключается в корневом `+layout.svelte`; удаление
|
||||
preview-группы и временного флага/кнопки. Старые страницы теперь не
|
||||
используются (мёртвый код), но физически ещё в репо. Критерий: реальные
|
||||
маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений.
|
||||
|
||||
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого
|
||||
`ui/`, старого `app.css` (и его токенов), неиспользуемых классов;
|
||||
переименование `kit/` → `ui/` (финальное имя) либо оставление `kit/` как
|
||||
основного — решается на фазе 1. Финальный проход по брейкпоинтам
|
||||
1200/1100/800/480. Критерий: `grep` по старым токенам/классам пуст,
|
||||
build чистый, старый дизайн нигде не упоминается.
|
||||
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`,
|
||||
старого `app.css` (и его токенов), неиспользуемых классов; переименование
|
||||
`kit/` → `ui/` (финальное имя) либо оставление `kit/` как основного — решается
|
||||
на фазе 1. Финальный проход по брейкпоинтам 1200/1100/800/480. Критерий:
|
||||
`grep` по старым токенам/классам пуст, build чистый, старый дизайн нигде не
|
||||
упоминается.
|
||||
|
||||
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы
|
||||
(~1–2 дня), Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка
|
||||
(~полдня).
|
||||
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня),
|
||||
Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня).
|
||||
|
||||
## 8. Смоук (после фазы 3, контроль на фазе 4)
|
||||
|
||||
@@ -260,35 +259,34 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
шахматке.
|
||||
4. Chain из 3+ инструментов: step-cards рендерятся, удаление/сброс работают.
|
||||
5. Узкий экран 800px: настройки над превью, sticky выключается как в рефе.
|
||||
6. RU/EN: новые лейблы локализованы; mono-лейблы рефа — либо ключи i18n,
|
||||
либо осознанно английские брендовые элементы (см. §9).
|
||||
6. RU/EN: новые лейблы локализованы; mono-лейблы рефа — либо ключи i18n, либо
|
||||
осознанно английские брендовые элементы (см. §9).
|
||||
|
||||
## 9. Открытые вопросы
|
||||
|
||||
- **Имя финального каталога компонентов:** оставить `kit/` как основное имя
|
||||
или переименовать в `ui/` на фазе 5 (удалив старое `ui/`). Рекомендую
|
||||
сразу писать в `kit/`, на фазе 5 переименовать в `ui/`. Решить на фазе 1.
|
||||
- **Имя финального каталога компонентов:** оставить `kit/` как основное имя или
|
||||
переименовать в `ui/` на фазе 5 (удалив старое `ui/`). Рекомендую сразу писать
|
||||
в `kit/`, на фазе 5 переименовать в `ui/`. Решить на фазе 1.
|
||||
- **Изоляция через layout:** `app.css` вынесен из корневого layout в
|
||||
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
|
||||
Проверить на фазе 1, что на `/preview/*` не попадают стили старого
|
||||
дизайна (DevTools → вкладка Styles, нет правил из `app.css`).
|
||||
- **Переключатель для проверки:** рекомендую `localStorage`-флаг +
|
||||
кнопка «Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
|
||||
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
|
||||
(DevTools → вкладка Styles, нет правил из `app.css`).
|
||||
- **Переключатель для проверки:** рекомендую `localStorage`-флаг + кнопка
|
||||
«Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
|
||||
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
|
||||
ключи, но допустить часть английских брендовых («приборная панель»).
|
||||
- **Версия в шапке/футере:** из `package.json` через `define` или
|
||||
захардкодить — мелочь, решить при сборке topbar.
|
||||
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.**
|
||||
После внедрения нового дизайна все правки дизайна делаются сразу в
|
||||
продакшен-коде (`design2.css` + `kit/`), а не в `refs/`. Механизм
|
||||
синхронизации «ref → код» не нужен и не создаётся. `refs/` и `refs-html/`
|
||||
можно удалить из репо на фазе 5 (по желанию), они не являются частью
|
||||
рантайма.
|
||||
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
|
||||
мелочь, решить при сборке topbar.
|
||||
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
|
||||
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
|
||||
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
|
||||
не нужен и не создаётся. `refs/` и `refs-html/` можно удалить из репо на фазе
|
||||
5 (по желанию), они не являются частью рантайма.
|
||||
|
||||
## 10. План исполнения (мелкие коммиты)
|
||||
|
||||
Ветка `feat/redesign`. Каждый шаг — отдельный коммит, цель < ~500 строк.
|
||||
Перед стартом: `test`/`build` зелёные на базе (якорь регрессии).
|
||||
Ветка `feat/redesign`. Каждый шаг — отдельный коммит, цель < ~500 строк. Перед
|
||||
стартом: `test`/`build` зелёные на базе (якорь регрессии).
|
||||
|
||||
### Шаг 0. Изоляция layout (без дизайна)
|
||||
|
||||
@@ -297,13 +295,15 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
`<div>{children}</div>`.
|
||||
- [x] **[C2]** Создать `routes/(old)/+layout.svelte` (импорт `app.css` + старая
|
||||
шапка/подвал) и перенести старые страницы (`+page`, `demo`, `list-tools`,
|
||||
`tools/[id]`) в `(old)/`. URL не меняются. Проверка: старый сайт идентичен.
|
||||
_(файлы — move, объём строк минимален)_
|
||||
`tools/[id]`) в `(old)/`. URL не меняются. Проверка: старый сайт
|
||||
идентичен. _(файлы — move, объём строк минимален)_
|
||||
|
||||
### Шаг 1. Фундамент нового дизайна
|
||||
|
||||
- [x] **[C3]** Создать `src/lib/styles/design2.css` (токены `:root` + `[data-theme='dark']` по §2) и подключить `@fontsource/ibm-plex-sans` + `@fontsource/ibm-plex-mono` (self-host, offline-first).
|
||||
подключить шрифты в `design2.css` (self-host, offline-first).
|
||||
- [x] **[C3]** Создать `src/lib/styles/design2.css` (токены `:root` +
|
||||
`[data-theme='dark']` по §2) и подключить `@fontsource/ibm-plex-sans` +
|
||||
`@fontsource/ibm-plex-mono` (self-host, offline-first). подключить шрифты
|
||||
в `design2.css` (self-host, offline-first).
|
||||
- [x] **[C4]** _(слит в C3: шрифты нельзя подключить до создания `design2.css`)_
|
||||
`[data-theme='dark']` (таблица §2). Без утилитарных классов.
|
||||
- [x] **[C5]** Примитивы batch 1: `AppShell`, `BlueprintGrid`, `CheckerCanvas`,
|
||||
@@ -318,85 +318,103 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
|
||||
### Шаг 2. Витрина kit (проверка компонентов)
|
||||
|
||||
- [x] **[C8]** `preview/+layout.svelte` (импорт `design2.css` + `<AppShell>` + переключатель темы) и
|
||||
временная `preview/kit` страница со всеми состояниями (light/dark,
|
||||
hover/disabled, варианты Button/Segmented/Badge и пр.)
|
||||
- [x] **[C8]** `preview/+layout.svelte` (импорт `design2.css` + `<AppShell>` +
|
||||
переключатель темы) и временная `preview/kit` страница со всеми
|
||||
состояниями (light/dark, hover/disabled, варианты Button/Segmented/Badge и
|
||||
пр.)
|
||||
|
||||
### Шаг 3. Новые страницы
|
||||
|
||||
- [x] **[C9]** `/preview` — воркспейс (eyebrow, h1, lede, поиск, «открыть последний»).
|
||||
Поиск фильтрует `TOOLS` из `$lib/registry`; карточки результата пока
|
||||
ведут на `#` (реальные `/preview/tools/[id]` подключим на C12, иначе
|
||||
prerender падает на 404).
|
||||
- [x] **[C9]** `/preview` — воркспейс (eyebrow, h1, lede, поиск, «открыть
|
||||
последний»). Поиск фильтрует `TOOLS` из `$lib/registry`; карточки
|
||||
результата пока ведут на `#` (реальные `/preview/tools/[id]` подключим на
|
||||
C12, иначе prerender падает на 404).
|
||||
- [x] **[C10]** `/preview/list-tools` — каталог карточек (группировка по
|
||||
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
|
||||
подключим на C12).
|
||||
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
|
||||
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
|
||||
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`); охватывает background-remover и прочие.
|
||||
- [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги (параметры inline) + «Add step» (chainable), живой прогон пайплайна на исходнике, превью каждого шага + итог + Download.
|
||||
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью),
|
||||
статичный приёмочный стенд.
|
||||
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel +
|
||||
preview-panel (gradient live, остальные — плейсхолдер); `NumberField`,
|
||||
`entries()` для prerender, ToolCard-ссылки подключены.
|
||||
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source
|
||||
инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`);
|
||||
охватывает background-remover и прочие.
|
||||
- [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги
|
||||
(параметры inline) + «Add step» (chainable), живой прогон пайплайна на
|
||||
исходнике, превью каждого шага + итог + Download.
|
||||
|
||||
### Шаг 4. Переключатель и проверка
|
||||
|
||||
- [x] **[C15]** В старой шапке ссылка «Preview v2» → `/preview`, ставит `localStorage['easy-png-tools:preview-v2']='1'`.
|
||||
- [x] **[C16]** База под дизайн: рабочий переключатель тем (persist в localStorage, применяется на `<html>`; проверено — фон `#eef1f4`↔`#11171d`), `TopBar` приведён к виду `demo.html` (бренд + синий хлебный крош `/* страница */`, безрамочные иконки, язык — active = `--foreground`/`--background`), страницы `/preview/*` на всю ширину (убран `max-width`). Точечное выравнивание к рефу отложено: реф-дизайн неконсистентен.
|
||||
(доказательство отсутствия регресса). Фиксы по результатам — отдельными
|
||||
мелкими коммитами.
|
||||
- [x] **[C15]** В старой шапке ссылка «Preview v2» → `/preview`, ставит
|
||||
`localStorage['easy-png-tools:preview-v2']='1'`.
|
||||
- [x] **[C16]** База под дизайн: рабочий переключатель тем (persist в
|
||||
localStorage, применяется на `<html>`; проверено — фон
|
||||
`#eef1f4`↔`#11171d`), `TopBar` приведён к виду `demo.html` (бренд + синий
|
||||
хлебный крош `/* страница */`, безрамочные иконки, язык — active =
|
||||
`--foreground`/`--background`), страницы `/preview/*` на всю ширину (убран
|
||||
`max-width`). Точечное выравнивание к рефу отложено: реф-дизайн
|
||||
неконсистентен. (доказательство отсутствия регресса). Фиксы по результатам
|
||||
— отдельными мелкими коммитами.
|
||||
|
||||
### Шаг 5. Сделать новый дизайн основным — ОТЛОЖЕНО
|
||||
### Шаг 5. Сделать новый дизайн основным — СЛЕДУЮЩИЙ ЭТАП
|
||||
|
||||
> Выполняется только после Этапов 0–1 из `docs/plan-design-fix.md` (новый
|
||||
> дизайн доведён до приемлемой верности). Пока не трогаем `preview/*` и
|
||||
> Раньше планировалось после «верности к рефу» (Этапы 0–1
|
||||
> `docs/archive/plan-design-fix.md`) — план закрыт, гейт снят (2026-09-07).
|
||||
> Перед стартом C17 выполнить переезд старых файлов в папки `old/` (backlog:
|
||||
> «Переезд старых файлов в `old/`», шаг 37 закрытого
|
||||
> `docs/archive/plan-composite-params.md`). Пока не трогаем `preview/*` и
|
||||
> `(old)/`.
|
||||
|
||||
- [ ] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
||||
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый shell.
|
||||
Убрать флаг/кнопку. **И обновить `newCode` в `web/eslint.config.js`:** заменить
|
||||
`src/routes/preview/**` на `src/routes/**` (после переезда все маршруты —
|
||||
новый дизайн), компонентный glob `src/lib/components/kit/**` пока оставить.
|
||||
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
|
||||
shell. Убрать флаг/кнопку. **И обновить `newCode` в
|
||||
`web/eslint.config.js`:** заменить `src/routes/preview/**` на
|
||||
`src/routes/**` (после переезда все маршруты — новый дизайн), компонентный
|
||||
glob `src/lib/components/kit/**` пока оставить.
|
||||
- [ ] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
|
||||
|
||||
### Шаг 6. Удаление старого
|
||||
|
||||
- [ ] **[C19]** Удалить старое `ui/`, прочие старые компоненты, старый `app.css`,
|
||||
неиспользуемые классы. Переименовать `kit/` → `ui/` (если решено на C1 —
|
||||
тогда в `eslint.config.js` обновить и компонентный glob).
|
||||
- [ ] **[C19]** Удалить старое `ui/`, прочие старые компоненты, старый
|
||||
`app.css`, неиспользуемые классы. Переименовать `kit/` → `ui/` (если
|
||||
решено на C1 — тогда в `eslint.config.js` обновить и компонентный glob).
|
||||
**И раскрыть ESLint `recommended` на весь код** — убрать scoped-блок
|
||||
`newCode` в `web/eslint.config.js` (см. AGENTS.md), прогнать `lint` и
|
||||
починить всплывшие ошибки в оставшемся коде.
|
||||
_Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) —
|
||||
это не «старый дизайн», у них свой линт-долг; расширение `recommended` на
|
||||
них может выдать много ошибок. Решить на C19: либо чиним сразу, либо
|
||||
расширяем scoped только на `src/routes/**` + `src/lib/components/**`._
|
||||
починить всплывшие ошибки в оставшемся коде. _Примечание: доменные модули
|
||||
(`lib/core`, `lib/registry`, тесты и т.п.) — это не «старый дизайн», у них
|
||||
свой линт-долг; расширение `recommended` на них может выдать много ошибок.
|
||||
Решить на C19: либо чиним сразу, либо расширяем scoped только на
|
||||
`src/routes/**` + `src/lib/components/**`._
|
||||
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
||||
предупреждений; `grep` по старым токенам/классам пуст.
|
||||
- [ ] **[C21]** _(опционально)_ Удалить `refs/` и `refs-html/` из репо — они
|
||||
больше не источник правды.
|
||||
|
||||
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7),
|
||||
> разбивать на под-коммиты по 2–4 компонента. Тесты/билд прогонять после
|
||||
> каждого коммита, чтобы регрессия локализовалась одним шагом назад.
|
||||
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
||||
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
||||
> чтобы регрессия локализовалась одним шагом назад.
|
||||
>
|
||||
> **Атомарность и точки «поломки» между коммитами.**
|
||||
>
|
||||
> - C1+C2 уже сделаны вместе (связаны: нейтральный root без `(old)`-layout
|
||||
> ломает старый сайт). Аналогично связанных пар больше нет.
|
||||
> - C3 и C4 слиты: шрифты подключаются внутри `design2.css`, поэтому файл
|
||||
> должен существовать в том же коммите.
|
||||
> - Коммиты **C3→C16 не трогают старый сайт** `(old)/` — они только
|
||||
> добавляют новые файлы/маршруты, сломать старый сайт на них нельзя.
|
||||
> - C3 и C4 слиты: шрифты подключаются внутри `design2.css`, поэтому файл должен
|
||||
> существовать в том же коммите.
|
||||
> - Коммиты **C3→C16 не трогают старый сайт** `(old)/` — они только добавляют
|
||||
> новые файлы/маршруты, сломать старый сайт на них нельзя.
|
||||
> - **C17 — единственный атомарный переключатель**: перенос `preview/*` на
|
||||
> реальные пути + удаление `(old)/` + смена root-layout должны быть в
|
||||
> ОДНОМ коммите (иначе конфликт маршрутов или пропажа страниц). Не дробить.
|
||||
> - **C19 должен идти строго после C17** (старый `app.css`/`ui/` удаляем
|
||||
> только когда old-сайт уже выключен).
|
||||
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7)
|
||||
> должны существовать до страниц, которые их импортируют (C8–C14).
|
||||
> реальные пути + удаление `(old)/` + смена root-layout должны быть в ОДНОМ
|
||||
> коммите (иначе конфликт маршрутов или пропажа страниц). Не дробить.
|
||||
> - **C19 должен идти строго после C17** (старый `app.css`/`ui/` удаляем только
|
||||
> когда old-сайт уже выключен).
|
||||
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны
|
||||
> существовать до страниц, которые их импортируют (C8–C14).
|
||||
|
||||
## Бэклог полировки (после C9–C21, отдельным проходом)
|
||||
|
||||
Конкретные расхождения витрины `preview/kit` с `refs-html/`. Править
|
||||
системно в конце, а не по ходу сборки базы.
|
||||
Конкретные расхождения витрины `preview/kit` с `refs-html/`. Править системно в
|
||||
конце, а не по ходу сборки базы.
|
||||
|
||||
- **Toggle** в дизайне прямоугольный, у нас — закруглённый. Сделать
|
||||
прямоугольным (или уточнить радиус у свича отдельно от общих токенов).
|
||||
@@ -404,11 +422,11 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
ненулевой `--radius` для всех бордеров. Проверить токен `--radius` против
|
||||
референсов; возможно он должен быть 0 (или близко), а скругления оставить
|
||||
только там, где они реально есть в дизайне.
|
||||
- **DownloadButton**: нет отступа между текстом и размером файла
|
||||
(`size`-чип). Добавить `gap`/отступ внутри кнопки.
|
||||
- **DownloadButton**: нет отступа между текстом и размером файла (`size`-чип).
|
||||
Добавить `gap`/отступ внутри кнопки.
|
||||
- **Поля ввода (controls)**: выглядят без паддингов (возможно артефакт самой
|
||||
витрины, но визуально плохо). Проверить паддинги в `TextField`/`SelectField`/
|
||||
`ColorField`/`SliderField` против референса и поправить.
|
||||
- **Segmented**: на витрине последняя кнопка растянута и нечитаемо, что это
|
||||
сегмент. Проверить, как `Segmented` выглядит в реальных раскладках
|
||||
(C9–C14); возможно нужен фиксированный/авто размер сегментов или перенос.
|
||||
сегмент. Проверить, как `Segmented` выглядит в реальных раскладках (C9–C14);
|
||||
возможно нужен фиксированный/авто размер сегментов или перенос.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# План: закрытие техдолга перед переездом `old/` (C17)
|
||||
|
||||
> Статус: план к выполнению. Ветка `feat/redesign`. Входит в гейт перед
|
||||
> переездом старых файлов в `old/` (backlog №14; см. также
|
||||
> `archive/plan-migrate-remaining.md` — техдолг «чинить по заведённым задачам,
|
||||
> не игнорировать правилами»). Целевое состояние по завершении: `svelte-check` →
|
||||
> 0 errors, `pnpm test` → зелёный, `lint:all` → без ошибок (допустим только
|
||||
> задокументированный остаток), дизайн-токены → единый словарь `preview.css`.
|
||||
|
||||
## Состав техдолга (на 2026-09-07)
|
||||
|
||||
- **svelte-check error** — `ToolCard.svelte:19:36`: `resolve(normalizedHref)`
|
||||
принимает только типизированный роут, а `normalizedHref: string`. Красный
|
||||
svelte-check, висит на каждом прогоне.
|
||||
- **фейл теста** — `src/lib/palette.test.ts`: `ENOENT src/app.css` (файл
|
||||
разъехался на `old.css` + `design2.css` при редизайне). Тест проверял
|
||||
контрасты палитры по токенам старого `app.css`
|
||||
(`text/surface/bg/link/ accent/danger/border`). При гейтах `pnpm test`
|
||||
красный, хотя 607 тестов проходят.
|
||||
- **design-tokens в `kit/**`** — ~335 ошибок `lint:all` от локального плагина
|
||||
`eslint-plugins/design-tokens` (захардкоженные цвета/размеры/длительности/
|
||||
z-index/`color-mix()` в `<style>`-блоках svelte), плюс stylelint по CSS.
|
||||
- **a11y-предупреждение** — `ColorField.svelte`.
|
||||
- **пустой ruleset** — `Toggle.svelte`.
|
||||
|
||||
## Этапы
|
||||
|
||||
### Этап 1 — «зелёные» гейты (мелкие, по ~10–40 строк каждый)
|
||||
|
||||
1. **ToolCard** — убрать ошибку типов: либо валидировать `href` против набора
|
||||
известных маршрутов, либо явно протащить типизированный роут из каталога.
|
||||
Проверка: svelte-check 0 errors.
|
||||
2. **palette.test.ts** — переписать чтение на новый словарь: тест контрастов
|
||||
гоняется по токенам `preview.css` (`:root` + `[data-theme="dark"]`, цвета
|
||||
`--color-*`/`--brand-*`). Отдельно: взять из нового дизайна, какие пары
|
||||
реально проверить (текст/фон, muted, акцент/бренд, бордер). Если часть
|
||||
проверок потеряла смысл в новом дизайне — сократить, не растягивать.
|
||||
3. **a11y `ColorField` + пустой ruleset `Toggle`** — пофиксить прямо в этих
|
||||
компонентах.
|
||||
|
||||
Гейт этапа: `svelte-check` 0 errors, `pnpm test` зелёный.
|
||||
|
||||
### Этап 2 — миграция `kit/**` на design-токены (main объём)
|
||||
|
||||
Цель — `lint:all` без ошибок design-tokens (все примитивы/`color-mix()` уходят в
|
||||
токены `preview.css`).
|
||||
|
||||
**Вариант А (основной): авто-фикс одноразовым скриптом.** Писать скрипт ТОЛЬКО
|
||||
если он гарантированно ничего не испортит. Гарантии:
|
||||
|
||||
- Скрипт — одноразовый (`web/scripts/fix-design-tokens.mjs`), после прогона
|
||||
удаляется либо помечается неактуальным.
|
||||
- Строгая карта замен: примитив → токен **1:1, только если значение точно
|
||||
совпадает с уже существующим токеном словаря** `preview.css` (цвет →
|
||||
`--color-*`/`--brand-*`, радиус → `--radius-*`, размер →
|
||||
`--space-*`/`--size-*`, длительность → `--duration-*`, z-index → `--z-*`).
|
||||
Никаких новых токенов скрипт не генерирует.
|
||||
- Замены — только примитивы, не содержащие `var()`/`calc()`/`color-mix()`;
|
||||
исключения по правилам плагина (0, 0px, 1px, проценты, unitless) не трогаются.
|
||||
`color-mix()` → выносится в `preview.css` как производный `--color-*` токен
|
||||
(может требовать ручного решения имени).
|
||||
- Скрипт обрабатывает только `src/lib/components/kit/**` и `src/preview.css`
|
||||
(scoped-пути плагина) и только `<style>`-блоки/`.css` — логику/разметку не
|
||||
меняет, только строки стилей.
|
||||
- После прогона: обязательный дифф-ревью (все изменения — замена значений на
|
||||
`var(--x)`, ничего удалено/переставлено), затем полный `lint:all` +
|
||||
`svelte-check` + `pnpm test`. Если хоть что-то вне «замена примитива на токен»
|
||||
— скрипт считается проваленным, откатываем и переходим на вариант Б.
|
||||
|
||||
**Вариант Б (страховка): руками, пакетами по компонентам.** Если скрипт не
|
||||
удался — чистим/заводим токены вручную, пакетами по компонентам (1–2 на задачу,
|
||||
диф < ~500 строк). Отсутствующие токены (например `--space-2xl`/`--space-panel`,
|
||||
уже дважды обходились в SchemaTextSource) добавляем в словарь `preview.css`
|
||||
осознанно.
|
||||
|
||||
Гейт этапа: `pnpm --dir web lint:all` — только допустимый остаток
|
||||
(неиспользуемые токены — варнинг), плюс регресс-прогон тестов и svelte-check.
|
||||
|
||||
### Этап 3 — чистота словаря `preview.css`
|
||||
|
||||
- Разобрать варнинги неиспользуемых токенов (check-tokens): либо использовать,
|
||||
либо удалить из словаря.
|
||||
- После этапа 2 — повторный аудит токенов
|
||||
(`pnpm --dir web exec node scripts/check-tokens.mjs`): parity light/dark,
|
||||
hct-only, без мёртвых токенов.
|
||||
|
||||
### Этап 4 — финальные гейты (гейт переезда `old/`)
|
||||
|
||||
1. `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` → 0 errors
|
||||
2. `pnpm --dir web test` → зелёный
|
||||
3. `pnpm --dir web exec prettier --check .` → чистый
|
||||
4. `pnpm --dir web lint:all` → 0 (или только задокументированный остаток)
|
||||
5. Обе ветки открываются: старый UI `/`, новый `/preview`
|
||||
|
||||
После зеленых гейтов — закрытие задач и переход к `backlog` №14 (переезд старых
|
||||
файлов в `old/`).
|
||||
|
||||
## Порядок и коммиты
|
||||
|
||||
- Коммиты атомарные, < ~500 строк (AGENTS). По завершении каждой мини-задачи —
|
||||
ревью (агент не коммитит).
|
||||
- Техдолг чинится по заведённым задачам, правила линтеров не отключаются и не
|
||||
игнорируются.
|
||||
+89
-37
@@ -1,17 +1,39 @@
|
||||
# План разработки: easy-png-tools
|
||||
|
||||
> **Статус (2026-09-07):** Фаза 1 (полноценный TS-сайт) в основном выполнена —
|
||||
> сайт живёт, каталог переведён в типизированный `registry-new` (121/125), идёт
|
||||
> редизайн на `preview/*` по `plan-redesign.md` (параллельная ветка, старый UI
|
||||
> на `(old)/`). Фазы 2–8 (эталоны, Rust/wasm, CLI, harness) — будущие, разделам
|
||||
> ниже не запущены.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
- **Набор PNG-утилит, всё в браузере** (SvelteKit + `adapter-static`), данные не покидают машину.
|
||||
- **Первый и главный приоритет — полностью рабочий TS-сайт**: максимум простых инструментов уровня EASY, UX/UI, пайплайны (несколько инструментов подряд), описания инструментов. Сайтом можно пользоваться уже после этой фазы.
|
||||
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` — эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
|
||||
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат закладывается сейчас и потом станет общим для браузера, wasm и CLI.
|
||||
- **Реестр инструментов** (id, title, description, схема параметров, `run`) — источник истины: из него генерируются страницы, формы, пайплайны. Позже — общий формат для Rust-парсера.
|
||||
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций, **perceptual diff** для canvas-зависимых.
|
||||
- **Единая точка исполнения инструментов.** Страницы вызывают операции только через `web/src/lib/tools/executor.ts::executeStep` — нигде напрямую `tool.run`. Контракт уже асинхронный (`Promise<PixelImage>`), поэтому:
|
||||
- когда появятся тяжёлые MEDIUM-операции (свёртки, квантование), внутренности `executeStep` переезжают в Web Worker — воркер импортирует тот же чистый core как TS-фолбэк, алгоритмы не дублируются, вызывающий код не меняется;
|
||||
- в WASM-фазе тот же воркер принимает wasm-модуль и диспетчеризует по `toolId` — свитч A/B из фазы 5 сводится к замене реализации внутри исполнителя;
|
||||
- до появления MEDIUM-операций воркер не вводится: EASY-операции быстры и не блокируют UI.
|
||||
- **Набор PNG-утилит, всё в браузере** (SvelteKit + `adapter-static`), данные не
|
||||
покидают машину.
|
||||
- **Первый и главный приоритет — полностью рабочий TS-сайт**: максимум простых
|
||||
инструментов уровня EASY, UX/UI, пайплайны (несколько инструментов подряд),
|
||||
описания инструментов. Сайтом можно пользоваться уже после этой фазы.
|
||||
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` —
|
||||
эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки
|
||||
Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
|
||||
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат закладывается сейчас
|
||||
и потом станет общим для браузера, wasm и CLI.
|
||||
- **Реестр инструментов** (id, title, description, схема параметров, `run`) —
|
||||
источник истины: из него генерируются страницы, формы, пайплайны. Позже —
|
||||
общий формат для Rust-парсера.
|
||||
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из
|
||||
TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций,
|
||||
**perceptual diff** для canvas-зависимых.
|
||||
- **Единая точка исполнения инструментов.** Страницы вызывают операции только
|
||||
через `web/src/lib/tools/executor.ts::executeStep` — нигде напрямую
|
||||
`tool.run`. Контракт уже асинхронный (`Promise<PixelImage>`), поэтому:
|
||||
- когда появятся тяжёлые MEDIUM-операции (свёртки, квантование), внутренности
|
||||
`executeStep` переезжают в Web Worker — воркер импортирует тот же чистый
|
||||
core как TS-фолбэк, алгоритмы не дублируются, вызывающий код не меняется;
|
||||
- в WASM-фазе тот же воркер принимает wasm-модуль и диспетчеризует по `toolId`
|
||||
— свитч A/B из фазы 5 сводится к замене реализации внутри исполнителя;
|
||||
- до появления MEDIUM-операций воркер не вводится: EASY-операции быстры и не
|
||||
блокируют UI.
|
||||
|
||||
## 1. Структура репозитория (этап TS)
|
||||
|
||||
@@ -33,15 +55,28 @@ easy-png-tools/
|
||||
|
||||
### Фаза 1 — Полноценный TS-сайт (единственный приоритет)
|
||||
|
||||
**Цель: сайт, которым можно пользоваться.** Без wasm, без Rust, без clamp-семантики (в TS это делает `Uint8ClampedArray` сам).
|
||||
**Цель: сайт, которым можно пользоваться.** Без wasm, без Rust, без
|
||||
clamp-семантики (в TS это делает `Uint8ClampedArray` сам).
|
||||
|
||||
1.1 **Каркас и UX/UI.** SvelteKit + static adapter, дизайн-система (цвета, типографика, компоненты), общий лейаут, шапка с навигацией по категориям.
|
||||
1.1 **Каркас и UX/UI.** SvelteKit + static adapter, дизайн-система (цвета,
|
||||
типографика, компоненты), общий лейаут, шапка с навигацией по категориям.
|
||||
|
||||
1.2 **Ядро.** Тип `Image` (обёртка над `ImageData`), загрузка/декодирование файла, кодирование и скачивание, библиотека операций (color/alpha/geometry/format/text/analyze). Всё без DOM-зависимостей внутри `core`.
|
||||
1.2 **Ядро.** Тип `Image` (обёртка над `ImageData`), загрузка/декодирование
|
||||
файла, кодирование и скачивание, библиотека операций
|
||||
(color/alpha/geometry/format/text/analyze). Всё без DOM-зависимостей внутри
|
||||
`core`.
|
||||
|
||||
1.3 **Реестр инструментов.** Одна запись = `{ id, title, description, category, params: ParamDef[], run }`. Универсальный рендерер: страница инструмента и форма параметров строятся из записи реестра автоматически; маршруты-утилиты генерируются из реестра на билде. Новый инструмент = новая запись + функция `run`.
|
||||
1.3 **Реестр инструментов.** Одна запись =
|
||||
`{ id, title, description, category, params: ParamDef[], run }`. Универсальный
|
||||
рендерер: страница инструмента и форма параметров строятся из записи реестра
|
||||
автоматически; маршруты-утилиты генерируются из реестра на билде. Новый
|
||||
инструмент = новая запись + функция `run`.
|
||||
|
||||
1.4 **Пайплайн-workspace** (`/workspace`): загрузил изображение → список шагов (инструмент + его параметры) → последовательное применение с превью каждого шага → скачивание финального результата. Добавление/удаление/перестановка шагов. Сохранение пайплайнов в localStorage, экспорт/импорт JSON. Общие компоненты: DropZone, Preview, ParamForm, Download, PipelineSteps.
|
||||
1.4 **Пайплайн-workspace** (`/workspace`): загрузил изображение → список шагов
|
||||
(инструмент + его параметры) → последовательное применение с превью каждого шага
|
||||
→ скачивание финального результата. Добавление/удаление/перестановка шагов.
|
||||
Сохранение пайплайнов в localStorage, экспорт/импорт JSON. Общие компоненты:
|
||||
DropZone, Preview, ParamForm, Download, PipelineSteps.
|
||||
|
||||
1.5 **Массовая реализация EASY-инструментов** по категориям:
|
||||
|
||||
@@ -50,29 +85,36 @@ easy-png-tools/
|
||||
- цвет: замена, тон, каналы, оттенки серого, инверсии;
|
||||
- геометрия и холст: resize, crop, rotate, flip, border, padding, background;
|
||||
- текст и простые эффекты: watermark, add-text, рамки, шум, pixelate;
|
||||
- анализ: размеры, палитра, проверки, просмотр.
|
||||
Приоритет — ширина, не глубина: как можно больше простых инструментов.
|
||||
- анализ: размеры, палитра, проверки, просмотр. Приоритет — ширина, не глубина:
|
||||
как можно больше простых инструментов.
|
||||
|
||||
1.6 **Полировка.** Описания и подсказки для всех инструментов, состояния загрузки/ошибок, доступность, пустые состояния.
|
||||
1.6 **Полировка.** Описания и подсказки для всех инструментов, состояния
|
||||
загрузки/ошибок, доступность, пустые состояния.
|
||||
|
||||
**Checkpoint:** загрузил PNG → применил пайплайн из N шагов → сохранил и вернул пайплайн → скачал результат. Все инструменты 1.5 работают на сайте. Ни одного wasm.
|
||||
**Checkpoint:** загрузил PNG → применил пайплайн из N шагов → сохранил и вернул
|
||||
пайплайн → скачал результат. Все инструменты 1.5 работают на сайте. Ни одного
|
||||
wasm.
|
||||
|
||||
### Фаза 2 — Тестовые PNG и генератор эталонов
|
||||
|
||||
- Фикстурные входы: градиенты/паттерны/шум + пара «настоящих» PNG.
|
||||
- Правило: сравниваем **распакованные пиксели**, не байты файла.
|
||||
- **Checkpoint:** в `tests/reference/<op>/` лежат `input-*.png` + `expected-*.png`.
|
||||
- **Checkpoint:** в `tests/reference/<op>/` лежат `input-*.png` +
|
||||
`expected-*.png`.
|
||||
|
||||
### Фаза 3 — Playwright-тесты
|
||||
|
||||
- `tools/gen-references.ts`: грузит страницу, гоняет TS-эталон, сохраняет `expected-*.png`.
|
||||
- `tools/gen-references.ts`: грузит страницу, гоняет TS-эталон, сохраняет
|
||||
`expected-*.png`.
|
||||
- Регресс: изменение TS-ядра ловится тестами.
|
||||
- **Checkpoint:** регресс-тесты падают при изменении выхода эталона.
|
||||
|
||||
### Фаза 4 — Rust-ядро
|
||||
|
||||
- Порт 1:1 чистых операций (первым делом те, что покрыты эталонными файлами) с `Image` ↔ TS `ImageData`.
|
||||
- `clamp_to_u8` с семантикой `ToUint8Clamp` (round-half-to-even, ≥255 → 255) + юнит-тесты на краях.
|
||||
- Порт 1:1 чистых операций (первым делом те, что покрыты эталонными файлами) с
|
||||
`Image` ↔ TS `ImageData`.
|
||||
- `clamp_to_u8` с семантикой `ToUint8Clamp` (round-half-to-even, ≥255 → 255) +
|
||||
юнит-тесты на краях.
|
||||
- `cargo test` читает эталонные файлы напрямую, **без браузера**.
|
||||
- **Checkpoint:** `cargo test` зелёный на всех эталонных файлах.
|
||||
|
||||
@@ -91,14 +133,18 @@ easy-png-tools/
|
||||
|
||||
### Фаза 7 — Сквозная проверка идентичности
|
||||
|
||||
- Матрица: **эталонные файлы (из TS) ↔ Rust-native ↔ Rust-wasm**, одна команда `harness compare`.
|
||||
- Матрица: **эталонные файлы (из TS) ↔ Rust-native ↔ Rust-wasm**, одна команда
|
||||
`harness compare`.
|
||||
- **Checkpoint:** сходимость по всем покрытым операциям во всех трёх рантаймах.
|
||||
|
||||
### Фаза 8 — Масштабирование и продвинутое
|
||||
|
||||
- Порт оставшихся EASY → MEDIUM (свёртки, морфология, квантование; кодеки через крейт `image`).
|
||||
- Canvas-зависимые операции (текст, градиенты, штампы, `ctx.filter`): остаются в TS с perceptual-классом либо реимплементация в Rust.
|
||||
- batch-страница: дроп папки → wasm по всем файлам → zip. localStorage-пресеты (тот же JSON, что у CLI).
|
||||
- Порт оставшихся EASY → MEDIUM (свёртки, морфология, квантование; кодеки через
|
||||
крейт `image`).
|
||||
- Canvas-зависимые операции (текст, градиенты, штампы, `ctx.filter`): остаются в
|
||||
TS с perceptual-классом либо реимплементация в Rust.
|
||||
- batch-страница: дроп папки → wasm по всем файлам → zip. localStorage-пресеты
|
||||
(тот же JSON, что у CLI).
|
||||
- HARD: compress/optimize через oxipng/zopfli (в Rust — нативно).
|
||||
|
||||
## 3. Правила сравнения (для фаз 4–7)
|
||||
@@ -114,19 +160,25 @@ easy-png-tools/
|
||||
|
||||
## 4. Риски и контрмеры
|
||||
|
||||
- **Реестр разрастается, страницы дублируются** — один универсальный рендерер страниц/форм из записей реестра; инструмент = данные + `run`.
|
||||
- **Семантика пайплайна** — зафиксировать: каждый шаг применяется к результату предыдущего; единый тип `Image` на всём пути; схема параметров на шаге.
|
||||
- **Canvas-операции не бит-в-бит** — класс perceptual закладывается на Фазе 2–3, а не в конце.
|
||||
- **WASM + Vite** — спайк на Фазе 5 до массовой интеграции (риск отложен сознательно: сайт уже живёт на TS).
|
||||
- **Расползание реестра при будущем Rust-порте** — схема параметров в JSON, её можно парсить и из Rust.
|
||||
- **Разные декодеры PNG** — фикстурные PNG генерировать самим, чтобы не тащить чужие артефакты.
|
||||
- **Реестр разрастается, страницы дублируются** — один универсальный рендерер
|
||||
страниц/форм из записей реестра; инструмент = данные + `run`.
|
||||
- **Семантика пайплайна** — зафиксировать: каждый шаг применяется к результату
|
||||
предыдущего; единый тип `Image` на всём пути; схема параметров на шаге.
|
||||
- **Canvas-операции не бит-в-бит** — класс perceptual закладывается на Фазе 2–3,
|
||||
а не в конце.
|
||||
- **WASM + Vite** — спайк на Фазе 5 до массовой интеграции (риск отложен
|
||||
сознательно: сайт уже живёт на TS).
|
||||
- **Расползание реестра при будущем Rust-порте** — схема параметров в JSON, её
|
||||
можно парсить и из Rust.
|
||||
- **Разные декодеры PNG** — фикстурные PNG генерировать самим, чтобы не тащить
|
||||
чужие артефакты.
|
||||
|
||||
## 5. Команды верификации
|
||||
|
||||
```bash
|
||||
cd web && npm run dev # разработка (Фаза 1+)
|
||||
cd web && npm run build # статический экспорт
|
||||
npx playwright test # эталоны + UI-регресс (Фаза 3+)
|
||||
cargo test # Rust-ядро против эталонных файлов (Фаза 4+)
|
||||
pnpm dev # разработка (Фаза 1+, обёртка `pnpm --dir web dev`)
|
||||
pnpm build # статический экспорт (обёртка `pnpm --dir web build`)
|
||||
pnpm --dir web exec playwright test # эталоны + UI-регресс (Фаза 3+)
|
||||
cargo test # Rust-ядро против эталонных файлов (Фаза 4+)
|
||||
harness compare tests/reference # сквозная сверка native + wasm (Фаза 7)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Хранить данные для сортировки по популярности инструментов
|
||||
|
||||
Идея выложить на Vercel и использовать Vercel KV (на базе Upstash Redis) — это
|
||||
отличный, рабочий и очень быстрый в реализации вариант. Для 150 инструментов
|
||||
объем данных будет крошечным, но здесь есть пара критически важных нюансов по
|
||||
лимитам, о которых нужно знать на старте, чтобы бесплатный тариф не отключился в
|
||||
первый же месяц. [1]
|
||||
|
||||
## ⚠️ Подводный камень: Лимиты Vercel KV на Free-тарифе
|
||||
|
||||
На бесплатном тарифе (Hobby) для Vercel KV действуют следующие ограничения: [2]
|
||||
|
||||
- 30 000 запросов в месяц (суммарно чтение + запись).
|
||||
- Ограничение около 3 000 запросов в день. [2, 3]
|
||||
|
||||
Если делать запрос к базе на каждый просмотр страницы («посещение»), лимит в 30
|
||||
тысяч улетит мгновенно при первой же паре сотен посетителей в день.
|
||||
|
||||
## 💡 Как сделать это правильно и бесплатно
|
||||
|
||||
Чтобы не выйти за рамки лимитов, архитектуру нужно построить экономно:
|
||||
|
||||
## 1. Чтение: Сортировка на этапе сборки (ISR / SSG)
|
||||
|
||||
Не нужно запрашивать KV из Edge-функции при каждом открытии сайта пользователем.
|
||||
|
||||
- Используйте Incremental Static Regeneration (ISR) (если вы делаете на Next.js)
|
||||
или раз в сутки запускайте Cron-сид (Vercel Cron).
|
||||
- Сайт раз в несколько часов запрашивает весь список из KV (всего 1 запрос на
|
||||
получение общей структуры), сортирует инструменты и кеширует эту страницу как
|
||||
статическую.
|
||||
- Результат: 100 000 пользователей увидят отсортированный список, а вы потратите
|
||||
всего пару запросов к KV в день.
|
||||
|
||||
## 2. Запись: Защита от накрутки и экономия («Добавление в избранное»)
|
||||
|
||||
«Избранное» — идеальная метрика для KV, так как её нажимают редко. А вот
|
||||
«Посещения» лучше отправлять не на каждый клик, а пачками (батчингом) или
|
||||
использовать для просмотров бесплатную Vercel Web Analytics (там лимит 25 000
|
||||
событий в месяц обновляется отдельно).
|
||||
|
||||
---
|
||||
|
||||
## 💻 Оптимальная структура данных в Redis (KV)
|
||||
|
||||
Вместо 150 отдельных ключей, храните всё в одной хэш-таблице (Hash) или
|
||||
сортированном множестве (Sorted Set). Это сэкономит количество запросов. Вариант
|
||||
структуры через Hash:
|
||||
|
||||
```js
|
||||
// Запись / инкремент (в API роуте Vercel)
|
||||
import { kv } from "@vercel/kv";
|
||||
//Когда пользователь зашел на инструмент 'crop-png'
|
||||
await kv.hincrby("tools:visits", "crop-png", 1);
|
||||
// Когда добавил в избранное
|
||||
await kv.hincrby("tools:favorites", "crop-png", 1);
|
||||
```
|
||||
|
||||
Как забрать всё одним запросом для сортировки:
|
||||
|
||||
```js
|
||||
// Получаем сразу все просмотры (вернет объект вида { 'crop-png': '120', 'resize-png': '85' })
|
||||
const allVisits = await kv.hgetall("tools:visits");
|
||||
const allFavorites = await kv.hgetall("tools:favorites");
|
||||
// Дальше соединяем в массив на клиенте/сервере и сортируем:
|
||||
// Популярность = Просмотры + (Избранное * 5) <- так лайки весят больше
|
||||
```
|
||||
|
||||
## ⚡ Альтернатива: Cloudflare Workers + KV
|
||||
|
||||
Если вы боитесь упереться в 30 000 запросов на Vercel, рассмотрите связку
|
||||
Cloudflare Workers + Workers KV. Там на бесплатном тарифе дают: [4]
|
||||
|
||||
- 100 000 запросов на запись в сутки.
|
||||
- 10 000 000 (10 млн) запросов на чтение в сутки.
|
||||
- Для пет-проекта этого хватит навсегда, даже если вы будете делать запросы
|
||||
напрямую с фронтенда без кеширования.
|
||||
+48
-22
@@ -1,7 +1,9 @@
|
||||
# Карта инструментов: реализовано · добавить · идеи
|
||||
|
||||
> Живой документ для сверки с каталогом. Формат записи: `id / название — параметры через запятую`.
|
||||
> Источник сравнения: onlinepngtools.com (311 уникальных; 107 из них — нишевые клоны серий Logo*/Icon*/Stamp*/Signature* поверх обычных операций).
|
||||
> Живой документ для сверки с каталогом. Формат записи:
|
||||
> `id / название — параметры через запятую`. Источник сравнения:
|
||||
> onlinepngtools.com (311 уникальных; 107 из них — нишевые клоны серий
|
||||
> Logo*/Icon*/Stamp*/Signature* поверх обычных операций).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,8 +23,10 @@
|
||||
- png-to-base64 / base64-to-png — строка
|
||||
- png-to-data-uri / data-uri-to-png — строка
|
||||
- png-to-hex — rrggbbaa по строкам / hex-to-png — tokens + width
|
||||
- png-to-bytes — десятичные RGBA-байты по строкам / bytes-to-png — tokens + width
|
||||
- png-to-rgb-values — rgba(r,g,b,a) по пикселям / rgb-values-to-png — числа + width
|
||||
- png-to-bytes — десятичные RGBA-байты по строкам / bytes-to-png — tokens +
|
||||
width
|
||||
- png-to-rgb-values — rgba(r,g,b,a) по пикселям / rgb-values-to-png — числа +
|
||||
width
|
||||
- svg-to-png — width результата
|
||||
- verify-is-png — текстовый источник (base64/data-uri), вердикт по сигнатуре
|
||||
|
||||
@@ -41,7 +45,8 @@
|
||||
- harden-alpha-png — threshold
|
||||
- feather-edges-png — radius (размытие только альфы)
|
||||
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного)
|
||||
- watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin
|
||||
- watermark-image-png — вторая картинка-знак (загружается на странице), scale,
|
||||
opacity, position, margin
|
||||
- despeckle-alpha-png / close-holes-png — radius
|
||||
- center-by-alpha-png — без параметров
|
||||
- round-corners-png — radius
|
||||
@@ -52,7 +57,8 @@
|
||||
|
||||
### Цвет
|
||||
|
||||
- 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
|
||||
- change-png-hue — degrees
|
||||
- extract-channel-png — channel (r/g/b)
|
||||
@@ -73,7 +79,8 @@
|
||||
|
||||
### Разложение каналов
|
||||
|
||||
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb)
|
||||
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray
|
||||
| space-as-rgb)
|
||||
- png-to-cmyk — component (c/m/y/k), display
|
||||
- png-to-ycbcr — component (y/cb/cr), display
|
||||
- png-to-lab — component (l/a/b), display
|
||||
@@ -117,12 +124,15 @@
|
||||
### Маски по свойствам пикселей
|
||||
|
||||
- show-transparent-png — color, opacity (подсветка прозрачных/полупрозрачных)
|
||||
- show-grayscale-pixels-png — tolerance, mode (binary/highlight), highlightColor, highlightOpacity
|
||||
- show-grayscale-pixels-png — tolerance, mode (binary/highlight),
|
||||
highlightColor, highlightOpacity
|
||||
- show-color-pixels-png — tolerance, mode, highlightColor, highlightOpacity
|
||||
- light-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
|
||||
- dark-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
|
||||
- unique-color-mask-png — rarity (макс. повторов), mode, highlightColor, highlightOpacity
|
||||
- extract-color-from-png — color, tolerance (оставить близкие, остальное прозрачным)
|
||||
- unique-color-mask-png — rarity (макс. повторов), mode, highlightColor,
|
||||
highlightOpacity
|
||||
- extract-color-from-png — color, tolerance (оставить близкие, остальное
|
||||
прозрачным)
|
||||
|
||||
### Генерация
|
||||
|
||||
@@ -130,7 +140,8 @@
|
||||
- single-color-png — width, height, color
|
||||
- random-noise-png — width, height, seed
|
||||
- linear-gradient-png — width, height, fromColor, toColor, direction
|
||||
- text-to-png — text, fontSize, font, bold, color, transparentBg, backgroundColor, padding
|
||||
- text-to-png — text, fontSize, font, bold, color, transparentBg,
|
||||
backgroundColor, padding
|
||||
- emoji-to-png — emoji, size
|
||||
- placeholder-png — width, height, backgroundColor, color, showText
|
||||
- color-spectrum-png — width, height, direction, saturation, lightness
|
||||
@@ -139,13 +150,17 @@
|
||||
|
||||
### Текст
|
||||
|
||||
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin, plate, plateColor, plateOpacity
|
||||
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate, plateColor, plateOpacity
|
||||
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY, font, bold
|
||||
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin,
|
||||
plate, plateColor, plateOpacity
|
||||
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate,
|
||||
plateColor, plateOpacity
|
||||
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY,
|
||||
font, bold
|
||||
|
||||
### Палитры и цветовые утилиты
|
||||
|
||||
- color-wheel-generator — size, кольца/сектора, показ hex при клике (у нас — статичный свотч-полотно)
|
||||
- color-wheel-generator — size, кольца/сектора, показ hex при клике (у нас —
|
||||
статичный свотч-полотно)
|
||||
- complementary-palette — baseColor
|
||||
- monochromatic-palette — baseColor, count
|
||||
- analogous-palette — baseColor, spread
|
||||
@@ -154,7 +169,8 @@
|
||||
- similar-shades-palette — baseColor, count, range
|
||||
- sort-colors — источник (палитра-картинка или список), порядок (hsl/luma)
|
||||
- mix-colors — colors[], веса?
|
||||
- average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка)
|
||||
- average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps
|
||||
(три частных случая одного движка)
|
||||
|
||||
---
|
||||
|
||||
@@ -193,7 +209,8 @@
|
||||
|
||||
### Прочее единичное
|
||||
|
||||
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется (покрыто)
|
||||
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется
|
||||
(покрыто)
|
||||
- preview/test на цветных фонах — фича превью, не инструмент (решить позже)
|
||||
- extract-barcode — HARD, вне планов
|
||||
|
||||
@@ -201,8 +218,17 @@
|
||||
|
||||
## 3. Идеи на рассмотрение (нужна архитектура или спорная ценность)
|
||||
|
||||
- **Region-инструменты** — требуется UI выделения прямоугольника/ластика на превью: censor-region, erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area. Один раз делаем selection-компонент — получаем сразу шесть инструментов.
|
||||
- **Мультифайловый вывод** — сейчас инструмент отдаёт одну картинку: split-into-parts, gif-to-frames, separate-colors, multiply-grid-as-files. Нужен механизм «результат = набор файлов» (zip?).
|
||||
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling: это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
|
||||
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital, extract-barcode.
|
||||
- **Нишевые серии** (logo/icon/stamp/signature — 107 клонов у источника) — сознательно не копируем: это обычные операции над конкретным контентом, у нас они доступны через базовые инструменты + цепочки.
|
||||
- **Region-инструменты** — требуется UI выделения прямоугольника/ластика на
|
||||
превью: censor-region, erase-region, pixelate-area, blur-area, sharpen-area,
|
||||
reverse-colors-area. Один раз делаем selection-компонент — получаем сразу
|
||||
шесть инструментов.
|
||||
- **Мультифайловый вывод** — сейчас инструмент отдаёт одну картинку:
|
||||
split-into-parts, gif-to-frames, separate-colors, multiply-grid-as-files.
|
||||
Нужен механизм «результат = набор файлов» (zip?).
|
||||
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
|
||||
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
|
||||
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
|
||||
extract-barcode.
|
||||
- **Нишевые серии** (logo/icon/stamp/signature — 107 клонов у источника) —
|
||||
сознательно не копируем: это обычные операции над конкретным контентом, у нас
|
||||
они доступны через базовые инструменты + цепочки.
|
||||
|
||||
+7
-2
@@ -7,7 +7,9 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "pnpm --dir web dev",
|
||||
"build": "pnpm --dir web build",
|
||||
"format": "pnpm --dir web format",
|
||||
"format": "pnpm format:docs && pnpm --dir web format",
|
||||
"format:docs": "prettier --write docs --log-level warn",
|
||||
"check:docs": "prettier --check docs",
|
||||
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html",
|
||||
"cdp-audit": "pnpm --dir web refs-cdp-audit:large"
|
||||
},
|
||||
@@ -21,5 +23,8 @@
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"prettier": "^3.9.6"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+16
-1
@@ -205,4 +205,19 @@ settings:
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
.:
|
||||
devDependencies:
|
||||
prettier:
|
||||
specifier: ^3.9.6
|
||||
version: 3.9.6
|
||||
|
||||
packages:
|
||||
|
||||
prettier@3.9.6:
|
||||
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
prettier@3.9.6: {}
|
||||
|
||||
@@ -21,3 +21,8 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Playwright
|
||||
/test-results
|
||||
/playwright-report
|
||||
/e2e-results
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { trackErrors, expectNoErrors } from "./helpers/page";
|
||||
|
||||
const TOTAL = 121;
|
||||
const GROUPS = 8;
|
||||
|
||||
test("catalog shows total and all groups", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await page.goto("/preview/list-tools");
|
||||
await expect(page.locator(".catalog-total b")).toHaveText(String(TOTAL));
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("catalog search narrows results", async ({ page }) => {
|
||||
await page.goto("/preview/list-tools");
|
||||
await expect(async () => {
|
||||
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card").first()).toContainText("Resize PNG");
|
||||
});
|
||||
|
||||
test("category filter shows only matching group and resets on ALL", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/preview/list-tools");
|
||||
const allButtons = page.locator(".tool-card");
|
||||
const allCount = await allButtons.count();
|
||||
expect(allCount).toBe(TOTAL);
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "CONVERT" }).click();
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".catalog-group")).toContainText("CONVERT");
|
||||
const convertCards = await page.locator(".tool-card").count();
|
||||
expect(convertCards).toBeGreaterThan(0);
|
||||
expect(convertCards).toBeLessThan(allCount);
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "ALL" }).click();
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card")).toHaveCount(allCount);
|
||||
});
|
||||
|
||||
test("workspace search matches a tool by title", async ({ page }) => {
|
||||
await page.goto("/preview");
|
||||
await expect(async () => {
|
||||
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card").first()).toContainText("Flip PNG");
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { trackErrors, openTool, expectNoErrors } from "./helpers/page";
|
||||
|
||||
test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
||||
// Генераторы (21/121) открываются, но в UI нет ни полей схемы, ни кнопки
|
||||
// «Generate» — только RU/EN/Reset. Пока баг открыт — fixme с ожидаемым
|
||||
// сценарием; после фикса убрать fixme.
|
||||
test.fixme("single-color-png renders Generate controls and produces a result", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "single-color-png");
|
||||
const generate = page.getByRole("button", { name: "Generate" });
|
||||
await expect(generate).toBeVisible();
|
||||
await generate.click();
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("create-empty-png page opens without errors", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "create-empty-png");
|
||||
await expect(page.locator("h1")).toContainText("Create");
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
|
||||
export interface SourceFile {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++)
|
||||
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const out = Buffer.alloc(8 + data.length + 4);
|
||||
out.writeUInt32BE(data.length, 0);
|
||||
out.write(type, 4, "ascii");
|
||||
data.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
|
||||
export function makePng(
|
||||
width: number,
|
||||
height: number,
|
||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||
): Buffer {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
const stride = width * 4 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 4;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
raw[p + 3] = a;
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
const checker = (x: number, y: number): [number, number, number, number] =>
|
||||
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
|
||||
|
||||
const alphaGrid = (x: number, y: number): [number, number, number, number] =>
|
||||
(x + y) % 3 === 0 ? [0, 0, 0, 0] : [0, 128, 255, 255];
|
||||
|
||||
const solidRed = (): [number, number, number, number] => [255, 0, 0, 255];
|
||||
|
||||
export function asSourceFile(buffer: Buffer, name: string): SourceFile {
|
||||
return { name, mimeType: "image/png", buffer };
|
||||
}
|
||||
|
||||
export const opaquePng: SourceFile = asSourceFile(
|
||||
makePng(64, 48, checker),
|
||||
"opaque.png",
|
||||
);
|
||||
export const transparentPng: SourceFile = asSourceFile(
|
||||
makePng(64, 64, alphaGrid),
|
||||
"transparent.png",
|
||||
);
|
||||
export const onePixelPng: SourceFile = asSourceFile(
|
||||
makePng(1, 1, solidRed),
|
||||
"1px.png",
|
||||
);
|
||||
export const landscapePng: SourceFile = asSourceFile(
|
||||
makePng(64, 16, checker),
|
||||
"landscape.png",
|
||||
);
|
||||
export const largePng: SourceFile = asSourceFile(
|
||||
makePng(256, 256, checker),
|
||||
"large.png",
|
||||
);
|
||||
export const corruptPng: SourceFile = {
|
||||
name: "corrupt.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("this is definitely not a png file", "utf8"),
|
||||
};
|
||||
|
||||
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
|
||||
|
||||
export const svgMarkup =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6">' +
|
||||
'<rect width="10" height="6" fill="#ff0000"/></svg>';
|
||||
|
||||
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
|
||||
`${r} ${g} ${b} ${a}`;
|
||||
|
||||
/** Строка для bytes-to-png / rgb-values-to-png, ровно 32 пикселя в ширину по умолчанию. */
|
||||
export function pixelRow(
|
||||
count: number,
|
||||
rgba: [number, number, number, number],
|
||||
): string {
|
||||
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, type Page } from "playwright/test";
|
||||
import type { SourceFile } from "./fixtures";
|
||||
|
||||
export type ErrorSink = { errors: string[] };
|
||||
|
||||
export function trackErrors(page: Page): ErrorSink {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") errors.push(`console: ${m.text()}`);
|
||||
});
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export async function openTool(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/preview/tools/${id}`);
|
||||
await expect(page.locator(".schema-tool h1")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
||||
await page.locator('.actions input[type="file"]').setInputFiles({
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
buffer: file.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
export async function metaValue(page: Page, caption: string): Promise<string> {
|
||||
const row = page.locator(".meta-row", { hasText: caption });
|
||||
await expect(row).toBeVisible();
|
||||
const value = await row.locator(".meta-value").textContent();
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export async function suggestedDownloadName(
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<string> {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.locator(selector).click(),
|
||||
]);
|
||||
return download.suggestedFilename();
|
||||
}
|
||||
|
||||
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
||||
await expect(page.locator('[role="alert"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
||||
expect(sink.errors, "нет console/page errors").toEqual([]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng } from "./helpers/fixtures";
|
||||
import { openTool, uploadImage } from "./helpers/page";
|
||||
|
||||
// Весь файл — зарегистрированные баги preview (см. docs/checklist-manual-testing.md
|
||||
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
|
||||
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
|
||||
// тест станет зелёным «сам по себе».
|
||||
|
||||
test.describe("known bugs — documented as fixme", () => {
|
||||
test.fixme("resize-png: upload produces a resized result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme("crop-png: upload produces a cropped result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "crop-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme("error message is localized, not a raw i18n key", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
const alert = page.locator("[role='alert']");
|
||||
await expect(alert).toBeVisible();
|
||||
const text = (await alert.textContent()) ?? "";
|
||||
expect(text).not.toMatch(/^errors\./);
|
||||
expect(text.length).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { trackErrors, expectNoErrors } from "./helpers/page";
|
||||
|
||||
const ROUTES = [
|
||||
"/preview",
|
||||
"/preview/list-tools",
|
||||
"/preview/kit",
|
||||
"/preview/tools/flip-png",
|
||||
];
|
||||
|
||||
for (const route of ROUTES) {
|
||||
test(`page ${route} loads without errors`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
const resp = await page.goto(route);
|
||||
expect(resp?.status()).toBe(200);
|
||||
await page.waitForLoadState("networkidle");
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
|
||||
test("unknown tool route responds 404 on static build", async ({ page }) => {
|
||||
const resp = await page.goto("/preview/tools/definitely-not-a-tool");
|
||||
expect(resp?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("theme toggle flips preview theme and persists to localStorage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/preview");
|
||||
const root = page.locator("main.preview-root");
|
||||
const before = await root.getAttribute("data-theme");
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "Toggle theme" }).click();
|
||||
const after = await root.getAttribute("data-theme");
|
||||
expect(after).not.toBe(before);
|
||||
}).toPass();
|
||||
const after = await root.getAttribute("data-theme");
|
||||
const stored = await page.evaluate(() =>
|
||||
localStorage.getItem("easy-png-tools:theme"),
|
||||
);
|
||||
expect(["light", "dark"]).toContain(before);
|
||||
expect(["light", "dark"]).toContain(after);
|
||||
expect(stored).toBe(after);
|
||||
});
|
||||
|
||||
test("language toggle marks the active button", async ({ page }) => {
|
||||
await page.goto("/preview");
|
||||
const group = page.getByRole("group", { name: "Language" });
|
||||
await expect(async () => {
|
||||
await group.getByRole("button", { name: "EN" }).click();
|
||||
await expect(page.locator(".lang-btn.active")).toHaveText("EN");
|
||||
}).toPass();
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
opaquePng,
|
||||
landscapePng,
|
||||
onePixelPng,
|
||||
largePng,
|
||||
corruptPng,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
metaValue,
|
||||
suggestedDownloadName,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
} from "./helpers/page";
|
||||
|
||||
test("flip-png: full flow — upload, result, meta, download", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveAttribute(
|
||||
"src",
|
||||
/^(blob:|data:image\/png;base64,)/,
|
||||
);
|
||||
const resultMeta = await metaValue(page, "RESULT");
|
||||
expect(resultMeta).toBe("64 × 16 px");
|
||||
const downloadName = await suggestedDownloadName(
|
||||
page,
|
||||
'button[aria-label="Download result"]',
|
||||
);
|
||||
expect(downloadName).toBe("flip-png.png");
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "convert-png-to-jpg");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
const downloadName = await suggestedDownloadName(
|
||||
page,
|
||||
'button[aria-label="Download result"]',
|
||||
);
|
||||
expect(downloadName).toBe("convert-png-to-jpg.jpg");
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("reset clears result but keeps source", async ({ page }) => {
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveCount(0);
|
||||
await expect(page.locator(".empty")).toContainText("no result yet");
|
||||
await expect(page.locator('img[alt="source"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("blur: changing slider updates result image", async ({ page }) => {
|
||||
await openTool(page, "blur-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
const src1 = await page.locator('img[alt="result"]').getAttribute("src");
|
||||
await expect(async () => {
|
||||
await page.locator('input[type="range"]').fill("20");
|
||||
await expect(page.locator('img[alt="result"]')).not.toHaveAttribute(
|
||||
"src",
|
||||
src1 ?? "",
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(window, "Worker", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, corruptPng);
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveCount(0);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
for (const fixture of [onePixelPng, largePng]) {
|
||||
test(`handles ${fixture.name} correctly`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, fixture);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
opaquePng,
|
||||
transparentPng,
|
||||
tinyBase64,
|
||||
svgMarkup,
|
||||
pixelRow,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
} from "./helpers/page";
|
||||
|
||||
for (const [id, input] of [
|
||||
["base64-to-png", tinyBase64],
|
||||
["hex-to-png", "ff0000ff"],
|
||||
["bytes-to-png", pixelRow(32, [255, 0, 0, 255])],
|
||||
[
|
||||
"rgb-values-to-png",
|
||||
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
|
||||
],
|
||||
["svg-to-png", svgMarkup],
|
||||
] as const) {
|
||||
test(`text input → ${id} produces result image`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await expect(async () => {
|
||||
await openTool(page, id);
|
||||
await page.locator(".text-source textarea").fill(input);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
}).toPass({ timeout: 25_000 });
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
|
||||
test("png-to-base64 shows decoded text result", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "png-to-base64");
|
||||
await uploadImage(page, opaquePng);
|
||||
const code = page.locator(".result-pre code");
|
||||
await expect(code).toBeVisible();
|
||||
const text = (await code.textContent()) ?? "";
|
||||
expect(text.length).toBeGreaterThan(20);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
for (const [input, expected] of [
|
||||
[tinyBase64, "Yes — valid PNG signature."],
|
||||
["aGVsbG8=", "No — the content is not a PNG."],
|
||||
] as const) {
|
||||
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
||||
await expect(async () => {
|
||||
await openTool(page, "verify-is-png");
|
||||
await page.locator(".text-source textarea").fill(input);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
await expect(page.locator(".verdict-text")).toContainText(expected);
|
||||
}).toPass({ timeout: 25_000 });
|
||||
});
|
||||
}
|
||||
|
||||
test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — fully opaque.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, transparentPng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"Yes — has transparency.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-is-grayscale: colored image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-grayscale");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — contains colors.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-orientation: landscape image → Landscape", async ({ page }) => {
|
||||
await openTool(page, "png-orientation");
|
||||
await uploadImage(page, opaquePng); // 64×48 → landscape
|
||||
await expect(page.locator(".verdict-text")).toContainText("Landscape");
|
||||
});
|
||||
|
||||
test("png-file-size: returns a size in KB", async ({ page }) => {
|
||||
await openTool(page, "png-file-size");
|
||||
await uploadImage(page, opaquePng);
|
||||
const verdict = page.locator(".verdict-text");
|
||||
await expect(verdict).toBeVisible();
|
||||
await expect(verdict).toContainText("KB");
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
||||
import type { SourceFile } from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
} from "./helpers/page";
|
||||
|
||||
type Kind = "image" | "text-out" | "verdict";
|
||||
|
||||
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
|
||||
// ── convert ────────────────────────────────────────────────
|
||||
{ id: "convert-png-to-jpg", kind: "image" },
|
||||
{ id: "convert-png-to-webp", kind: "image" },
|
||||
{ id: "png-to-bmp", kind: "image" },
|
||||
{ id: "png-to-base64", kind: "text-out" },
|
||||
// ── alpha ──────────────────────────────────────────────────
|
||||
{ id: "remove-background-png", kind: "image", file: transparentPng },
|
||||
{ id: "remove-color-from-png", kind: "image", file: transparentPng },
|
||||
{ id: "round-corners-png", kind: "image", file: transparentPng },
|
||||
{ id: "add-stroke-png", kind: "image", file: transparentPng },
|
||||
{ id: "circle-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "wavy-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "find-contour-png", kind: "image", file: transparentPng },
|
||||
{ id: "feather-edges-png", kind: "image", file: transparentPng },
|
||||
{ id: "clean-edges-png", kind: "image", file: transparentPng },
|
||||
{ id: "make-thicker-png", kind: "image", file: transparentPng },
|
||||
{ id: "make-thinner-png", kind: "image", file: transparentPng },
|
||||
{ id: "despeckle-alpha-png", kind: "image", file: transparentPng },
|
||||
{ id: "close-holes-png", kind: "image", file: transparentPng },
|
||||
{ id: "harden-alpha-png", kind: "image", file: transparentPng },
|
||||
{ id: "invert-alpha-png", kind: "image", file: transparentPng },
|
||||
{ id: "set-alpha-channel-png", kind: "image", file: transparentPng },
|
||||
{ id: "extract-alpha-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "remove-alpha-channel-png", kind: "image", file: transparentPng },
|
||||
{ id: "square-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "star-mask-png", kind: "image", file: transparentPng },
|
||||
// ── color ──────────────────────────────────────────────────
|
||||
{ id: "grayscale-png", kind: "image" },
|
||||
{ id: "invert-colors-png", kind: "image" },
|
||||
{ id: "sepia-png", kind: "image" },
|
||||
{ id: "quantize-png", kind: "image" },
|
||||
{ id: "dithering-png", kind: "image" },
|
||||
{ id: "decrease-color-count-png", kind: "image" },
|
||||
{ id: "two-colors-png", kind: "image" },
|
||||
{ id: "tint-png", kind: "image" },
|
||||
{ id: "auto-contrast-png", kind: "image" },
|
||||
{ id: "png-to-hsl", kind: "image" },
|
||||
{ id: "png-to-cmyk", kind: "image" },
|
||||
// ── geometry ───────────────────────────────────────────────
|
||||
{ id: "flip-png", kind: "image" },
|
||||
{ id: "rotate-png", kind: "image" },
|
||||
{ id: "add-border-png", kind: "image" },
|
||||
{ id: "tile-png", kind: "image" },
|
||||
{ id: "change-canvas-size-png", kind: "image" },
|
||||
{ id: "trim-empty-space-png", kind: "image" },
|
||||
{ id: "swap-orientation-png", kind: "image" },
|
||||
{ id: "skew-png", kind: "image" },
|
||||
{ id: "zoom-png", kind: "image" },
|
||||
{ id: "center-by-alpha-png", kind: "image" },
|
||||
{ id: "change-aspect-ratio-png", kind: "image" },
|
||||
{ id: "symmetric-copy-png", kind: "image" },
|
||||
{ id: "shift-png", kind: "image" },
|
||||
// ── filters ────────────────────────────────────────────────
|
||||
{ id: "blur-png", kind: "image" },
|
||||
{ id: "sharpen-png", kind: "image" },
|
||||
{ id: "pixelate-png", kind: "image" },
|
||||
{ id: "add-noise-png", kind: "image" },
|
||||
{ id: "vignette-png", kind: "image" },
|
||||
{ id: "silhouette-png", kind: "image" },
|
||||
{ id: "randomize-pixels-png", kind: "image" },
|
||||
{ id: "jpeg-artifacts-png", kind: "image" },
|
||||
// ── text ───────────────────────────────────────────────────
|
||||
{ id: "add-text-png", kind: "image" },
|
||||
{ id: "date-stamp-png", kind: "image" },
|
||||
{ id: "watermark-tile-png", kind: "image" },
|
||||
// ── analyze (image output) ─────────────────────────────────
|
||||
{ id: "extract-color-from-png", kind: "image", file: transparentPng },
|
||||
{ id: "show-transparent-png", kind: "image", file: transparentPng },
|
||||
{ id: "light-pixel-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "extract-channel-png", kind: "image", file: transparentPng },
|
||||
{ id: "unique-color-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "show-grayscale-pixels-png", kind: "image", file: transparentPng },
|
||||
{ id: "show-color-pixels-png", kind: "image", file: transparentPng },
|
||||
{ id: "dark-pixel-mask-png", kind: "image", file: transparentPng },
|
||||
// ── analyze (verdict output) ───────────────────────────────
|
||||
{ id: "png-is-transparent", kind: "verdict" },
|
||||
{ id: "png-is-grayscale", kind: "verdict" },
|
||||
{ id: "png-orientation", kind: "verdict" },
|
||||
{ id: "png-file-size", kind: "verdict" },
|
||||
];
|
||||
|
||||
test.describe("smoke: tools produce output without errors", () => {
|
||||
for (const { id, kind, file: fixture } of CASES) {
|
||||
test(`tool ${id} (${kind})`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
|
||||
const outputLocator = (): ReturnType<typeof page.locator> => {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return page.locator('img[alt="result"]');
|
||||
case "text-out":
|
||||
return page.locator(".result-pre code");
|
||||
case "verdict":
|
||||
return page.locator(".verdict-text");
|
||||
}
|
||||
};
|
||||
|
||||
// toPass: устойчивость к hydration-рейсу (ввод до гидрации SvelteKit
|
||||
// может не обработаться с первого раза).
|
||||
await expect(async () => {
|
||||
await openTool(page, id);
|
||||
await uploadImage(page, fixture ?? opaquePng);
|
||||
await expect(outputLocator()).toBeVisible({ timeout: 8_000 });
|
||||
}).toPass({ timeout: 25_000 });
|
||||
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,14 +6,28 @@
|
||||
// Properties that accept a COLOR.
|
||||
// =====================================================================
|
||||
export const COLOR_PROPS =
|
||||
/^(color|background|background-color|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
|
||||
/^(color|background|background-color|background-image|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
|
||||
|
||||
// =====================================================================
|
||||
// Properties that accept a SIZE (px/rem/em).
|
||||
// z-index is handled separately (it's an integer, not a length).
|
||||
// =====================================================================
|
||||
export const SIZE_PROPS =
|
||||
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|border-spacing)$/;
|
||||
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
|
||||
|
||||
// =====================================================================
|
||||
// SHORTHAND properties that accept BOTH a color and a size. The browser
|
||||
// assigns their sub-properties by value type at runtime (length -> width,
|
||||
// color -> ...-color), so they cannot be category-checked positionally.
|
||||
// border -> border-width + border-color
|
||||
// outline -> outline-width + outline-color
|
||||
// text-decoration -> text-decoration-line/-color/...
|
||||
// column-rule -> column-rule-width + column-rule-color
|
||||
// The longhands they expand to (border-width, border-color, ...) are already
|
||||
// covered individually by SIZE_PROPS / COLOR_PROPS.
|
||||
// =====================================================================
|
||||
export const MIXED_PROPS =
|
||||
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
|
||||
|
||||
// =====================================================================
|
||||
// Properties that carry a DURATION (ms/s) — transitions/animations.
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
|
||||
// is a sign the wrong token is being reused.
|
||||
|
||||
import { COLOR_PROPS, COLOR_TOKEN, SIZE_PROPS, SIZE_TOKEN } from "./lists.js";
|
||||
import {
|
||||
COLOR_PROPS,
|
||||
COLOR_TOKEN,
|
||||
MIXED_PROPS,
|
||||
SIZE_PROPS,
|
||||
SIZE_TOKEN,
|
||||
} from "./lists.js";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
|
||||
// Collect the token names referenced by var() in a value.
|
||||
@@ -50,6 +56,14 @@ export default {
|
||||
const prop = decl.prop ?? "";
|
||||
const value = decl.value ?? "";
|
||||
|
||||
// Mixed shorthands (border, outline, text-decoration,
|
||||
// column-rule) legitimately take BOTH a size and a color.
|
||||
// The browser assigns sub-properties by value type at runtime,
|
||||
// not by position, so skip category checks for them.
|
||||
// Their longhands (border-width, border-color, ...) are still
|
||||
// covered individually.
|
||||
if (MIXED_PROPS.test(prop)) return;
|
||||
|
||||
const isSizeProp = SIZE_PROPS.test(prop);
|
||||
const isColorProp = COLOR_PROPS.test(prop);
|
||||
if (!isSizeProp && !isColorProp) return;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DURATION_PROPS,
|
||||
FORBIDDEN_DURATION_TOKEN,
|
||||
FORBIDDEN_SIZE_TOKEN,
|
||||
MIXED_PROPS,
|
||||
SIZE_PROPS,
|
||||
} from "./lists.js";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
@@ -36,7 +37,9 @@ export default {
|
||||
hardcodedZIndex:
|
||||
"Hardcoded z-index '{{value}}'. Use a var(--z-...) token.",
|
||||
hardcodedBreakpoint:
|
||||
"Hardcoded breakpoint '{{value}}' in @media. Use a var(--bp-...) token.",
|
||||
"Hardcoded breakpoint '{{value}}' in @media. Use the @custom-media name instead: declare `@custom-media --bp-* (...)` in preview.css and write `@media (--bp-*)`.",
|
||||
varInMedia:
|
||||
"var() inside @media '{{value}}'. Custom properties do not resolve in media queries — declare `@custom-media --bp-* (...)` in preview.css and use `@media (--bp-*)`.",
|
||||
colorMix:
|
||||
"color-mix() in component ({{value}}). Tokenize the result in the design CSS file.",
|
||||
},
|
||||
@@ -69,6 +72,10 @@ export default {
|
||||
const BREAKPOINT_RE =
|
||||
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
|
||||
|
||||
// Custom properties do not resolve inside @media conditions, so any
|
||||
// var(--bp-*) inside @media is a latent bug — the block never applies.
|
||||
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
||||
|
||||
// Sub-rule 6: color-mix() is banned anywhere in a component style.
|
||||
const COLOR_MIX_RE = /color-mix\s*\(/gi;
|
||||
|
||||
@@ -80,6 +87,10 @@ export default {
|
||||
while ((m = BREAKPOINT_RE.exec(params)) !== null) {
|
||||
report(atRule, "hardcodedBreakpoint", { value: m[1] });
|
||||
}
|
||||
VAR_IN_MEDIA_RE.lastIndex = 0;
|
||||
while ((m = VAR_IN_MEDIA_RE.exec(params)) !== null) {
|
||||
report(atRule, "varInMedia", { value: m[1] });
|
||||
}
|
||||
});
|
||||
|
||||
// --- declarations --- //
|
||||
@@ -115,13 +126,16 @@ export default {
|
||||
// Sub-rule 2: sizes.
|
||||
// Fire only on a real remaining px/rem/em token — percentages
|
||||
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
|
||||
// Only legal absolute length is a single 1px border line.
|
||||
if (SIZE_PROPS.test(prop)) {
|
||||
// The only legal absolute length is a 0px reset line.
|
||||
// MIXED_PROPS (border/outline shorthands) are checked for their
|
||||
// SIZE half too — a border width must come from var(--size-*).
|
||||
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
|
||||
const withoutVars = stripVars(value);
|
||||
const match = withoutVars.match(FORBIDDEN_SIZE_TOKEN);
|
||||
if (match) {
|
||||
const reported = new Set();
|
||||
for (const match of withoutVars.matchAll(FORBIDDEN_SIZE_TOKEN)) {
|
||||
const shown = match[0];
|
||||
if (shown !== "1px" && shown !== "0px") {
|
||||
if (shown !== "0px" && !reported.has(shown)) {
|
||||
reported.add(shown);
|
||||
report(decl, "hardcodedSize", { prop, value: shown });
|
||||
}
|
||||
}
|
||||
@@ -140,12 +154,13 @@ export default {
|
||||
)
|
||||
.trim();
|
||||
if (withoutKeywords !== "" && withoutKeywords !== "0") {
|
||||
const match = value.match(COLOR_LITERAL);
|
||||
if (match) {
|
||||
report(decl, "hardcodedColor", {
|
||||
prop,
|
||||
value: match[0].trim(),
|
||||
});
|
||||
const reported = new Set();
|
||||
for (const match of value.matchAll(COLOR_LITERAL)) {
|
||||
const shown = match[0].trim();
|
||||
if (!reported.has(shown)) {
|
||||
reported.add(shown);
|
||||
report(decl, "hardcodedColor", { prop, value: shown });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Local ESLint plugin "isolation".
|
||||
*
|
||||
* Guarantees full isolation between the old UI branch and the new (preview)
|
||||
* branch. Unlike no-restricted-imports (which matches the literal import
|
||||
* specifier string only), these rules RESOLVE the specifier to a real file
|
||||
* (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then
|
||||
* classify both sides by their actual location on disk.
|
||||
*
|
||||
* Why this is needed:
|
||||
* - old code imports new things via RELATIVE paths (e.g. `../registry-schema`),
|
||||
* which `$lib/...` glob patterns cannot catch;
|
||||
* - the same target can be written many ways (`$lib/x`, `../x`, `../../x`),
|
||||
* so enumerating literal strings is fragile.
|
||||
*
|
||||
* Configuration (per rule options):
|
||||
* - `root`: path from the lint cwd to the source root (default "src");
|
||||
* - `alias`: mapping from import aliases to dirs relative to root
|
||||
* (default { "$lib": "lib" });
|
||||
* - `old`, `new`: glob patterns (gitignore-style, `!` = negation) for the two
|
||||
* branches. Files matched by neither are "shared" and unrestricted.
|
||||
*/
|
||||
import noMixedImports from "./no-mixed-imports.js";
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
name: "isolation",
|
||||
version: "1.0.0",
|
||||
},
|
||||
rules: {
|
||||
"no-mixed-imports": noMixedImports,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Isolation rule: forbid mixing the "old" and the "new" (preview) branches.
|
||||
*
|
||||
* The rule RESOLVES every import specifier to a real file (handles both the
|
||||
* `$lib/...` alias and relative `./` / `../` paths), classifies the source
|
||||
* file and each import target by their actual location, and reports whenever an
|
||||
* "old" file imports a "new" one or vice versa. Files that match neither side
|
||||
* are "shared" (core/, i18n/, theme, assets, tests, ...) and can import freely.
|
||||
*
|
||||
* Options (object, all optional):
|
||||
* - root: path from lint cwd to the source dir (default "src");
|
||||
* - alias: { $lib: "lib" } — alias -> dir relative to root;
|
||||
* - old, new: gitignore-style glob lists for the two branches. Both default
|
||||
* to the current layout so that moving a module between branches is a
|
||||
* one-line config change (e.g. everything old will live under `old/`).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_OLD = [
|
||||
"routes/(old)/**",
|
||||
"lib/registry/**",
|
||||
"lib/registry.ts",
|
||||
"lib/registry-helpers.ts",
|
||||
"lib/categories.ts",
|
||||
"lib/tools/**",
|
||||
"lib/components/**",
|
||||
"!lib/components/kit/**",
|
||||
];
|
||||
|
||||
const DEFAULT_NEW = [
|
||||
"routes/preview/**",
|
||||
"lib/components/kit/**",
|
||||
"lib/preview/**",
|
||||
"lib/registry-new/**",
|
||||
"lib/registry-schema.ts",
|
||||
"lib/registry-schema.test.ts",
|
||||
];
|
||||
|
||||
/** Escape everything except glob metacharacters, then handle **, *, ?. */
|
||||
function globToRegExp(glob) {
|
||||
let out = "^";
|
||||
for (let i = 0; i < glob.length; i++) {
|
||||
const ch = glob[i];
|
||||
if (ch === "*" && glob[i + 1] === "*") {
|
||||
out += ".*";
|
||||
i++;
|
||||
} else if (ch === "*") {
|
||||
out += "[^/]*";
|
||||
} else if (ch === "?") {
|
||||
out += "[^/]";
|
||||
} else if ("\\^$.|?*+()[]{}".includes(ch)) {
|
||||
out += `\\${ch}`;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return new RegExp(out + "$");
|
||||
}
|
||||
|
||||
/** gitignore-style: last-match-wins, `!` negates earlier positives. */
|
||||
function matchesGlobList(patterns, rel) {
|
||||
let included = false;
|
||||
for (const raw of patterns) {
|
||||
const negated = raw.startsWith("!");
|
||||
const pattern = negated ? raw.slice(1) : raw;
|
||||
if (globToRegExp(pattern).test(rel)) {
|
||||
if (negated) return false;
|
||||
included = true;
|
||||
}
|
||||
}
|
||||
return included;
|
||||
}
|
||||
|
||||
/** First existing candidate when resolving a bare path (no extension known). */
|
||||
function firstExisting(base) {
|
||||
const candidates = [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.svelte`,
|
||||
`${base}.svelte.ts`,
|
||||
`${base}/index.ts`,
|
||||
`${base}/index.svelte`,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (fs.statSync(c).isFile()) return c;
|
||||
} catch {
|
||||
// keep trying
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toPortable(p) {
|
||||
return p.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Forbid imports between the old UI branch and the new (preview) branch.",
|
||||
category: "Best Practices",
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
root: { type: "string" },
|
||||
alias: { type: "object", additionalProperties: { type: "string" } },
|
||||
old: { type: "array", items: { type: "string" } },
|
||||
new: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
noMixed:
|
||||
'Isolation violation: "{{side}}" file imports "{{target}}" ({{targetRel}}).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const options = context.options[0] || {};
|
||||
const cwd = context.cwd;
|
||||
const srcRoot = path.resolve(cwd, options.root || "src");
|
||||
const alias = options.alias || { $lib: "lib" };
|
||||
const oldPatterns = options.old || DEFAULT_OLD;
|
||||
const newPatterns = options.new || DEFAULT_NEW;
|
||||
|
||||
/** Classify a path (already absolute or portable rel to srcRoot). */
|
||||
function classify(absOrRel, relativeToRoot) {
|
||||
const rel = relativeToRoot
|
||||
? absOrRel
|
||||
: toPortable(path.relative(srcRoot, absOrRel));
|
||||
// new wins over old if a path is matched by both (e.g. components/kit
|
||||
// is != lib/components/** via negation, but keep precedence safe).
|
||||
if (matchesGlobList(newPatterns, rel)) return "new";
|
||||
if (matchesGlobList(oldPatterns, rel)) return "old";
|
||||
return "shared";
|
||||
}
|
||||
|
||||
/** Resolve a specifier to a portable rel path from srcRoot, or null. */
|
||||
function resolveToRel(spec, currentDir) {
|
||||
if (spec.startsWith("$")) {
|
||||
const dot = spec.indexOf("/");
|
||||
const mapKey = spec.slice(0, dot === -1 ? spec.length : dot);
|
||||
const dir = alias[mapKey];
|
||||
if (!dir) return null;
|
||||
const rest = dot === -1 ? "" : spec.slice(dot + 1);
|
||||
const base = path.resolve(srcRoot, dir, rest);
|
||||
const found = firstExisting(base);
|
||||
if (!found) return null;
|
||||
return toPortable(path.relative(srcRoot, found));
|
||||
}
|
||||
if (spec.startsWith("./") || spec.startsWith("../")) {
|
||||
const base = path.resolve(currentDir, spec);
|
||||
const found = firstExisting(base);
|
||||
if (!found) return null;
|
||||
const rel = path.relative(srcRoot, found);
|
||||
if (rel.startsWith("..")) return null; // outside srcRoot
|
||||
return toPortable(rel);
|
||||
}
|
||||
return null; // package import (svelte, vitest, @lucide, ...)
|
||||
}
|
||||
|
||||
const filename = context.filename || context.physicalFilename;
|
||||
const currentAbs = path.resolve(filename);
|
||||
const currentRel = toPortable(path.relative(srcRoot, currentAbs));
|
||||
const currentSide = classify(currentRel, true);
|
||||
const currentDir = path.dirname(currentAbs);
|
||||
|
||||
if (currentSide === "shared") return {};
|
||||
|
||||
function checkSource(node) {
|
||||
const spec = node.source?.value;
|
||||
if (typeof spec !== "string") return;
|
||||
const targetRel = resolveToRel(spec, currentDir);
|
||||
if (!targetRel) return;
|
||||
const targetSide = classify(targetRel, true);
|
||||
if (
|
||||
(currentSide === "old" && targetSide === "new") ||
|
||||
(currentSide === "new" && targetSide === "old")
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "noMixed",
|
||||
data: { side: currentSide, target: targetSide, targetRel },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration: checkSource,
|
||||
ImportExpression: checkSource,
|
||||
ExportNamedDeclaration: checkSource,
|
||||
ExportAllDeclaration: checkSource,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import js from "@eslint/js";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import designTokens from "./eslint-plugins/index.js";
|
||||
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
||||
import globals from "globals";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
@@ -49,7 +50,18 @@ export default tseslint.config(
|
||||
"vitest.config.ts",
|
||||
"eslint.config.js",
|
||||
"svelte.config.js",
|
||||
"playwright.config.ts",
|
||||
"e2e/helpers/fixtures.ts",
|
||||
"e2e/helpers/page.ts",
|
||||
"e2e/navigation.spec.ts",
|
||||
"e2e/catalog.spec.ts",
|
||||
"e2e/pipeline.spec.ts",
|
||||
"e2e/text-and-verdicts.spec.ts",
|
||||
"e2e/tools-smoke.spec.ts",
|
||||
"e2e/generators.spec.ts",
|
||||
"e2e/known-issues.spec.ts",
|
||||
],
|
||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 12,
|
||||
},
|
||||
extraFileExtensions: [".svelte"],
|
||||
},
|
||||
@@ -114,6 +126,20 @@ export default tseslint.config(
|
||||
"prefer-const": "off",
|
||||
},
|
||||
},
|
||||
// ===== Изоляция старого UI (old) и нового preview =====================
|
||||
// Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5).
|
||||
// Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному
|
||||
// пути (и $lib, и относительные) и ругается на old→new / new→old.
|
||||
// Общее (core/, i18n/, theme) разрешено обоим.
|
||||
{
|
||||
files: ["**/*.{ts,svelte}"],
|
||||
plugins: {
|
||||
isolation: isolationPlugin,
|
||||
},
|
||||
rules: {
|
||||
"isolation/no-mixed-imports": "error",
|
||||
},
|
||||
},
|
||||
// prettier — последним, чтобы гасить форматирующие правила из recommended.
|
||||
prettier,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"format": "prettier --write . --log-level warn",
|
||||
"lint": "eslint .",
|
||||
"lint:css": "stylelint \"./src/**/*.css\"",
|
||||
@@ -23,6 +24,7 @@
|
||||
"refs-dom-audit": "node scripts/audit-dom.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-global-data": "^4.0.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@panmdaa/colors": "^0.4.2",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -35,6 +37,7 @@
|
||||
"globals": "^17.11.0",
|
||||
"playwright": "^1.62.1",
|
||||
"postcss": "^8.5.26",
|
||||
"postcss-custom-media": "^12.0.1",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"stylelint": "^17.14.1",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "./e2e-results",
|
||||
fullyParallel: true,
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm build && node scripts/serve-static.mjs --port 4173",
|
||||
url: "http://127.0.0.1:4173/preview",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
});
|
||||
Generated
+42
@@ -18,6 +18,9 @@ importers:
|
||||
specifier: ^1.33.0
|
||||
version: 1.33.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))
|
||||
devDependencies:
|
||||
'@csstools/postcss-global-data':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(postcss@8.5.26)
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(eslint@10.9.0(supports-color@10.2.2))
|
||||
@@ -54,6 +57,9 @@ importers:
|
||||
postcss:
|
||||
specifier: ^8.5.26
|
||||
version: 8.5.26
|
||||
postcss-custom-media:
|
||||
specifier: ^12.0.1
|
||||
version: 12.0.1(postcss@8.5.26)
|
||||
prettier:
|
||||
specifier: ^3.9.6
|
||||
version: 3.9.6
|
||||
@@ -104,6 +110,13 @@ packages:
|
||||
'@cacheable/utils@2.5.0':
|
||||
resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==}
|
||||
|
||||
'@csstools/cascade-layer-name-parser@3.0.0':
|
||||
resolution: {integrity: sha512-/3iksyevwRfSJx5yH0RkcrcYXwuhMQx3Juqf40t97PeEy2/Mz2TItZ/z/216qpe4GgOyFBP8MKIwVvytzHmfIQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-calc@3.3.0':
|
||||
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -136,6 +149,12 @@ packages:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/postcss-global-data@4.0.0':
|
||||
resolution: {integrity: sha512-mPKrL3Tzt8k1V+hTkU8XTH6JKRekB97oKHv/MekC9nfmRa+gMf1swlHRt8YK722sYN4xOipl17FZst99jsScLA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
|
||||
'@csstools/selector-resolve-nested@4.0.1':
|
||||
resolution: {integrity: sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1176,6 +1195,12 @@ packages:
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss-custom-media@12.0.1:
|
||||
resolution: {integrity: sha512-66syE14+VeqkUf0rRX0bvbTCbNRJF132jD+ceo8th1dap2YJEAqpdh5uG98CE3IbgHT7m9XM0GIlOazNWqQdeA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
|
||||
postcss-load-config@3.1.4:
|
||||
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1582,6 +1607,11 @@ snapshots:
|
||||
hashery: 1.5.1
|
||||
keyv: 5.6.0
|
||||
|
||||
'@csstools/cascade-layer-name-parser@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
@@ -1602,6 +1632,10 @@ snapshots:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/postcss-global-data@4.0.0(postcss@8.5.26)':
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
|
||||
'@csstools/selector-resolve-nested@4.0.1(postcss-selector-parser@7.1.5)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.5
|
||||
@@ -2520,6 +2554,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-custom-media@12.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
'@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
'@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
postcss: 8.5.26
|
||||
|
||||
postcss-load-config@3.1.4(postcss@8.5.26):
|
||||
dependencies:
|
||||
lilconfig: 2.1.0
|
||||
|
||||
+13
-1
@@ -1,5 +1,17 @@
|
||||
import postcssHct from "./scripts/postcss-hct.mjs";
|
||||
import postcssGlobalData from "@csstools/postcss-global-data";
|
||||
import postcssCustomMedia from "postcss-custom-media";
|
||||
|
||||
export default {
|
||||
plugins: [postcssHct],
|
||||
plugins: [
|
||||
// Breakpoints live only in preview.css (next to the --bp-* tokens);
|
||||
// global-data injects them so @media (--bp-*) expands in every file.
|
||||
postcssGlobalData({
|
||||
files: ["src/preview.css"],
|
||||
}),
|
||||
postcssCustomMedia({
|
||||
preserve: false,
|
||||
}),
|
||||
postcssHct,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Статический сервер для e2e (Playwright webServer). Раздаёт web/build
|
||||
// (adapter-static): /foo -> foo.html | foo/index.html, MIME по расширению.
|
||||
// Запуск: pnpm build && node scripts/serve-static.mjs --port 4173
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const portIndex = process.argv.indexOf("--port");
|
||||
const PORT =
|
||||
portIndex >= 0 ? Number(process.argv[portIndex + 1]) || 4173 : 4173;
|
||||
const ROOT = fileURLToPath(new URL("../build", import.meta.url));
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".xml": "application/xml",
|
||||
".map": "application/json",
|
||||
};
|
||||
|
||||
function candidates(pathname) {
|
||||
const rel = pathname.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
const base = rel ? join(ROOT, ...rel.split("/")) : ROOT;
|
||||
const out = [base];
|
||||
if (!extname(base)) {
|
||||
out.push(`${base}.html`);
|
||||
out.push(join(base, "index.html"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serve(req, res) {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const path = decodeURIComponent(url.pathname);
|
||||
for (const file of candidates(path)) {
|
||||
try {
|
||||
if (!statSync(file).isFile()) continue;
|
||||
const body = readFileSync(file);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[extname(file)] ?? "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(body);
|
||||
return;
|
||||
} catch {
|
||||
/* not found — try next candidate */
|
||||
}
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Not found: " + path);
|
||||
}
|
||||
|
||||
createServer(serve).listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[serve-static] serving ${ROOT} on http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
+1
-1
@@ -23,6 +23,6 @@
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
%sveltekit.body%
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
onclick?: () => void;
|
||||
type?: "button" | "submit";
|
||||
variant?: "primary" | "ghost" | "accent" | "danger";
|
||||
icon?: Component<{ size?: number; class?: string }>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
let {
|
||||
children,
|
||||
onclick,
|
||||
type = "button",
|
||||
variant = "primary",
|
||||
icon,
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
class="btn btn--{variant}"
|
||||
{disabled}
|
||||
onclick={() => onclick?.()}
|
||||
>
|
||||
{#if icon}<Icon {icon} size={14} />{/if}
|
||||
<span class="btn-label">{@render children()}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn--primary {
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.btn--accent {
|
||||
background: var(--cyan);
|
||||
color: #042;
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
.btn--danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -23,29 +23,30 @@
|
||||
.catalog-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: var(--space-xl);
|
||||
padding-bottom: var(--space-m);
|
||||
border-bottom: var(--size-border) solid var(--color-border);
|
||||
}
|
||||
.group-title span {
|
||||
font: 12px var(--font-mono);
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--foreground);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-xl);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.group-title i {
|
||||
font: 10px var(--font-mono);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
font-style: normal;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
letter-spacing: var(--space-text-l);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.tool-cards {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,26 +25,26 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1.5rem;
|
||||
gap: var(--space-xxl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow {
|
||||
font: 12px var(--font-mono);
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.5rem;
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-xl);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
color: var(--color-text);
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
margin: var(--space-m) 0 0;
|
||||
max-width: 56ch;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-m);
|
||||
}
|
||||
.catalog-total {
|
||||
display: flex;
|
||||
@@ -53,14 +53,14 @@
|
||||
line-height: 1;
|
||||
}
|
||||
.catalog-total b {
|
||||
font: 700 2rem var(--font-mono);
|
||||
color: var(--foreground);
|
||||
font: 700 var(--font-size-2xl) var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.catalog-total span {
|
||||
font: 10px var(--font-mono);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-l);
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
margin-top: 0.35rem;
|
||||
margin-top: var(--space-s);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Search, Filter } from "@lucide/svelte";
|
||||
import { Funnel, Search } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
@@ -14,8 +14,12 @@
|
||||
{ value: "color", label: "COLOR" },
|
||||
{ value: "geometry", label: "GEOMETRY" },
|
||||
{ value: "filters", label: "FILTERS" },
|
||||
{ value: "text", label: "TEXT" },
|
||||
{ value: "analyze", label: "ANALYZE" },
|
||||
{ value: "generate", label: "GENERATE" },
|
||||
];
|
||||
|
||||
// TODO: Button component
|
||||
</script>
|
||||
|
||||
<div class="catalog-toolbar">
|
||||
@@ -28,13 +32,15 @@
|
||||
/>
|
||||
</label>
|
||||
<div class="catalog-filters">
|
||||
<Filter size={15} />
|
||||
<Funnel size={15} />
|
||||
{#each FILTERS as f (f.value)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={category === f.value}
|
||||
onclick={() => (category = f.value)}>{f.label}</button
|
||||
onclick={() => (category = f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,50 +50,50 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
gap: var(--space-xl);
|
||||
flex-wrap: wrap;
|
||||
padding: 1.25rem 0;
|
||||
padding: var(--space-xl) 0;
|
||||
}
|
||||
.catalog-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
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: var(--color-panel);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.catalog-search input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font: 12px var(--font-mono);
|
||||
color: var(--color-text);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
.catalog-search input::placeholder {
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.catalog-filters {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
gap: var(--space-s);
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.catalog-filters button {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: 10px var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: var(--radius);
|
||||
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);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
border-radius: var(--radius-m);
|
||||
cursor: pointer;
|
||||
}
|
||||
.catalog-filters button.active {
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
border-color: var(--blue);
|
||||
background: var(--color-main);
|
||||
color: var(--color-background);
|
||||
border-color: var(--color-main);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,25 +20,25 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background-color: #0b0f14;
|
||||
background-color: var(--color-text);
|
||||
background-image:
|
||||
linear-gradient(45deg, #1a2129 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a2129 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a2129 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a2129 75%);
|
||||
background-size: 20px 20px;
|
||||
linear-gradient(45deg, var(--color-checker-main) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--color-checker-main) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--color-checker-main) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--color-checker-main) 75%);
|
||||
background-size: var(--space-xl) var(--space-xl);
|
||||
background-position:
|
||||
0 0,
|
||||
0 10px,
|
||||
10px -10px,
|
||||
-10px 0;
|
||||
0 calc(var(--space-xl) / 2),
|
||||
calc(var(--space-xl) / 2) calc(-1 * var(--space-xl) / 2),
|
||||
calc(-1 * var(--space-xl) / 2) 0;
|
||||
}
|
||||
|
||||
.checker-canvas--sm {
|
||||
min-height: 180px;
|
||||
min-height: var(--size-preview-canvas-min-height-s);
|
||||
}
|
||||
|
||||
.checker-canvas--large {
|
||||
min-height: 360px;
|
||||
min-height: var(--size-preview-canvas-min-height-l);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Copy } from "@lucide/svelte";
|
||||
import IconButton from "./IconButton.svelte";
|
||||
import IconButton from "./ui/IconButton.svelte";
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
@@ -24,27 +24,27 @@
|
||||
<style>
|
||||
.code-block {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--background);
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--color-background);
|
||||
padding: var(--space-m) var(--space-l);
|
||||
}
|
||||
.code-lang {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-size: var(--font-size-s);
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.code-pre {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.code-copy {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
top: var(--space-s);
|
||||
right: var(--space-s);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ArrowDownToLine, Download } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
onclick?: () => void;
|
||||
}
|
||||
let { label, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class="download-btn" type="button" onclick={() => onclick?.()}>
|
||||
<Download size={16} />
|
||||
{label}
|
||||
<ArrowDownToLine size={14} />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.download-btn {
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
font: 11px var(--font-mono);
|
||||
border: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.download-btn:hover {
|
||||
background: color-mix(in srgb, var(--blue) 88%, #000);
|
||||
}
|
||||
</style>
|
||||
@@ -63,32 +63,32 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 220px;
|
||||
padding: 2rem;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
background: var(--background);
|
||||
gap: var(--space-m);
|
||||
min-height: var(--size-panel-min-height);
|
||||
padding: var(--space-xxxl);
|
||||
border: var(--size-border) dashed var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-background);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
border-color var(--duration-s) ease,
|
||||
background var(--duration-s) ease;
|
||||
}
|
||||
.dropzone:hover,
|
||||
.dropzone:focus-visible,
|
||||
.dropzone.dragging {
|
||||
border-color: var(--blue);
|
||||
color: var(--foreground);
|
||||
border-color: var(--color-main);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
.dz-title {
|
||||
font: 600 0.9rem var(--font-mono);
|
||||
color: var(--foreground);
|
||||
font: 600 var(--font-size-m) var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.dz-sub {
|
||||
font: 10px var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-m);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import Icon from "./ui/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -23,20 +23,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1rem;
|
||||
gap: var(--space-m);
|
||||
padding: var(--space-xxxl) var(--space-xl);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.empty-icon {
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.empty-title {
|
||||
color: var(--foreground);
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-m);
|
||||
}
|
||||
.empty-desc {
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--font-size-s);
|
||||
max-width: 32ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { compact = false, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="controls" class:compact>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr 1.2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.controls.compact {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.controls,
|
||||
.controls.compact {
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import { Upload } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
size?: string;
|
||||
icon?: Component<{ size?: number }>;
|
||||
}
|
||||
|
||||
let { name, size, icon = Upload }: Props = $props();
|
||||
const Cmp = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div class="file-chip">
|
||||
<Cmp size={15} />
|
||||
<span>{name}</span>
|
||||
{#if size}<b>{size}</b>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
font: 11px var(--font-mono);
|
||||
color: var(--muted);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.file-chip b {
|
||||
color: var(--blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<footer class="preview-footer">
|
||||
<span>easy-png-tools <b class="version">v2.4.0</b></span>
|
||||
<span>© 2024</span>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.preview-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px clamp(20px, 4vw, 72px);
|
||||
border-top: 1px solid var(--line);
|
||||
font: 10px var(--font-mono);
|
||||
color: var(--muted);
|
||||
& span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
}
|
||||
.preview-footer .version {
|
||||
font-weight: 400;
|
||||
color: var(--blue);
|
||||
}
|
||||
</style>
|
||||
@@ -1,87 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
icon: Component<{ size?: number; class?: string }>;
|
||||
label: string;
|
||||
onclick?: () => void;
|
||||
variant?: "ghost" | "solid" | "accent" | "bare";
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
icon,
|
||||
label,
|
||||
onclick,
|
||||
variant = "ghost",
|
||||
size = 16,
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn icon-btn--{variant}"
|
||||
aria-label={label}
|
||||
{disabled}
|
||||
onclick={() => onclick?.()}
|
||||
>
|
||||
<Icon {icon} {size} />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.icon-btn {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
border: 0;
|
||||
background: 0 0;
|
||||
border: 0;
|
||||
place-items: center;
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
color: var(--foreground);
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.icon-btn--solid {
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.icon-btn--accent {
|
||||
background: var(--cyan);
|
||||
color: #042;
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
.icon-btn--bare {
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.icon-btn--bare:hover:not(:disabled) {
|
||||
color: var(--foreground);
|
||||
border-color: transparent;
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn--ghost {
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -18,15 +18,15 @@
|
||||
.image-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
gap: var(--space-s);
|
||||
margin: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem;
|
||||
background: var(--panel);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: var(--space-m);
|
||||
background: var(--color-panel);
|
||||
}
|
||||
.image-canvas {
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-m);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
<style>
|
||||
.meta-list {
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--size-border) solid var(--color-border);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding-top: 14px;
|
||||
gap: var(--space-m);
|
||||
padding-top: var(--space-l);
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,19 +13,19 @@
|
||||
|
||||
<style>
|
||||
.meta-row {
|
||||
gap: 5px;
|
||||
gap: var(--space-s);
|
||||
display: grid;
|
||||
}
|
||||
.meta-caption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text-muted);
|
||||
line-height: normal;
|
||||
}
|
||||
.meta-value {
|
||||
margin-top: 5px;
|
||||
margin-top: var(--space-s);
|
||||
line-height: normal;
|
||||
font: 11px var(--font-mono);
|
||||
color: var(--foreground);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,15 +17,13 @@
|
||||
|
||||
<style>
|
||||
.mono-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
font: normal var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-l);
|
||||
color: var(--color-text-muted);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: var(--blue);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Field from "./Field.svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
oninput?: (value: number) => void;
|
||||
}
|
||||
let { label, value = $bindable(), min, max, step, oninput }: Props = $props();
|
||||
|
||||
function handle(e: Event) {
|
||||
value = Number((e.target as HTMLInputElement).value);
|
||||
oninput?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field {label}>
|
||||
<input
|
||||
class="number-field"
|
||||
type="number"
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{value}
|
||||
oninput={handle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
.number-field {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.number-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--blue);
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
</style>
|
||||
@@ -23,18 +23,19 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-l) var(--space-xl);
|
||||
border-bottom: var(--size-border) solid var(--color-border);
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
.heading-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
.heading-title {
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--font-size-m);
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { RotateCcw } from "@lucide/svelte";
|
||||
import StatusDot from "./StatusDot.svelte";
|
||||
|
||||
interface Props {
|
||||
onreset?: () => void;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
let { onreset, note = "changes are applied automatically" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pipeline-footer">
|
||||
<button class="reset-btn" onclick={() => onreset?.()}>
|
||||
<RotateCcw size={14} /> Reset pipeline
|
||||
</button>
|
||||
<span class="auto-note"><StatusDot /> {note}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pipeline-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 18px;
|
||||
}
|
||||
.reset-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--blue);
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.auto-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font: 11px var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
pad?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { pad = "0", children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="preview-stack" style="padding:{pad};">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview-stack {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.preview-stack {
|
||||
grid-template-columns: repeat(3, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,74 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
caption: string;
|
||||
active: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { label, caption, active, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="preview-tile" class:active>
|
||||
<div class="tile-canvas">{@render children()}</div>
|
||||
<div class="tile-label">
|
||||
<span>{label}</span>
|
||||
<b>{caption}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview-tile {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--background);
|
||||
&.active {
|
||||
border-color: var(--blue);
|
||||
}
|
||||
}
|
||||
.tile-canvas {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 260px;
|
||||
min-height: clamp(300px, 22vw, 430px);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1.35;
|
||||
background-color: var(--checker-background);
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--checker-background-cross) 25%, #0000 25%),
|
||||
linear-gradient(-45deg, var(--checker-background-cross) 25%, #0000 25%),
|
||||
linear-gradient(45deg, #0000 75%, var(--checker-background-cross) 75%),
|
||||
linear-gradient(-45deg, #0000 75%, var(--checker-background-cross) 75%);
|
||||
background-position:
|
||||
0 0,
|
||||
0 7px,
|
||||
7px -7px,
|
||||
-7px 0;
|
||||
background-size: 14px 14px;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
}
|
||||
.tile-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
font: 9px var(--font-mono);
|
||||
color: var(--blue);
|
||||
}
|
||||
.tile-label span {
|
||||
font: 9px var(--font-mono);
|
||||
color: var(--blue);
|
||||
}
|
||||
.tile-label b {
|
||||
font: 400 9px var(--font-mono);
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import DownloadButton from "./ui/DownloadButton.svelte";
|
||||
import { Sparkles, Upload } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
inputMode: "file" | "text" | "none";
|
||||
canDownload: boolean;
|
||||
running: boolean;
|
||||
onupload: (file: File) => void;
|
||||
ongenerate: () => void;
|
||||
ondownload: () => void;
|
||||
}
|
||||
let {
|
||||
inputMode,
|
||||
canDownload,
|
||||
running,
|
||||
onupload,
|
||||
ongenerate,
|
||||
ondownload,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="actions">
|
||||
{#if inputMode === "none"}
|
||||
<button class="btn generate" onclick={ongenerate} disabled={running}>
|
||||
<Sparkles size={14} />
|
||||
{running ? "Generating…" : "Generate"}
|
||||
</button>
|
||||
{:else if inputMode === "file"}
|
||||
<label class="btn upload">
|
||||
<Upload size={14} /> Open image
|
||||
<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="Download result" onclick={ondownload} />
|
||||
{/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;
|
||||
}
|
||||
.generate {
|
||||
background: var(--color-main);
|
||||
border-color: var(--color-main);
|
||||
color: var(--color-background);
|
||||
}
|
||||
.generate:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.upload input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
FieldSpec,
|
||||
FieldSpecKind,
|
||||
ToolSchema,
|
||||
} from "$lib/registry-schema";
|
||||
import { RotateCcw } from "@lucide/svelte";
|
||||
import type { Component } from "svelte";
|
||||
import DimensionField from "./fields/DimensionField.svelte";
|
||||
import CheckboxControl from "./fields/schema/CheckboxControl.svelte";
|
||||
import ColorControl from "./fields/schema/ColorControl.svelte";
|
||||
import ColorPairControl from "./fields/schema/ColorPairControl.svelte";
|
||||
import ColorsControl from "./fields/schema/ColorsControl.svelte";
|
||||
import RangeControl from "./fields/schema/RangeControl.svelte";
|
||||
import SelectControl from "./fields/schema/SelectControl.svelte";
|
||||
import TextControl from "./fields/schema/TextControl.svelte";
|
||||
import OffsetControl from "./fields/schema/OffsetControl.svelte";
|
||||
import PositionControl from "./fields/schema/PositionControl.svelte";
|
||||
import FontStyleControl from "./fields/schema/FontStyleControl.svelte";
|
||||
import PlateControl from "./fields/schema/PlateControl.svelte";
|
||||
import GradientControl from "./fields/schema/GradientControl.svelte";
|
||||
|
||||
interface FieldControlProps {
|
||||
label: string;
|
||||
value: unknown;
|
||||
spec: FieldSpec;
|
||||
onchange?: (value: unknown) => void;
|
||||
}
|
||||
|
||||
const FIELDS: Record<FieldSpecKind, Component<FieldControlProps>> = {
|
||||
number: RangeControl,
|
||||
slider: RangeControl,
|
||||
color: ColorControl,
|
||||
"color-pair": ColorPairControl,
|
||||
colors: ColorsControl,
|
||||
select: SelectControl,
|
||||
checkbox: CheckboxControl,
|
||||
text: TextControl,
|
||||
dimension: DimensionField,
|
||||
offset: OffsetControl,
|
||||
position9: PositionControl,
|
||||
"font-style": FontStyleControl,
|
||||
plate: PlateControl,
|
||||
gradient: GradientControl,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
schema: ToolSchema<Record<string, unknown>>;
|
||||
values: Record<string, unknown>;
|
||||
onchange: (id: string, value: unknown) => void;
|
||||
onreset: () => void;
|
||||
}
|
||||
let { schema, values, onchange, onreset }: Props = $props();
|
||||
|
||||
function labelOf(id: string): string {
|
||||
return id
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface LayoutGroup {
|
||||
key: string;
|
||||
title?: string;
|
||||
cols: number;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
const layoutGroups = $derived.by((): LayoutGroup[] => {
|
||||
const all = Object.keys(schema.fields);
|
||||
const groups = schema.layout?.groups ?? [];
|
||||
const used: Record<string, true> = {};
|
||||
const named = groups
|
||||
.map((g, gi) => {
|
||||
const fields = g.fields.filter((f) => {
|
||||
if (used[f] || !(f in schema.fields)) return false;
|
||||
used[f] = true;
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
key: `${g.title ?? "group"}-${gi}`,
|
||||
title: g.title,
|
||||
cols: Math.max(1, Math.trunc(g.cols ?? 1)),
|
||||
fields,
|
||||
} satisfies LayoutGroup;
|
||||
})
|
||||
.filter((g) => g.fields.length > 0);
|
||||
const rest = all.filter((f) => !used[f]);
|
||||
if (rest.length > 0) {
|
||||
named.push({ key: "__default", title: undefined, cols: 1, fields: rest });
|
||||
}
|
||||
return named;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each layoutGroups as group (group.key)}
|
||||
{#if group.title}
|
||||
<span class="group-title">{group.title}</span>
|
||||
{/if}
|
||||
<div
|
||||
class="group-fields"
|
||||
style:grid-template-columns={group.cols > 1
|
||||
? `repeat(${group.cols}, minmax(0, 1fr))`
|
||||
: undefined}
|
||||
>
|
||||
{#each group.fields as id (id)}
|
||||
{@const Control = FIELDS[schema.fields[id].spec.kind]}
|
||||
<Control
|
||||
label={labelOf(id)}
|
||||
value={values[id]}
|
||||
spec={schema.fields[id].spec}
|
||||
onchange={(v) => onchange(id, v)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="panel-foot">
|
||||
<button class="reset-btn" onclick={onreset}>
|
||||
<RotateCcw size={14} /> Reset
|
||||
</button>
|
||||
<span class="auto-note"><i></i> updates automatically</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.group-title {
|
||||
display: block;
|
||||
margin-top: var(--space-l);
|
||||
margin-bottom: var(--space-s);
|
||||
color: var(--color-text-muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-l);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.group-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.group-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
column-gap: var(--space-l);
|
||||
}
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
margin-top: var(--space-xl);
|
||||
padding-top: var(--space-l);
|
||||
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 {
|
||||
width: var(--size-border-thick);
|
||||
height: var(--size-border-thick);
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import MetaList from "$lib/components/kit/MetaList.svelte";
|
||||
import SchemaActions from "$lib/components/kit/SchemaActions.svelte";
|
||||
import SchemaResultTile from "$lib/components/kit/SchemaResultTile.svelte";
|
||||
import SchemaSourceTile from "$lib/components/kit/SchemaSourceTile.svelte";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
|
||||
interface Props {
|
||||
inputMode?: "file" | "text" | "none";
|
||||
resultKind?: "image" | "text" | "verdict";
|
||||
source: PixelImage | null;
|
||||
result: PixelImage | null;
|
||||
textSource?: string;
|
||||
textResult?: string | null;
|
||||
running?: boolean;
|
||||
error?: string;
|
||||
onupload: (file: File) => void;
|
||||
ongenerate?: () => void;
|
||||
ontextsource?: (text: string) => void;
|
||||
onrendertext?: () => void;
|
||||
oncopytext?: () => void;
|
||||
ondownloadtxt?: () => void;
|
||||
ondownload: () => void;
|
||||
}
|
||||
let {
|
||||
inputMode = "file",
|
||||
resultKind = "image",
|
||||
source,
|
||||
result,
|
||||
textSource = "",
|
||||
textResult = null,
|
||||
running = false,
|
||||
error = "",
|
||||
onupload,
|
||||
ongenerate,
|
||||
ontextsource,
|
||||
onrendertext,
|
||||
oncopytext,
|
||||
ondownloadtxt,
|
||||
ondownload,
|
||||
}: Props = $props();
|
||||
|
||||
const isGenerator = $derived(inputMode === "none");
|
||||
const sourceValue = $derived(
|
||||
isGenerator
|
||||
? "—"
|
||||
: inputMode === "text"
|
||||
? "text"
|
||||
: source
|
||||
? `${source.width} × ${source.height} px`
|
||||
: "—",
|
||||
);
|
||||
const resultValue = $derived(
|
||||
resultKind === "image"
|
||||
? result
|
||||
? `${result.width} × ${result.height} px`
|
||||
: "—"
|
||||
: textResult
|
||||
? "text"
|
||||
: "—",
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="panel-head">
|
||||
<span class="label"
|
||||
>{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span
|
||||
>
|
||||
<SchemaActions
|
||||
{inputMode}
|
||||
canDownload={resultKind === "image" && Boolean(result)}
|
||||
{running}
|
||||
{onupload}
|
||||
ongenerate={ongenerate ?? (() => {})}
|
||||
{ondownload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="pair">
|
||||
{#if !isGenerator}
|
||||
<SchemaSourceTile
|
||||
mode={inputMode === "text" ? "text" : "file"}
|
||||
{source}
|
||||
{textSource}
|
||||
{running}
|
||||
ontextinput={ontextsource}
|
||||
{onrendertext}
|
||||
/>
|
||||
{/if}
|
||||
<SchemaResultTile
|
||||
{resultKind}
|
||||
{result}
|
||||
{textResult}
|
||||
wide={isGenerator || resultKind !== "image"}
|
||||
{running}
|
||||
oncopy={oncopytext}
|
||||
{ondownloadtxt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<MetaList
|
||||
items={[
|
||||
{ caption: "SOURCE", value: sourceValue },
|
||||
{ caption: "RESULT", value: resultValue },
|
||||
{ caption: "FORMAT", value: "PNG" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-m);
|
||||
padding-bottom: var(--space-l);
|
||||
border-bottom: var(--size-border) solid var(--color-border);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
.label {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-l);
|
||||
color: var(--color-main);
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 var(--space-l);
|
||||
color: var(--color-danger);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
.pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-l);
|
||||
}
|
||||
.meta {
|
||||
margin-top: var(--space-l);
|
||||
}
|
||||
@media (--bp-tablet) {
|
||||
.pair {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { toDataUrl } from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { Check, RefreshCw } from "@lucide/svelte";
|
||||
import SchemaTextResult from "./SchemaTextResult.svelte";
|
||||
|
||||
interface Props {
|
||||
resultKind?: "image" | "text" | "verdict";
|
||||
result: PixelImage | null;
|
||||
textResult?: string | null;
|
||||
wide?: boolean;
|
||||
running?: boolean;
|
||||
oncopy?: () => void;
|
||||
ondownloadtxt?: () => void;
|
||||
}
|
||||
let {
|
||||
resultKind = "image",
|
||||
result,
|
||||
textResult = null,
|
||||
wide = false,
|
||||
running = false,
|
||||
oncopy,
|
||||
ondownloadtxt,
|
||||
}: Props = $props();
|
||||
|
||||
const resultUrl = $derived(result ? toDataUrl(result) : null);
|
||||
</script>
|
||||
|
||||
<figure class="tile" style:grid-column={wide ? "1 / -1" : undefined}>
|
||||
<figcaption>
|
||||
<span>
|
||||
RESULT
|
||||
{#if running}
|
||||
<RefreshCw class="rotating" size="16" />
|
||||
{/if}
|
||||
</span>
|
||||
</figcaption>
|
||||
<div class="canvas" class:checker={resultKind === "image"}>
|
||||
{#if resultKind === "text" && textResult}
|
||||
<div class="text-result-wrap">
|
||||
<SchemaTextResult
|
||||
value={textResult}
|
||||
kind="text"
|
||||
oncopy={oncopy ?? (() => {})}
|
||||
ondownload={ondownloadtxt ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
{:else if resultKind === "verdict" && textResult}
|
||||
<div class="text-result-wrap">
|
||||
<SchemaTextResult
|
||||
value={textResult}
|
||||
kind="verdict"
|
||||
oncopy={oncopy ?? (() => {})}
|
||||
ondownload={ondownloadtxt ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
{:else if resultKind === "image" && resultUrl}
|
||||
<img src={resultUrl} alt="result" />
|
||||
{:else if running}
|
||||
<Check size={22} />
|
||||
{:else}
|
||||
<span class="empty">no result yet</span>
|
||||
{/if}
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.tile figcaption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
margin-bottom: var(--space-m);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
& :global(.rotating) {
|
||||
animation: rotate var(--duration-l) linear infinite;
|
||||
}
|
||||
}
|
||||
.canvas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-s);
|
||||
overflow: hidden;
|
||||
background: var(--color-background-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.canvas img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.canvas.checker {
|
||||
background: repeating-conic-gradient(
|
||||
var(--color-checker-main) 0 25%,
|
||||
var(--color-checker-alt) 0 50%
|
||||
)
|
||||
50% / 28px 28px;
|
||||
}
|
||||
.empty {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
.text-result-wrap {
|
||||
width: 100%;
|
||||
padding: var(--space-l);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { toDataUrl } from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import SchemaTextSource from "./SchemaTextSource.svelte";
|
||||
|
||||
interface Props {
|
||||
mode: "file" | "text";
|
||||
source: PixelImage | null;
|
||||
textSource?: string;
|
||||
running?: boolean;
|
||||
ontextinput?: (text: string) => void;
|
||||
onrendertext?: () => void;
|
||||
}
|
||||
let {
|
||||
mode,
|
||||
source,
|
||||
textSource = "",
|
||||
running = false,
|
||||
ontextinput,
|
||||
onrendertext,
|
||||
}: Props = $props();
|
||||
|
||||
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
||||
</script>
|
||||
|
||||
<figure class="tile">
|
||||
<figcaption><span>SOURCE</span></figcaption>
|
||||
<div class="canvas">
|
||||
{#if mode === "text"}
|
||||
<div class="text-source-wrap">
|
||||
<SchemaTextSource
|
||||
value={textSource}
|
||||
disabled={running}
|
||||
oninput={ontextinput ?? (() => {})}
|
||||
onrender={onrendertext ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
{:else if sourceUrl}
|
||||
<img src={sourceUrl} alt="source" />
|
||||
{:else}
|
||||
<span class="empty">choose an image</span>
|
||||
{/if}
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.tile figcaption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
margin-bottom: var(--space-m);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.canvas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-s);
|
||||
overflow: hidden;
|
||||
background: var(--color-background-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.canvas img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.empty {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
.text-source-wrap {
|
||||
width: 100%;
|
||||
padding: var(--space-l);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { Copy, Download, FileText } from "@lucide/svelte";
|
||||
import IconButton from "./ui/IconButton.svelte";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
kind: "text" | "verdict";
|
||||
fileName?: string;
|
||||
oncopy: () => void;
|
||||
ondownload: () => void;
|
||||
}
|
||||
let {
|
||||
value,
|
||||
kind,
|
||||
fileName = "result.txt",
|
||||
oncopy,
|
||||
ondownload,
|
||||
}: Props = $props();
|
||||
|
||||
const tone = $derived(
|
||||
/^(yes|true)/i.test(value.trim())
|
||||
? "success"
|
||||
: /^(no|false)/i.test(value.trim())
|
||||
? "danger"
|
||||
: "info",
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if kind === "verdict"}
|
||||
<div class="verdict">
|
||||
<span class="verdict-label">RESULT</span>
|
||||
<div class="badge badge-tone--{tone}" role="status">
|
||||
<span class="verdict-text">{value}</span>
|
||||
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-result">
|
||||
<div class="result-head">
|
||||
<span class="result-label"><FileText size={14} /> {fileName}</span>
|
||||
<div class="result-actions">
|
||||
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
|
||||
<IconButton
|
||||
icon={Download}
|
||||
label="Download .txt"
|
||||
onclick={ondownload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="result-pre"><code>{value}</code></pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.text-result {
|
||||
display: grid;
|
||||
gap: var(--space-m);
|
||||
min-width: 0;
|
||||
}
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
.result-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
color: var(--color-text-muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-l);
|
||||
}
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
max-height: var(--space-xxxl);
|
||||
overflow: auto;
|
||||
padding: var(--space-l) var(--space-m);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
.verdict {
|
||||
display: grid;
|
||||
gap: var(--space-m);
|
||||
justify-items: start;
|
||||
}
|
||||
.verdict-label {
|
||||
color: var(--color-text-muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-l);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-l);
|
||||
padding: var(--space-m) var(--space-xl);
|
||||
border: var(--size-border-thick) solid transparent;
|
||||
border-radius: var(--radius-m);
|
||||
font: var(--font-size-m) var(--font-mono);
|
||||
}
|
||||
.badge-tone--success {
|
||||
background: var(--color-success-tint);
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
.badge-tone--danger {
|
||||
background: var(--color-danger-tint);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.badge-tone--info {
|
||||
background: var(--color-info-tint);
|
||||
border-color: var(--color-info);
|
||||
color: var(--color-info);
|
||||
}
|
||||
.verdict-text {
|
||||
line-height: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { ArrowUp, Upload } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
oninput: (text: string) => void;
|
||||
onrender: () => void;
|
||||
onsample?: () => void;
|
||||
}
|
||||
let {
|
||||
value,
|
||||
placeholder = "Paste base64 / hex / bytes…",
|
||||
disabled = false,
|
||||
oninput,
|
||||
onrender,
|
||||
onsample,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="text-source">
|
||||
<textarea
|
||||
spellcheck="false"
|
||||
{placeholder}
|
||||
rows="6"
|
||||
{value}
|
||||
oninput={(e) => oninput((e.target as HTMLTextAreaElement).value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onrender();
|
||||
}
|
||||
}}></textarea>
|
||||
<div class="actions">
|
||||
{#if onsample}
|
||||
<button type="button" class="secondary" onclick={onsample}>
|
||||
<Upload size={14} /> Try sample
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="render" onclick={onrender} {disabled}>
|
||||
<ArrowUp size={14} /> Render text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-source {
|
||||
display: grid;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: var(--space-brand);
|
||||
max-height: var(--space-xxxl);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-m);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
line-height: 1.5;
|
||||
}
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-main);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-m);
|
||||
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>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import SchemaFields from "$lib/components/kit/SchemaFields.svelte";
|
||||
import SchemaPreview from "$lib/components/kit/SchemaPreview.svelte";
|
||||
import { debounce } from "$lib/core/debounce";
|
||||
import { decodeFile, encode } from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import {
|
||||
executeFromText,
|
||||
executeGenerate,
|
||||
executeStep,
|
||||
executeTextToText,
|
||||
executeToText,
|
||||
} from "$lib/preview/executor";
|
||||
import type { ToolEntry } from "$lib/registry-new";
|
||||
import {
|
||||
defaultSchemaParams,
|
||||
sanitizeSchemaParams,
|
||||
type ToolSchema,
|
||||
} from "$lib/registry-schema";
|
||||
|
||||
interface Props {
|
||||
tool: ToolEntry;
|
||||
}
|
||||
let { tool }: Props = $props();
|
||||
|
||||
const schema = $derived(
|
||||
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
|
||||
);
|
||||
|
||||
const isGenerator = $derived(Boolean(tool.generate && !tool.run));
|
||||
const inputMode = $derived(tool.input ?? "file");
|
||||
const resultKind = $derived(tool.result ?? "image");
|
||||
|
||||
let values = $state<Record<string, unknown>>({});
|
||||
let source = $state<PixelImage | null>(null);
|
||||
let result = $state<PixelImage | null>(null);
|
||||
let textSource = $state("");
|
||||
let textResult = $state<string | null>(null);
|
||||
let running = $state(false);
|
||||
let error = $state("");
|
||||
let started = $state(false);
|
||||
|
||||
const debouncedRun = debounce(() => run(), 200);
|
||||
|
||||
function init() {
|
||||
if (!schema || started) return;
|
||||
started = true;
|
||||
values = defaultSchemaParams(schema);
|
||||
}
|
||||
|
||||
function setValue(id: string, value: unknown) {
|
||||
values = { ...values, [id]: value };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
values = schema ? defaultSchemaParams(schema) : {};
|
||||
result = null;
|
||||
textResult = null;
|
||||
error = "";
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
error = "";
|
||||
try {
|
||||
source = await decodeFile(file);
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!schema) return;
|
||||
if (isGenerator) {
|
||||
if (!tool.generate) return;
|
||||
await runGenerate();
|
||||
return;
|
||||
}
|
||||
error = "";
|
||||
const params = sanitizeSchemaParams(schema, values);
|
||||
running = true;
|
||||
try {
|
||||
if (inputMode === "text") {
|
||||
if (!textSource.trim()) return;
|
||||
if (tool.textToText) {
|
||||
textResult = await executeTextToText(tool, textSource);
|
||||
result = null;
|
||||
} else {
|
||||
result = await executeFromText(tool, textSource, params);
|
||||
textResult = null;
|
||||
}
|
||||
} else {
|
||||
if (!source) return;
|
||||
if (resultKind !== "image" && tool.toText) {
|
||||
textResult = await executeToText(tool, source, params);
|
||||
result = null;
|
||||
} else {
|
||||
result = await executeStep(tool, source, params);
|
||||
textResult = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGenerate() {
|
||||
if (!schema || !tool.generate) return;
|
||||
running = true;
|
||||
error = "";
|
||||
const params = sanitizeSchemaParams(schema, values);
|
||||
try {
|
||||
result = await executeGenerate(tool, params);
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!result || !schema) return;
|
||||
const out = tool.output;
|
||||
const quality = out?.qualityParamId
|
||||
? Number(values[out.qualityParamId]) / 100
|
||||
: undefined;
|
||||
const blob = await encode(result, out?.mime ?? "image/png", quality);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tool.id}.${out?.ext ?? "png"}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyText() {
|
||||
if (!textResult) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(textResult);
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadText() {
|
||||
if (!textResult) return;
|
||||
const blob = new Blob([textResult], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tool.id}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (inputMode === "text" || isGenerator || !source || !started) return;
|
||||
void values;
|
||||
debouncedRun();
|
||||
return () => debouncedRun.cancel();
|
||||
});
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if schema}
|
||||
<div class="schema-tool">
|
||||
<header class="header">
|
||||
<div class="title-block">
|
||||
<span class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</span>
|
||||
<h1>{tool.title}</h1>
|
||||
<p class="lede">{tool.description}</p>
|
||||
</div>
|
||||
<span class="status"><i></i> LIVE PREVIEW</span>
|
||||
</header>
|
||||
|
||||
<div class="workspace">
|
||||
<section class="panel settings">
|
||||
<div class="panel-head">
|
||||
<span class="label">TOOL SETTINGS</span>
|
||||
<strong>Configure output</strong>
|
||||
</div>
|
||||
<SchemaFields {schema} {values} onchange={setValue} onreset={reset} />
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<SchemaPreview
|
||||
{source}
|
||||
{result}
|
||||
{textSource}
|
||||
{textResult}
|
||||
{inputMode}
|
||||
{resultKind}
|
||||
{running}
|
||||
{error}
|
||||
onupload={handleFile}
|
||||
ongenerate={runGenerate}
|
||||
ontextsource={(t) => {
|
||||
textSource = t;
|
||||
}}
|
||||
onrendertext={run}
|
||||
oncopytext={copyText}
|
||||
ondownloadtxt={downloadText}
|
||||
ondownload={download}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="no-schema">This tool has no schema yet.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.schema-tool {
|
||||
padding: calc(var(--space-xxxl) + var(--space-l))
|
||||
clamp(var(--space-m), 4vw, var(--space-xxxl));
|
||||
flex: 1;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-l);
|
||||
margin-bottom: var(--space-xxxl);
|
||||
}
|
||||
.title-block h1 {
|
||||
margin: var(--space-s) 0 0;
|
||||
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
|
||||
line-height: 1.1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.eyebrow,
|
||||
.label,
|
||||
.status {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-l);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.eyebrow span,
|
||||
.label {
|
||||
color: var(--color-main);
|
||||
}
|
||||
.lede {
|
||||
max-width: 100%;
|
||||
margin: var(--space-m) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-s);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status i {
|
||||
width: var(--size-border-thick);
|
||||
height: var(--size-border-thick);
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(var(--size-workspace-min), 1fr) minmax(
|
||||
0,
|
||||
2fr
|
||||
);
|
||||
gap: var(--space-xxl);
|
||||
align-items: start;
|
||||
}
|
||||
.panel {
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--color-panel);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-m);
|
||||
padding-bottom: var(--space-l);
|
||||
border-bottom: var(--size-border) solid var(--color-border);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
.panel-head strong {
|
||||
font-size: var(--font-size-l);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.no-schema {
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
@media (--bp-tablet) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import MonoLabel from "./MonoLabel.svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
title: string;
|
||||
meta?: string;
|
||||
actions?: Snippet;
|
||||
unwrapActions?: boolean;
|
||||
}
|
||||
|
||||
let { label, title, meta, actions, unwrapActions = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="section-label">
|
||||
<div class="section-text">
|
||||
<MonoLabel>{label}</MonoLabel>
|
||||
<strong
|
||||
>{title}{#if meta}<em>{meta}</em>{/if}</strong
|
||||
>
|
||||
</div>
|
||||
{#if actions}
|
||||
{#if unwrapActions}
|
||||
{@render actions()}
|
||||
{:else}
|
||||
<div class="section-actions">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.section-text strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font: 600 14px var(--font-mono);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.section-text em {
|
||||
color: #25a96a;
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
}
|
||||
.section-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -19,13 +19,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-m) var(--space-xl);
|
||||
border-bottom: var(--size-border) solid var(--color-border);
|
||||
}
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
gap: var(--space-m);
|
||||
padding: var(--space-l) var(--space-xl);
|
||||
border-top: var(--size-border) solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<style>
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: var(--space-s);
|
||||
height: var(--space-s);
|
||||
border-radius: 50%;
|
||||
background-color: var(--success2);
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
.status-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -45,45 +45,45 @@
|
||||
<style>
|
||||
.step-card {
|
||||
display: grid;
|
||||
grid-template-columns: 46px 20px 1fr;
|
||||
min-height: 138px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
grid-template-columns: var(--size-step-index) var(--size-step-grip) 1fr;
|
||||
min-height: var(--size-step-min-height);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
background: var(--color-panel);
|
||||
}
|
||||
.step-index {
|
||||
font: 11px var(--font-mono);
|
||||
color: var(--blue);
|
||||
padding: 17px 0 0px 15px;
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-main);
|
||||
padding: var(--space-xl) 0 0px var(--space-xl);
|
||||
}
|
||||
.step-body {
|
||||
padding: 15px 18px 18px;
|
||||
padding: var(--space-xl) var(--space-xl) var(--space-xl);
|
||||
}
|
||||
.step-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
border-color: var(--line);
|
||||
gap: var(--space-xl);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.step-type {
|
||||
color: var(--muted);
|
||||
font: 10px var(--font-mono);
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-text-muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-xl);
|
||||
}
|
||||
.step-title {
|
||||
margin: 5px 0 18px;
|
||||
font: 600 15px var(--font-mono);
|
||||
color: var(--foreground);
|
||||
margin: var(--space-s) 0 var(--space-xl);
|
||||
font: 600 var(--font-size-m) var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.step-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
gap: var(--space-l);
|
||||
}
|
||||
.step-remove {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
& :global(svg) {
|
||||
@@ -92,13 +92,13 @@
|
||||
}
|
||||
}
|
||||
.step-card :global(.drag) {
|
||||
margin-top: 17px;
|
||||
color: var(--line);
|
||||
margin-top: var(--space-xl);
|
||||
color: var(--color-border);
|
||||
}
|
||||
.step-remove:hover {
|
||||
color: var(--danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.step-body > :not(.step-heading) {
|
||||
padding: 0.75rem;
|
||||
padding: var(--space-l);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "./Toggle.svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let { label, checked = $bindable(), onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="toggle-row">
|
||||
<span>{label}</span>
|
||||
<Toggle bind:checked {onchange} {label} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toggle-row {
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -2,21 +2,19 @@
|
||||
import { resolve } from "$app/paths";
|
||||
import { ArrowUpRight } from "@lucide/svelte";
|
||||
import type { Component } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import Icon from "./ui/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
href: string;
|
||||
id: string;
|
||||
description?: string;
|
||||
index?: number;
|
||||
icon?: Component<{ size?: number; class?: string }>;
|
||||
}
|
||||
let { title, href, description, index, icon }: Props = $props();
|
||||
|
||||
let normalizedHref = $derived(href === "#" ? "/" : href);
|
||||
let { title, id, description, index, icon }: Props = $props();
|
||||
</script>
|
||||
|
||||
<a class="tool-card" href={resolve(normalizedHref)}>
|
||||
<a class="tool-card" href={resolve("/preview/tools/[id]", { id })}>
|
||||
{#if icon}<span class="tool-icon"><Icon {icon} size={19} /></span>{/if}
|
||||
<span class="tool-copy">
|
||||
<strong>{title}</strong>
|
||||
@@ -31,57 +29,48 @@
|
||||
<style>
|
||||
.tool-card {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) 24px 18px;
|
||||
grid-template-columns:
|
||||
var(--size-tool-icon) minmax(0, 1fr) var(--space-xxl)
|
||||
var(--size-tool-arrow);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 106px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
color: var(--foreground);
|
||||
gap: var(--space-l);
|
||||
min-height: var(--size-tool-min-height);
|
||||
padding: var(--space-xl);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--color-panel);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tool-card:hover {
|
||||
border-color: var(--blue);
|
||||
border-color: var(--color-main);
|
||||
}
|
||||
.tool-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: var(--size-tool-icon);
|
||||
height: var(--size-tool-icon);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--blue) 12%, transparent);
|
||||
color: var(--blue);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-main-soft);
|
||||
color: var(--color-main);
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
.tool-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--space-s);
|
||||
min-width: 0;
|
||||
}
|
||||
.tool-copy strong {
|
||||
font: 600 13px var(--font-mono);
|
||||
color: var(--foreground);
|
||||
font: 600 var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.tool-copy span {
|
||||
font: 12px/1.5 var(--font-mono);
|
||||
color: var(--muted);
|
||||
font: var(--font-size-s)/1.5 var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.tool-index {
|
||||
font: 10px var(--font-mono);
|
||||
color: var(--muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
align-self: start;
|
||||
}
|
||||
.tool-arrow {
|
||||
color: var(--blue);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
.tool-card:hover .tool-arrow {
|
||||
opacity: 1;
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user