Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d24ee0a448 | ||
|
|
44cfca92ce | ||
|
|
d0857c5d45 | ||
|
|
c2f3838485 | ||
|
|
49d0a9444e | ||
|
|
5167b0a9b1 | ||
|
|
b3e2735fc3 | ||
|
|
371387ce81 | ||
|
|
08c9fbce3b | ||
|
|
4de42b1e40 | ||
|
|
372babc3b7 | ||
|
|
4b44bc439c | ||
|
|
6c88a23e48 | ||
|
|
a4c85c0814 | ||
|
|
e06970997e | ||
|
|
1097186487 | ||
|
|
abfcbbfac2 | ||
|
|
9a27388119 | ||
|
|
3c10147ad8 | ||
|
|
a71487d30b | ||
|
|
71b5c90a4a | ||
|
|
ce33201302 | ||
|
|
dda5711a33 | ||
|
|
939038ffc0 | ||
|
|
de2a229545 | ||
|
|
9fdd960d81 | ||
|
|
7aefdc3848 | ||
|
|
5cd2a586ce | ||
|
|
ec585e1ee6 | ||
|
|
f2829de216 | ||
|
|
0c2de3f191 | ||
|
|
35df076732 | ||
|
|
05165031c3 | ||
|
|
0b107c7d93 | ||
|
|
24cd9f78c6 | ||
|
|
d14e14ac1a | ||
|
|
b91c1ab7ee | ||
|
|
2be8871930 | ||
|
|
adb578540f | ||
|
|
a7a42d0091 | ||
|
|
e1c3d0a95f | ||
|
|
ceee9adead | ||
|
|
d94c5d1297 | ||
|
|
ee5f660572 | ||
|
|
4826fc6ad8 | ||
|
|
1e47fdaf4a | ||
|
|
8451769efd | ||
|
|
cda88547c4 | ||
|
|
d8a612fbc4 | ||
|
|
fa5f7660c7 | ||
|
|
87bd4482a3 | ||
|
|
1651167a57 | ||
|
|
098897cddd |
@@ -0,0 +1,64 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ^11.20.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
run: pnpm build
|
||||
env:
|
||||
BASE_PATH: /easy-png-tools
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: web/build
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -5,173 +5,59 @@
|
||||
## Стек
|
||||
|
||||
- **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes).
|
||||
- Менеджер пакетов: **pnpm**. Запускать команды из `web/`, либо через
|
||||
обёртки в корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`).
|
||||
Не использовать npm.
|
||||
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`,
|
||||
тесты: `pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
|
||||
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт 4173).
|
||||
- Форматирование: два независимых прогона. Корневой prettier форматирует
|
||||
только markdown `docs/` (`.prettierrc` в корне, `pnpm format:docs`, проверка —
|
||||
`pnpm check:docs`). Корневой `.prettierignore` — только точечные исключения
|
||||
(node_modules, refs-html…), без `/*`-allowlist: он глушит формат-он-сейв
|
||||
для всего `web/` (файлы резолвятся как ignored и дочерние конфиги не
|
||||
применяются). Код `web/` форматируется
|
||||
отдельно: `pnpm --dir web format` (Prettier + `prettier-plugin-svelte`,
|
||||
конфиг `web/.prettierrc`, игнор `web/.prettierignore`). Проверка без записи:
|
||||
`pnpm --dir web exec prettier --check .`. Оба прогона сразу: `pnpm format`
|
||||
в корне.
|
||||
- Линтинг: `pnpm --dir web lint` (ESLint, flat-конфиг `web/eslint.config.js`).
|
||||
Устроен инкрементально:
|
||||
- На **весь код** — парсинг TS/Svelte + правило
|
||||
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов).
|
||||
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` + `eslint-plugin-svelte`)
|
||||
навешены **только на новый код** (`src/lib/components/kit/**`,
|
||||
`src/routes/preview/**`), чтобы старый код не засыпался предсуществующими
|
||||
ошибками. Когда старый дизайн удалён (C19), scoped-блок убирается и
|
||||
`recommended` включается на весь код.
|
||||
- **Scoped-пути двигаются вместе с папками** (см. `plan-redesign.md` §10):
|
||||
на C17 `src/routes/preview/**` → `src/routes/**` (preview переезжает на
|
||||
реальные маршруты), на C19 при переименовании `kit/`→`ui/` — и компонентный
|
||||
glob. Не оставлять устаревшие пути в `eslint.config.js`.
|
||||
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через `let`).
|
||||
- **Единый запуск всей дизайн-проверки: `pnpm --dir web lint:all`**
|
||||
(`web/scripts/lint-all.mjs`) — гоняет по очереди: ESLint (все правила,
|
||||
включая design-tokens), stylelint (`lint:css`) и токен-аудит
|
||||
(`lint:tokens`). Каждый шаг выполняется, даже если предыдущий упал; выход
|
||||
ненулевой, если хоть один не прошёл. Текущие «ожидаемые долги» (hardcoded
|
||||
размеры в kit, не-Prefix токены в preview.css и т.п.) — техдолг: чинить
|
||||
- Менеджер пакетов: **pnpm**. Запускать команды из `web/`, либо через обёртки в
|
||||
корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать
|
||||
npm.
|
||||
- Сборка: `pnpm --dir web build`, проверка типов:
|
||||
`pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, тесты:
|
||||
`pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
|
||||
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт
|
||||
4173).
|
||||
- Форматирование: `pnpm format` в корне (оба прогона) или
|
||||
`pnpm --dir web format` (только код web/). Проверка: `pnpm check:docs`
|
||||
(docs) + `pnpm --dir web exec prettier --check .` (код).
|
||||
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
|
||||
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
|
||||
выполняется, даже если предыдущий упал. Текущие «ожидаемые долги» (hardcoded
|
||||
размеры в kit до C19, не-Prefix токены в `app_v1.css`) — техдолг: чинить
|
||||
только по заведённым tasks, не игнорировать правилом.
|
||||
|
||||
## Правила кода
|
||||
|
||||
### Svelte 5: типизация props через `interface Props`
|
||||
Синтаксические ограничения описывать не нужно — они проверяются линтером
|
||||
(весь список правил и токены-префиксы в `web/eslint-plugins/README.md`,
|
||||
прогон: `pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то,
|
||||
что линтер не умеет.
|
||||
|
||||
Все типизированные пропсы компонентов описываются через локальный
|
||||
`interface Props`, а деструктуризация идёт через аннотацию типа при `$props()`:
|
||||
### Svelte 5
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
Типизация пропсов через локальный `interface Props` закреплена правилом
|
||||
`conventions/interface-props`; руками его нигде дублировать не надо.
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
accent?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
### Сначала искать
|
||||
|
||||
let { label, accent = false, children }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
Новая константа или тип не заводится, пока не произведён поиск существующего
|
||||
определения (по ключу в соседних модулях и по всему `web/src/`). Это правило
|
||||
в первую очередь для ИИ-агентов: дублирующее определение ухудшает правки в
|
||||
нескольких местах и запутывает. (Синтаксис самих определений — под линтером:
|
||||
строка `conventions/no-string-union-alias` и т.д.)
|
||||
|
||||
Не использовать инлайн-дженерик `$props<{ ... }>()` — он тяжело читается и
|
||||
разносит тип и деструктуризацию по разным местам. Также **не использовать
|
||||
инлайн-импорты в типах** (`children?: import('svelte').Snippet;`) — все
|
||||
`import type` поднимаются наверх файла. Это правило **включено в ESLint**
|
||||
(`@typescript-eslint/consistent-type-imports`, `prefer: 'type-imports'`).
|
||||
### Дизайн
|
||||
|
||||
> Правило «всегда `interface Props` + `let {...}: Props = $props()`» стандартным
|
||||
> ESLint-правилом не покрывается — остаётся конвенцией. Кастомное правило
|
||||
> (eslint-plugin-svelte / свой visitor) — TODO, позже.
|
||||
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
||||
которые линтер не проверяет:
|
||||
|
||||
### Дизайн: новый визуальный язык
|
||||
|
||||
Переезд на новый дизайн описан в `docs/plan-redesign.md`. Ключевые
|
||||
ограничения при работе с веткой `feat/redesign`:
|
||||
|
||||
- Глобальные стили старого и нового дизайна разнесены по layout без
|
||||
наследования: старое в `web/src/app.css` (группа `(old)/`), новое в
|
||||
`web/src/lib/styles/design2.css` (ветка `preview/*`). Префикс `.ds2` не
|
||||
используется.
|
||||
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
|
||||
`web/src/app_v1.css` (маршруты `/v1/*`).
|
||||
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
||||
`web/src/lib/components/kit/`, даже «просто div с двумя стилями».
|
||||
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
||||
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
||||
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
|
||||
|
||||
### Линтинг дизайн-токенов («единая точка правды» дизайна)
|
||||
### Изоляция веток old ↔ new
|
||||
|
||||
Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и z-index
|
||||
обязаны приходить из CSS-переменных. Стек: **stylelint** прогоняется по всем
|
||||
CSS-файлам (`web/stylelint.config.js`), **ESLint** лезет в `<style>`-блоки
|
||||
svelte-компонентов через локальный плагин `web/eslint-plugins/design-tokens/`
|
||||
(постпрефес postcss AST от svelte-eslint-parser). Правила применяются к тем же
|
||||
scoped-путям (`kit/**`, `preview/**`).
|
||||
Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
|
||||
каждый импорт до файла и запрещает смешивание.
|
||||
|
||||
Команды:
|
||||
|
||||
- `pnpm --dir web lint:css` — stylelint по всем `src/**/*.css` (`old.css` и
|
||||
`node_modules/build/.svelte-kit/static` игнорируются).
|
||||
- `pnpm --dir web exec node scripts/check-tokens.mjs` — токен-аудит по
|
||||
preview.css (лёгкий оркестратор поверх `web/scripts/token-audit/*`);
|
||||
проверяет три вещи. Каждый цветовой
|
||||
токен из `:root` обязан иметь пару в `[data-theme="dark"]` и наоборот.
|
||||
Производные токены (значение содержит `var()`, напр. `hct(from var(--...))`)
|
||||
из пары исключены — они наследуют тему автоматически. Выводит **варнинг** о
|
||||
неиспользуемых токенах preview.css (определены, но нигде не используются) —
|
||||
выход он не меняет. Провалом (exit 1) считаются только parity и hct-авторство.
|
||||
- Тот же скрипт проверяет **hct-only для цветов**: ЛЮБОЕ цветовое значение в
|
||||
preview.css обязано быть `hct(...)` — и литерал, и производное
|
||||
`hct(from var(...) h c t)` (вся палитра считается через hct-каналы; эммит в
|
||||
sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`). Исключения:
|
||||
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
|
||||
`color-mix(...)` (единственный легальный способ смешать два токена).
|
||||
`oklch()/rgb()/#hex` в `--color-*` запрещены.
|
||||
|
||||
Токены-префиксы (целевой словарь дизайна):
|
||||
|
||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные
|
||||
две переменные, которым разрешено быть hex/rgb, остальные цвета — только
|
||||
`hct()` (эммит в sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`)
|
||||
и только в preview.css.
|
||||
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
|
||||
- Брейкпоинты: `@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` — целиком с `--`.
|
||||
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
|
||||
`ignore`, не в `except`): пустые строки между подряд идущими токенами
|
||||
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
|
||||
токены в preview.css отдельными блоками.
|
||||
|
||||
Правила плагина (компоненты, `eslint.config.js`):
|
||||
|
||||
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
|
||||
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
|
||||
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
|
||||
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
|
||||
color-токен (`--color-*`/`--brand-*`) и наоборот.
|
||||
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
|
||||
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
|
||||
производные от токенов (`var()`, `calc()`, unitless-числа).
|
||||
- `design-tokens/no-undefined-in-svelte` — `var(--x)` в `<style>` должен быть
|
||||
определён в `src/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/old/**`.
|
||||
- Новое: `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) — известный техдолг, его НЕ чинить и не игнорировать правилами.
|
||||
|
||||
- Коммиты мелкие (< ~500 строк), атомарные
|
||||
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
|
||||
Изменения делать небольшими (< ~500 строк), атомарными.
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# План C17: перенос preview → корень + (old) → /v1/
|
||||
|
||||
> Статус: план к выполнению. Предпосылки: tech debt закрыт, backlog №14 (старые
|
||||
> файлы → lib/old/) выполнен. Домен НЕ блокер (см. plan-redesign.md §10,
|
||||
> plan-seo.md §6).
|
||||
> **СТАТУС: ВЫПОЛНЕН (2026-09-11) — устарел.** Перенос завершён:
|
||||
> preview-страницы на реальных маршрутах, старый дизайн на `/v1/*`,
|
||||
> `app.css`/`app_v1.css`, isolation-плагин на v1-путях, аудит-скрипты в
|
||||
> `scripts/archive/`. C18 (правки импортов/редиректов, проверка билда) тоже
|
||||
> выполнен. Следующий этап — C19–C21 (`plan-redesign.md`, §10).
|
||||
>
|
||||
> Оригинальный статус до выполнения: план к выполнению. Предпосылки: tech debt
|
||||
> закрыт, backlog №14 (старые файлы → lib/old/) выполнен. Домен НЕ блокер (см.
|
||||
> plan-redesign.md §10, plan-seo.md §6).
|
||||
|
||||
## Цель
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# План: инструмент «Разрезать PNG на части» (split-into-parts-png)
|
||||
|
||||
> Статус: **выполнено и в архиве** (2026-09). Инструмент и механизм «результат =
|
||||
> набор файлов» (1 → many) реализованы; первый потребитель —
|
||||
> `split-into-parts-png`. Общий процесс таких инструментов — задача 18 в
|
||||
> `docs/backlog.md`.
|
||||
|
||||
## Цель
|
||||
|
||||
Пользователь загружает одну картинку, выбирает число столбцов и строк, картинка
|
||||
разрезается на равномерные части. Результат — набор PNG-файлов, скачивается
|
||||
ZIP-архивом.
|
||||
|
||||
## Решения
|
||||
|
||||
- **Неравномерное деление:** канвас дополняется прозрачным до кратного размера
|
||||
(`pieceW = ceil(w / cols)`, `newW = pieceW * cols`), все части строго равные,
|
||||
картинка покрывается целиком.
|
||||
- **Дефолт схемы:** 2 × 2.
|
||||
- **Zip-библиотека:** `fflate` (zero deps, малый размер), скачивание из UI-слоя
|
||||
(`encode()` требует DOM).
|
||||
- **Результат инструмента:** `result: "files"` — терминальный тип, в пайплайн
|
||||
(следующий шаг) не передаётся.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. **Зависимость:** `fflate` в `web/package.json`.
|
||||
2. **`registry/types.ts`:** `RESULT_KINDS.files`, тип `ToolImageFile`,
|
||||
`FileResult`, расширение `ToolResult = PixelImage | string | FileResult`.
|
||||
3. **`core/geometry.ts`:** `splitToParts(img, columns, rows)` — padding + сетка
|
||||
через `crop`.
|
||||
4. **`web/src/lib/zip.ts` (новый):** `downloadZip(files, zipName)` через
|
||||
`zipSync`.
|
||||
5. **`core/errors.ts`:** ключ `errors.tooManyParts` (страховочный лимит
|
||||
cols·rows ≤ 1000; при максимуме 6×6 недостижим).
|
||||
6. **`registry/geometry.ts`:** схема (`columns`, `rows`, дефолт 2×2, min 1, max
|
||||
6 — максимум 36 частей) + entry `split-into-parts-png` в `geometryEntries`.
|
||||
7. **Executor:** сериализация/десериализация `FileResult` в `executor.worker.ts`
|
||||
и тип ветки в `executor.ts`.
|
||||
8. **UI:** `SchemaToolView.svelte` (`fileResult`, Download → zip),
|
||||
`SchemaPreview.svelte` (canDownload для files), `SchemaResultTile.svelte`
|
||||
(сетка-превью частей, мета «N parts / ZIP»).
|
||||
9. **i18n:** переводы в `ru.ts`/`en.ts` **не добавляем** — словари `tools`
|
||||
привязаны к v1-реестру (тест «нет лишних ключей»), а preview берёт заголовки
|
||||
из registry (EN). Перевод придёт вместе с беклог-задачей «i18n в preview».
|
||||
Поиск по id/заголовку работает из registry.
|
||||
10. **Тесты:** юнит `splitToParts`, registry (result: "files"), i18n coverage.
|
||||
11. **Docs:** `docs/backlog.md` (задача «процесс 1 → many и many → 1», отметить
|
||||
пункт 11 «Мультифайловый вывод»), `docs/tools-map.md` (перенос из идей).
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Изменение |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `web/package.json` | `+ fflate` (dependencies) |
|
||||
| `web/src/lib/registry/types.ts` | `RESULT_KINDS.files`, `ToolImageFile`, `FileResult`, `ToolResult` |
|
||||
| `web/src/lib/core/geometry.ts` | `splitToParts()` |
|
||||
| `web/src/lib/zip.ts` | **новый** — `downloadZip()` |
|
||||
| `web/src/lib/core/errors.ts` | `errors.tooManyParts` |
|
||||
| `web/src/lib/registry/geometry.ts` | schema + `splitPartsTool` |
|
||||
| `web/src/lib/executor/executor.worker.ts` | ветка `FileResult` |
|
||||
| `web/src/lib/executor/executor.ts` | тип ветки `FileResult` |
|
||||
| `web/src/lib/components/SchemaToolView.svelte` | `fileResult`, download → zip |
|
||||
| `web/src/lib/components/SchemaPreview.svelte` | props `fileResult`, `canDownload` |
|
||||
| `web/src/lib/components/SchemaResultTile.svelte` | сетка частей, «N parts» |
|
||||
| `web/src/lib/i18n/ru.ts`, `en.ts` | записи инструмента |
|
||||
|
||||
## Нейминг файлов в ZIP
|
||||
|
||||
`part-<row>-<col>.png` (row, col нумеруются с 1, при 2×2 — `part-1-1.png`).
|
||||
|
||||
## Верификация
|
||||
|
||||
```bash
|
||||
pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json
|
||||
pnpm --dir web test
|
||||
pnpm --dir web lint
|
||||
```
|
||||
|
||||
Ручная проверка: PNG не кратного размера (например 101×77), 3×2 → 6 частей
|
||||
одинакового размера, скачивается ZIP; на кратном размере (100×80, 3×2) padding
|
||||
= 0.
|
||||
@@ -64,9 +64,9 @@
|
||||
|
||||
**Вариант Б (страховка): руками, пакетами по компонентам.** Если скрипт не
|
||||
удался — чистим/заводим токены вручную, пакетами по компонентам (1–2 на задачу,
|
||||
дифф < ~500 строк). Отсутствующие токены (например `--space-2xl`/`--space-panel`,
|
||||
уже дважды обходились в SchemaTextSource) добавляем в словарь `preview.css`
|
||||
осознанно.
|
||||
дифф < ~500 строк). Отсутствующие токены (например
|
||||
`--space-2xl`/`--space-panel`, уже дважды обходились в SchemaTextSource)
|
||||
добавляем в словарь `preview.css` осознанно.
|
||||
|
||||
Гейт этапа: `pnpm --dir web lint:all` — только допустимый остаток
|
||||
(неиспользуемые токены — варнинг), плюс регресс-прогон тестов и svelte-check.
|
||||
|
||||
@@ -2,16 +2,33 @@
|
||||
|
||||
## lint и прочие правила
|
||||
|
||||
- [ ] линт правило для `interface Props {...}` + `let {}: Props = $props()`
|
||||
- [ ] линт правило для `object as const`, нежелательно использовать
|
||||
`type Kind = 'a' | 'b' | 'c'` - это приводит к дублированию описаний в
|
||||
разных местах, и к непонятным новым типам типа `type Kind2 = 'a' | 'c'`
|
||||
- [ ] можно ли сделать правило для минимизации новых `const` определений? чтобы
|
||||
дубли автоматически определялись? ИИ агент иногда дублирует определения
|
||||
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
||||
`docs/plan-preview-i18n.md`
|
||||
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
||||
что за `SonarLint` или написать кастомный линтер)
|
||||
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
||||
модуль
|
||||
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
||||
оно должно быть у всех или объяснено, почему этот инструмент такой
|
||||
особенный и заслуживает комментарий
|
||||
|
||||
## Мелочи всякие
|
||||
|
||||
- [ ] сгенерировать или найти файлы для ручной проверки `A. Краевые PNG-файлы`
|
||||
- [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют
|
||||
количество инструментов, точное совпадение текстов, поиск по
|
||||
`img[alt="Result image"]`, svelte классам и тому подобные
|
||||
- [ ] можно ли в тестах получать i18n ключ вместо текста?
|
||||
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
|
||||
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
||||
должны быть настраиваемой 0-100%, а не переключателем
|
||||
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
||||
единую структуру, позволит упростить некоторые вещи - например
|
||||
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
|
||||
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
||||
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
||||
`ondownloadtxt`, `ondownload`,
|
||||
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
||||
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
||||
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть
|
||||
@@ -24,14 +41,7 @@
|
||||
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
||||
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
||||
паспортом, семейства с кириллицей)
|
||||
- [ ] Посмотреть темную тему - слишком темная???
|
||||
- [ ] на картинке результате при работе дергается высота надписи (высота иконки)
|
||||
- [ ] Глянуть что за ошибка (воспроизвелось на 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), добавить прозрачность
|
||||
@@ -48,18 +58,21 @@
|
||||
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
|
||||
удаление, добавление инструментов. переход один инструмент - pipeline без
|
||||
перезагрузки
|
||||
- [ ] Слаги инструментов (`*-png`) — надо обдумать, возможно что ссылки\маршруты
|
||||
должны быть с png, а названия инструментов - без. Это упростит названия
|
||||
инструментов и сделает их более понятными
|
||||
- [ ] Слаги инструментов (`*-png`) — надо переименовть, они работают с любыми
|
||||
изображениями. Надо сделать отдельный интерфейс маппинга инструмент ->
|
||||
ссылка
|
||||
|
||||
## Переезд
|
||||
|
||||
- [x] составить план переноса preview -> корень, старый корень -> old
|
||||
- [ ] добавить i18n в preview
|
||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||
`tPlural()` и формат `PluralForms`, детали в §Фаза 9
|
||||
`docs/plan-preview-i18n.md`
|
||||
|
||||
## SEO/GEO
|
||||
|
||||
Общий план: `docs/plan-seo.md` (S0–S4). Старт — после C17.
|
||||
Общий план: `docs/plan-seo.md` (S0–S4). C17 выполнен; старт возможен — пока
|
||||
только domain-free часть S1.
|
||||
|
||||
- [ ] переименование бренда + домен: чек-лист в локальном плане
|
||||
`docs/plan-domain.local.md` (вне git, имя домена в репо не публикуется;
|
||||
@@ -81,7 +94,8 @@
|
||||
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
|
||||
(фазы 3–8) — здесь только новые треки.
|
||||
|
||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C19 и S1)
|
||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1;
|
||||
C19 отложен — не блокер)
|
||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||
(после wasm-фаз 5–7)
|
||||
|
||||
@@ -121,35 +135,27 @@
|
||||
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
|
||||
file).
|
||||
|
||||
2. **UI-эксперимент: параметры между исходником и результатом** — оценка S/M.
|
||||
Раскладка «Исходник → Параметры → Результат» в один ряд вместо параметров
|
||||
отдельным блоком снизу. Проверить на узких экранах; возможно за флагом/A-B,
|
||||
чтобы сравнить с текущей. Перекрыто редизайном: старый план
|
||||
(`docs/archive/plan-inline-params.md`) закрыт, страницы инструментов теперь
|
||||
`SchemaToolView`/`SchemaPreview`. Эксперимент переосмыслить уже для нового
|
||||
этапа инструментов (сетка source|params|result), если понадобится.
|
||||
|
||||
3. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
|
||||
2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
|
||||
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
|
||||
помнить в workspace-pipeline.
|
||||
|
||||
4. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
|
||||
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
|
||||
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
|
||||
Формат экспортного JSON расширить опциональным полем имени, старые файлы
|
||||
читаются как безымянные.
|
||||
|
||||
5. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
|
||||
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
|
||||
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
|
||||
chain по каждому файлу, сборка результата обратно в zip для скачивания.
|
||||
Зависит от п.4 (выбор цепочки) и от экспортного формата pipeline JSON;
|
||||
прогресс и ошибки — пофайлово.
|
||||
|
||||
6. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
|
||||
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
|
||||
карточках каталога, результатах поиска и в шапке страницы инструмента; список
|
||||
id в localStorage. Избранное показывается отдельной секцией сверху каталога и
|
||||
поднимается в выдаче поиска (бонус к popularity при скоринге).
|
||||
|
||||
7. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
|
||||
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
|
||||
— оценка M/L. Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др.
|
||||
форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого
|
||||
— бесшовное внедрение выбора формата/качества прямо в кнопку/диалог
|
||||
@@ -165,40 +171,37 @@
|
||||
(целевой KB, бинарный поиск по k из квантования). Проработать вместе с
|
||||
достройкой download-флоу нового UI.
|
||||
|
||||
8. **`watermark-image-png` (водяной знак картинкой)** — оценка M. Отложен из
|
||||
7. **`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. Отложен из
|
||||
8. **`png-info` (детальная информация о PNG)** — оценка M/L. Отложен из
|
||||
миграции. Отдельный райз: exif-теги, редактирование, структурированный вывод;
|
||||
нужен свой отдельный случай в UI, не «ещё один text/verdict».
|
||||
нужен свой отдельный случай в UI, не «ещё один text/verdict». возможно нужна
|
||||
вообще отдельная страница, несовместимая с другими инструментами: 1) она
|
||||
работает не с изображением, а с мета данными из файла, 2) она не может быть
|
||||
частью цепочки, т.к. результат не является изображением, 3) если надо
|
||||
изменить теги, никакого форматирования и изменения в изображение вносить не
|
||||
надо, только в мета информацияю
|
||||
|
||||
10. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
|
||||
9. **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`.
|
||||
10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
|
||||
— механизм «результат = набор файлов» (1 → many) реализован: `FileResult` +
|
||||
`result: "files"` + скачивание zip-архивом + грид-превью частей. Первый
|
||||
потребитель — `split-into-parts-png`. Остальные подключаются по мере нужды.
|
||||
|
||||
12. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
|
||||
11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
|
||||
решение о формате. Из `archive/plan-gap-waves.md`.
|
||||
|
||||
13. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
|
||||
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
|
||||
`archive/plan-gap-waves.md`.
|
||||
|
||||
14. ~~**Переезд старых файлов в папки `old/`**~~ — **ЗАКРЫТО.** Старые файлы
|
||||
(`lib/registry.ts`, `lib/registry/`, `lib/registry-helpers.ts`,
|
||||
`lib/categories.ts`, `lib/tools/`, `lib/components/` кроме `kit/`)
|
||||
перенесены в `lib/old/`. Isolation-плагин упрощён до `lib/old/**`. Все
|
||||
импорты обновлены. svelte-check 0, тесты 618/618, lint:all чист, билд
|
||||
проходит.
|
||||
|
||||
15. ~~**Техдолг (чинить по заведённым задачам, не игнорировать правила)**~~
|
||||
ЗАКРЫТО. Подробности: `docs/archive/plan-tech-debt.md`.
|
||||
|
||||
16. **Контраст текста на акцентных кнопках — пересмотреть после готовности
|
||||
13. **Контраст текста на акцентных кнопках — пересмотреть после готовности
|
||||
сайта**. Текст `--color-background` на фоне `--color-main`/`--color-accent`
|
||||
(тон 48) даёт контраст ~4.1–4.3 (light) — ниже AA 4.5 для обычного текста;
|
||||
`palette.test.ts` держит для кнопок порог 4.0 (AA large-text) осознанно, как
|
||||
@@ -207,7 +210,7 @@
|
||||
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
|
||||
light-danger тон 48 = 4.25 на границе.
|
||||
|
||||
17. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
|
||||
14. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
|
||||
реализовать процесс показа маски результата. **Контекст:** в старом
|
||||
контракте поле `preview` у `remove-color`/`remove-background` показывало
|
||||
быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что
|
||||
@@ -221,3 +224,16 @@
|
||||
отображения (показывает маску или сам результат), и для передачи в следующий
|
||||
инструмент (всегда результат. Оценка — на усмотрение при проработке (S/M).
|
||||
Пока живём без неё.
|
||||
|
||||
15. **Процесс работы с инструментами 1 → many и many → 1** — абстракция
|
||||
результата «набор файлов» оформлена и работает: тип `FileResult`,
|
||||
`result: "files"` в реестре, сериализация через executor, скачивание
|
||||
zip-архивом, грид-превью частей. Реализовано на первом потребителе
|
||||
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
|
||||
**1 → many:** подключение остальных потребителей (gif-to-frames,
|
||||
separate-colors) при необходимости; формат/качество файлов внутри zip —
|
||||
отложено до UX-райза «Download» (п.7). **many → 1** (вход = архив/несколько
|
||||
картинок → единый результат): не реализовано. Зависит от batch-обработки
|
||||
архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна отдельная
|
||||
проработка входа (распаковка zip в браузере, порядок файлов, поведение в
|
||||
пайплайне — такие инструменты терминальны, как и 1 → many).
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
|
||||
## A. Краевые PNG-файлы
|
||||
|
||||
> Фикстуры для этого раздела генерируются скриптом: `pnpm generate:fixtures` →
|
||||
> `tests/fixtures/manual-edge-cases/`.
|
||||
|
||||
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
|
||||
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
|
||||
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
|
||||
@@ -23,6 +26,31 @@
|
||||
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
|
||||
размер, прозрачность).
|
||||
|
||||
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
||||
|
||||
| Файл | Описание |
|
||||
| ------------------------ | ------------------------------------------------------------ |
|
||||
| `1x1.png` | Минимальное изображение 1×1, сплошной красный |
|
||||
| `1x1-transparent.png` | 1×1, полностью прозрачный (alpha=0) |
|
||||
| `2x2-extreme.png` | 2×2: чёрный, белый, полупрозрачный красный, прозрачный синий |
|
||||
| `1024x1024-checker.png` | 1024×1024, шахматка ≈1 мегапиксель |
|
||||
| `1920x1080-gradient.png` | 1920×1080, градиент ≈2 мегапикселя |
|
||||
| `alpha-checkerboard.png` | 64×64, чёрно-белая шахматка с чередующейся альфой |
|
||||
| `alpha-gradient.png` | 128×64, альфа-градиент 0→255 по ширине |
|
||||
| `sparse-alpha.png` | 100×100, 10% пикселей с alpha=0 |
|
||||
| `all-black-opaque.png` | 64×64, сплошной чёрный |
|
||||
| `all-white-opaque.png` | 64×64, сплошной белый |
|
||||
| `all-transparent.png` | 64×64, полностью прозрачное |
|
||||
| `palette-4colors.png` | 32×32, палитровый 4 цвета (RGBW) |
|
||||
| `palette-256.png` | 64×64, палитровый полная палитра 256 цветов |
|
||||
| `16bit-subtle.png` | 32×32, 16-bit RGBA, тонкий градиент |
|
||||
| `noise-pattern.png` | 100×100, шумовая текстура |
|
||||
| `1x4000-strip.png` | 1×4000, длинная вертикальная полоса |
|
||||
| `4000x1-strip.png` | 4000×1, широкая горизонтальная полоса |
|
||||
| `corrupt-bad-crc.png` | 4×4, повреждённый CRC в IDAT |
|
||||
| `truncated.png` | Обрезанный файл (только PNG-заголовок + часть IHDR) |
|
||||
| `fake-png.txt` | Текстовый файл «.png» — не-PNG |
|
||||
|
||||
## B. Края параметров
|
||||
|
||||
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate — не
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
||||
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
||||
|
||||
## 2. Фаза 10 — PWA (после C17/C19 и S1)
|
||||
## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер)
|
||||
|
||||
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
||||
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
||||
@@ -64,8 +64,8 @@
|
||||
## 3. Порядок и связи
|
||||
|
||||
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
|
||||
- PWA — после C17/C19 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`
|
||||
(не кешировать noindex-версии); API — после фаз 5–7.
|
||||
- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19
|
||||
отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7.
|
||||
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
||||
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
||||
GEO-текстов (`llms.txt`, описания категорий).
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# План: i18n в preview (новый UI)
|
||||
|
||||
> Статус: готов к реализации (вопросы §5 решены). Соответствует беклог-задаче
|
||||
> «добавить i18n в preview» (`docs/backlog.md`). Реализация не начата.
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
|
||||
`v1`) переходит на существующий модуль `lib/i18n`: переключатель RU/EN в шапке
|
||||
работает по-настоящему, все строки локализованы, ошибки уже локализуются.
|
||||
Инфраструктура i18n уже есть и проверена (v1-ветка) — задача в том, чтобы
|
||||
подключить к ней preview и закрыть пробелы словарей.
|
||||
|
||||
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
|
||||
копия `lib/i18n` → `lib/v1/i18n`, у нового UI — своя независимая ветка
|
||||
`lib/i18n`, которую дальше меняем без оглядки на v1 (Фаза 0). Переключённая
|
||||
локаль (localStorage) остаётся общей для обеих версий — язык переживает переход
|
||||
между страницами.
|
||||
|
||||
## 2. Текущее состояние (исследование)
|
||||
|
||||
### Уже есть
|
||||
|
||||
- `lib/i18n/locale.svelte.ts` — состояние локали (runes), персист в
|
||||
localStorage, обновление `document.documentElement.lang`.
|
||||
- `lib/i18n/t.ts` — перевод по точечному ключу с фолбэком на базовую локаль и
|
||||
интерполяцией `{var}`.
|
||||
- `lib/i18n/dict.ts` — тип `Dict`, `BASE_LOCALE = "en"` (база — английский, ru —
|
||||
перевод), `LOCALES = ["ru", "en"]`.
|
||||
- `lib/i18n/ru.ts` (~1351 строка) и `en.ts` (188 строк) — полные переводы
|
||||
инструментов v1 по id (title/description/params/options/results).
|
||||
- `lib/i18n/tool-strings.ts` — хелперы для v1
|
||||
(`toolTitle/toolDescription/ paramLabel/optionLabel/searchDoc`).
|
||||
- `lib/i18n/matching.ts` — кросс-языковой поиск (ё→е, диакритика, скоринг).
|
||||
- Тесты: `i18n.test.ts`, `tools-i18n.test.ts`, `matching.test.ts`,
|
||||
`smoke-i18n.test.ts`, `search-coverage.test.ts`.
|
||||
- `+layout.svelte` уже вызывает `initLocale()` на клиенте.
|
||||
|
||||
### Пробелы в новом UI
|
||||
|
||||
- `LangToggle.svelte` — заглушка с локальным `let lang = $state("RU")`, не
|
||||
связана с `locale.svelte.ts`.
|
||||
- Новый UI почти целиком на хардкод-английском:
|
||||
- ~150+ строк по ~30 компонентам и маршрутам (заголовки, кнопки, аria,
|
||||
плейсхолдеры, подписи секций);
|
||||
- 122 инструмента с английскими `title`/`description` в `lib/registry/*.ts`;
|
||||
- ~60+ подписей опций select;
|
||||
- ~30 заголовков layout-групп (`title: "Canvas"`, `"Background"`, …);
|
||||
- вердикты анализаторов — raw English строки (`"Yes — valid PNG signature."`),
|
||||
при том что v1 возвращает ключи словаря (`"verifyYes"`) и рендерит их через
|
||||
`tools[id].results`;
|
||||
- `labelOf(id)` в `SchemaFields.svelte` генерирует подписи полей из field ID
|
||||
(keepAspect → "Keep Aspect");
|
||||
- категории в `catalog.ts` (`GROUP_LABELS`) — English uppercase, хотя в `dict`
|
||||
уже есть ключи `categories.X`.
|
||||
- Из всех новых компонентов `t()` вызывает только `SchemaToolView.svelte`, и
|
||||
только для ошибок (`ToolError.key`).
|
||||
|
||||
### Совпадение регистров
|
||||
|
||||
- Новый registry и старый v1 registry используют **одинаковые id** инструментов
|
||||
(resize-png, flip-png, …) — переводы title/description из v1 `ru.ts` служат
|
||||
исходником для новой ветки `lib/i18n/ru.ts` (копирование данных при наполнении
|
||||
Фазы 2, не общий модуль).
|
||||
- **Расхождение**: param ids в ru.ts (`width`/`height`/`keepAspect`) не равны
|
||||
field ids новоно schema (`size` dimension, `anchor`). Нужны новые переводы
|
||||
полей.
|
||||
- `ru.ts` tools[id].results для анализаторов уже содержит ключи (`verifyYes`,
|
||||
`grayscaleNo`, `orientationPortrait` и т.д.).
|
||||
|
||||
### Архитектурные ограничения
|
||||
|
||||
- Изоляция old↔new (`isolation/no-mixed-imports`): сейчас `lib/i18n/` — shared.
|
||||
По решению задачи (Фаза 0) i18n **форкается**: v1 получает полную копию
|
||||
`lib/v1/i18n`, а `lib/i18n` переходит в ветку «new» (правило изоляции запретит
|
||||
старому коду импортить новый i18n). До форка в общем модуле два скрытых
|
||||
v1-импорта: `dict.ts` тянет `CategoryId` из `../v1/categories`,
|
||||
`tool-strings.ts` — `$lib/v1/registry`; после форка оба живут только в копии.
|
||||
Новому UI нужен параллельный `schema-tool-strings.ts` поверх `$lib/registry`.
|
||||
- Tone detection в `SchemaTextResult.svelte`: `/^(yes|true)/i` по value —
|
||||
сломается при русских вердиктах («Да», «Нет»).
|
||||
- `tools-i18n.test.ts` покрывает только v1 TOOLS; нужен аналог для нового
|
||||
registry.
|
||||
- `known-issues.spec.ts`:
|
||||
`test.fixme("error message is localized, not a raw i18n key")`.
|
||||
|
||||
## 3. Как делаем
|
||||
|
||||
Принципы:
|
||||
|
||||
- Мелкие атомарные коммиты (правило AGENTS.md); самому не коммитить — коммитит
|
||||
разработчик после ревью.
|
||||
- Изменения «снизу вверх»: сначала общий слой i18n, потом словари, потом UI.
|
||||
- Каждый этап заканчивается зелёными `pnpm --dir web lint` +
|
||||
`pnpm --dir web check` + тесты.
|
||||
- Решения по §5 зафиксированы и учитываются с первых правок словарей.
|
||||
|
||||
## 4. Фазы
|
||||
|
||||
### Фаза 0: Форк i18n для v1 + подготовка общего слоя
|
||||
|
||||
Выполняется строго до остальных фаз, без изменения поведения UI. Цель — два
|
||||
независимых i18n-слоя: v1 получает замороженную копию, новый UI — свою ветку,
|
||||
которую дальше меняем свободно. На выходе фазы — зелёные `lint`, `check`,
|
||||
`test`, `test:rules`.
|
||||
|
||||
- **0.1** Форк: скопировать `web/src/lib/i18n/` → `web/src/lib/v1/i18n/` (полный
|
||||
снимок: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
|
||||
`matching.ts`, `tool-strings.ts` + все 5 тестов). v1 работает дальше только с
|
||||
копией — поздние правки нового i18n её не затрагивают.
|
||||
- **0.2** Привести копию к v1-канону (относительные пути от нового места):
|
||||
- копия `dict.ts`: импорт `CategoryId` `../v1/categories` → `../categories` (=
|
||||
`lib/v1/categories`);
|
||||
- копии тестов (`tools-i18n.test.ts`, `search-coverage.test.ts`,
|
||||
`smoke-i18n.test.ts`): `../v1/registry` → `../registry` (=
|
||||
`lib/v1/registry`);
|
||||
- импорт `$lib/v1/registry` в копии `tool-strings.ts` остаётся валидным;
|
||||
- перепроверить остальные относительные и `$lib`-пути в копии.
|
||||
- **0.3** Переключить импорты старого UI: все `$lib/i18n/*` в `routes/v1/**` и
|
||||
`lib/v1/components/**` (39 импортов в 23 файлах) → `$lib/v1/i18n/*`.
|
||||
- **0.4** Очистить новый `lib/i18n/` от v1-специфики, иначе isolation поймает
|
||||
new→old:
|
||||
- удалить `tool-strings.ts` (завязан на v1-типы `ParamDef`/`ToolEntry`; для
|
||||
нового UI вместо него `schema-tool-strings.ts`, см. 0.8);
|
||||
- удалить v1-центричные тесты `tools-i18n.test.ts`, `search-coverage.test.ts`,
|
||||
`smoke-i18n.test.ts` (они импортируют `../v1/registry`; живут только в
|
||||
копии);
|
||||
- остаются: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
|
||||
`matching.ts`, `i18n.test.ts`, `matching.test.ts`.
|
||||
- **0.5** Обновить правило изоляции и конфиги:
|
||||
- `isolation/no-mixed-imports.js`: добавить `lib/i18n/**` в `DEFAULT_NEW`
|
||||
(копия по-прежнему old через `lib/v1/**`);
|
||||
- `eslint-plugins/__tests__/no-mixed-imports.test.ts`: кейсы «old → i18n
|
||||
чисто» (строки 83–89) и «lib/i18n — shared» (строки 99–105) обновить под
|
||||
новую классификацию: old→новый i18n = нарушение; добавить фикстуру
|
||||
`__fixtures__/src/lib/v1/i18n/t.ts` для кейса old-копия;
|
||||
- `eslint.config.js`: в `allowDefaultProject` добавить фикстуру
|
||||
`eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts`; поправить комментарий
|
||||
изоляции «Общее (core/, i18n/, theme)» → «Общее (core/, theme)»;
|
||||
- `eslint-plugins/README.md`: убрать i18n из списка shared (§ Isolation).
|
||||
- **0.6** Новый `lib/i18n/dict.ts`: `CategoryId` переключить с
|
||||
`../v1/categories` на `../categories` (новый структурно-идентичный тип) —
|
||||
скрытых v1-зависимостей в новом i18n не остаётся.
|
||||
- **0.7** Проверка форка: `pnpm --dir web lint`, `check`, `test` (v1-копия
|
||||
тестов подхватывается `vitest include: src/**/*.test.ts`), `test:rules`;
|
||||
смоук: v1-страницы и оба переключателя RU/EN работают как раньше, языки обеих
|
||||
версий согласованы.
|
||||
- **0.8** Добавить `lib/i18n/schema-tool-strings.ts` поверх `$lib/registry`
|
||||
(новый тип `ToolEntry`):
|
||||
- `toolTitle(tool)`, `toolDescription(tool)` с фолбэком на registry English;
|
||||
- `fieldLabel(field, id)` — перевод по ключу из `fields`-секции словаря (см.
|
||||
решение C), фолбэк на `labelOf(id)`;
|
||||
- `groupLabel(title)` — перевод по ключу из `groups`-секции словаря (см.
|
||||
решение A);
|
||||
- `verdictText(toolId, key)` — рендер вердикта по ключу;
|
||||
- `verdictTone(key)` — `success`|`danger`|`info` по имени ключа
|
||||
(`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке.
|
||||
- **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`):
|
||||
- секция `preview` **не заводится**: строки нового UI живут в собственных
|
||||
категориях — расширяем существующие (`catalog`, `header`, `dropZone`,
|
||||
`sourceCard`, `resultCard`, `paramsCard`, `textResult`, `ui`) и заводим две
|
||||
маленькие новые: `actions` (Generate / Open image / Download result…) и
|
||||
`textSource` (placeholder, Try sample…);
|
||||
- новая глобальная секция `fields: Record<string, string>` — переводы подписей
|
||||
полей по ключу из схемы (решение C);
|
||||
- новая глобальная секция `groups: Record<string, string>` — переводы
|
||||
заголовков групп полей (решение A).
|
||||
|
||||
### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем)
|
||||
|
||||
- **1.1** Заполнить `en.ts` и `ru.ts` секциями `actions`, `textSource`,
|
||||
`fields`, `groups` и ключами недостающих строк в существующих секциях.
|
||||
- **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и
|
||||
ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить
|
||||
title/description в ru.ts новой ветки (по категориям — несколько коммитов).
|
||||
- **1.3** Добавить в `ru.ts` ключи `fields`/`groups` для используемых в схемах
|
||||
ключей полей и заголовков групп (по ходу проставления ключей в schema, Фаза
|
||||
5).
|
||||
- **1.4** Добавить `tools[id].options[fieldId][value]` для select-полей, чьи
|
||||
опции нужно локализовать.
|
||||
- **1.5** Тест полноты (задел на Фазу 6): для каждого ключа label в schema есть
|
||||
перевод в обоих словарях.
|
||||
|
||||
### Фаза 2: LangToggle → реальный locale
|
||||
|
||||
- **2.1** `LangToggle.svelte`: убрать локальный `lang`, подключить
|
||||
`getLocale()`, `setLocale()`, `LOCALES`. Модель — language switch из
|
||||
`routes/v1/+layout.svelte`.
|
||||
- **2.2** Проверить e2e `navigation.spec.ts` «language toggle marks the active
|
||||
button» — должен проходить без изменений.
|
||||
|
||||
### Фаза 3: Routes + layout
|
||||
|
||||
- **3.1** `+layout.svelte`: breadcrumbs через `t()`, динамический `lang`.
|
||||
- **3.2** `+page.svelte`, `list-tools/+page.svelte`: footer-строка →
|
||||
`t("header.footerNote")`.
|
||||
- **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`.
|
||||
|
||||
### Фаза 4: Компоненты — основной блок (коммиты кучками по смыслу)
|
||||
|
||||
Все текстовые подписи в компонентах — информативные, сохраняем и переводим на
|
||||
`t`/хелперы. Каждый коммит: замена строк + lint/check.
|
||||
|
||||
- `SchemaToolView.svelte`: title/description →
|
||||
`toolTitle()`/`toolDescription()`, перевести `TOOL SETTINGS`, «Configure
|
||||
output» и «This tool has no schema yet.».
|
||||
- `SchemaSourceTile.svelte`: перевести figcaption `SOURCE`, «choose an
|
||||
image»/«no source…» и alt.
|
||||
- `SchemaResultTile.svelte`: перевести figcaption `RESULT`, «no result yet» и
|
||||
alt; суффикс «parts» — в `PreviewTile`.
|
||||
- `SchemaPreview.svelte`: перевести панельный лейбл `PREVIEW PANEL` и
|
||||
meta-капшены `SOURCE / RESULT / FORMAT`; значения «parts»/«text» → `t()`.
|
||||
- `SchemaActions.svelte`: Generating…, Generate, Open image, Download result.
|
||||
- `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)` →
|
||||
`fieldLabel(field)`.
|
||||
- `SchemaTextSource.svelte`: placeholder, Try sample, Render text.
|
||||
- `SchemaTextResult.svelte`: перевести лейбл `RESULT` у вердикта, Copy /
|
||||
Download .txt; tone detection → `verdictTone()`.
|
||||
- `CatalogToolbar.svelte`: фильтры → `t("categories.all")` +
|
||||
`t("categories.X")`, поисковый placeholder.
|
||||
- `CatalogHeader.svelte`: перевести heading/lead и счётчик «TOOLS AVAILABLE».
|
||||
- `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS».
|
||||
- `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`.
|
||||
- Schema field controls (по одному или группой): Width/Height, From/To, Backing
|
||||
plate, Opacity, Font/Size/Color/Bold, Sans/Serif/Mono, Angle, «+ Add color»,
|
||||
«Remove color».
|
||||
- `TopNav.svelte`, `ThemeToggle.svelte`, `CodeBlock.svelte`, `StepCard.svelte`,
|
||||
`Footer.svelte`.
|
||||
|
||||
### Фаза 5: Вердикты + ключи в schema
|
||||
|
||||
- **5.1** Анализаторы в `registry/analyze.ts`: возвращают ключи (`"verifyYes"`
|
||||
вместо raw English).
|
||||
- **5.2** `SchemaPreview.svelte`: рендер вердикта через
|
||||
`verdictText(tool.id, key)`.
|
||||
- **5.3** Layout groups в `registry/*.ts` (решение A): `title` → ключ
|
||||
(`title: "groups.canvas"`); в `SchemaFields.svelte` — `groupLabel(title)`. В
|
||||
`Dict` — глобальная секция `groups`.
|
||||
- **5.4** Лейблы полей в `registry/*.ts` (решение C): на поле в схеме
|
||||
проставляется `label`-ключ (`field.number({ label: "fields.thickness", … })`),
|
||||
вывод в UI — `fieldLabel(field)` = `t(label)`. В `Dict` — глобальная секция
|
||||
`fields`.
|
||||
- **5.5** Категории в `catalog.ts`: `GROUP_LABELS` → `t("categories.X")` (ключи
|
||||
уже есть в dict).
|
||||
|
||||
### Фаза 6: Тесты
|
||||
|
||||
- **6.1** `tools-i18n.test.ts` — полнота словарей для текущего registry:
|
||||
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
|
||||
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
|
||||
- **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются.
|
||||
- **6.3** В новой ветке `lib/i18n` появится собственный `smoke-i18n.test.ts`
|
||||
(аналог v1: переключение локали, `html lang`, контент без смеси языков) —
|
||||
v1-копия теста не трогается.
|
||||
|
||||
### Фаза 7: Кросс-языковой поиск
|
||||
|
||||
Переключить поиск в новом каталоге с `toLowerCase()` на
|
||||
`scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см.
|
||||
решение D). Последний шаг, аккуратный отдельный коммит.
|
||||
|
||||
### Фаза 8: Линтер словарей (i18n key linter)
|
||||
|
||||
Замена ручной проверки ключей на автоматическую. Инфраструктура уже есть:
|
||||
`eslint-plugins/` умеет читать соседние файлы (`isolation/no-mixed-imports`) и
|
||||
гоняться фикстурами (`test:rules`).
|
||||
|
||||
- **8.1** Новое правило `i18n/dict-consistency` в `web/eslint-plugins/`.
|
||||
Применяется к файлам-словарям (конвенция: экспортируют `Dict`, лежат в
|
||||
`lib/i18n/`); новые локали подхватываются автоматически — правка правила не
|
||||
нужна.
|
||||
- **8.2** Проверки — **только `warn`, никогда `error`** (пропущенный перевод не
|
||||
валит сборку):
|
||||
- **Паритет дерева ключей**: каждый ключ (dot-path `a.b.c`) присутствует во
|
||||
всех остальных локалях, не только против BASE. Нет —
|
||||
`missing key "x.y" in en`.
|
||||
- **Пустые значения**: `""` и строка из одних пробелов —
|
||||
`empty value "x.y" in en`.
|
||||
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
|
||||
всех локалях — поймать потерянную при переводе переменную.
|
||||
- **8.3** Цель — zero-warn. Сейчас `en.ts` неполный (в `tools` только
|
||||
результаты-помощники, остальной английский — из реестра), поэтому после
|
||||
включения будет много warn'ов: доводим `en.ts` до полноты `ru.ts` (секции +
|
||||
`tools` полностью, source — registry-English, без дрейфа). До достижения цели
|
||||
допускается allowlist-механизм в ядре правила для осознанных отклонений.
|
||||
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
|
||||
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
|
||||
|
||||
### Фаза 9: Множественные числа (plural-формы)
|
||||
|
||||
- **9.1** Тип значения словаря расширяем: `string | PluralForms`, где
|
||||
`PluralForms = { other: string } & Partial<{ one, few, many }>` по
|
||||
CLDR-правилам локали (ru: обязательны `one/few/many`; `other` — всегда, как
|
||||
фолбэк). Новая функция `tPlural(path, n)` в `lib/i18n/`: выбирает категорию
|
||||
через `Intl.PluralRules(locale).select(n)` и подставляет её с интерполяцией
|
||||
`{n}`.
|
||||
- **9.2** Первые потребители: «часть/части/частей» (split-into-parts-png),
|
||||
счётчики инструментов (`catalog.toolsCount`), «N параметров», «N файлов».
|
||||
- **9.3** Паритет в линтере (Фаза 8): если ключ — plural-объект в одной локали,
|
||||
он обязан быть plural-объектом во всех остальных (тип-паритет; string в одной
|
||||
из локалей — warn). Набор форм между локалями не сравнивается дословно — он
|
||||
проверяется по правилам конкретной локали (`other` обязателен; en: one/other,
|
||||
ru: one/few/many).
|
||||
- **9.4** Открытый вопрос на этапе реализации: неоднозначность «объект-секция»
|
||||
(`Record<string, string>`) vs plural-объект в дереве словаря. Варианты:
|
||||
отдельная секция `plurals` в словаре, конвенция имён ключей, вывод типа из
|
||||
контекста. Фаза начинается с проработки этого вопроса, не с кода.
|
||||
|
||||
## 5. Решения
|
||||
|
||||
- **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме
|
||||
(`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря
|
||||
(`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` =
|
||||
`t(title)` с фолбэком на сырой ключ.
|
||||
- **C. Лейблы полей**: в схеме задаём i18n-ключ
|
||||
(`field.number({ label: "fields.thickness", … })`), в UI выводится перевод —
|
||||
`fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря,
|
||||
фолбэк на текущий `labelOf(id)` для несопоставленных.
|
||||
- **D. Объём**: переводим всё сразу — и каркас, и контент реестра, и вердикты, и
|
||||
кросс-языковой поиск (Фаза 7). Крупные изменения кода разбиваем на небольшие
|
||||
атомарные шаги/коммиты.
|
||||
|
||||
## 6. Проверки
|
||||
|
||||
- `pnpm --dir web lint`
|
||||
- `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`
|
||||
- `pnpm --dir web test`
|
||||
- `pnpm --dir web test:rules` (если менялся eslint/isolation-конфиг)
|
||||
- Смоук: переключатель RU/EN на новом UI; страницы каталога/инструмента, ошибка,
|
||||
вердикт — без смеси языков; `html lang` следует за локалью.
|
||||
@@ -1,12 +1,13 @@
|
||||
# План: переезд на новый дизайн (refs) — параллельная сборка
|
||||
|
||||
> Статус: план к выполнению. Шаги 5–6 (C17–C21) остаются **следующим этапом**.
|
||||
> Они больше не привязаны к «верности против рефа»:
|
||||
> `docs/archive/plan-design-fix.md` закрыт, аудит против рефа прекращён.
|
||||
> Tech debt закрыт (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд
|
||||
> старых файлов в `old/`) — **выполнено**. Покупка домена НЕ блокер C17:
|
||||
> домен нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
||||
> C17 можно запускать без домена (см. §10).
|
||||
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**.
|
||||
> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21
|
||||
> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не
|
||||
> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт,
|
||||
> аудит против рефа прекращён. Tech debt закрыт
|
||||
> (`docs/archive/plan-tech-debt.md`). Backlog №14 (переезд старых файлов в
|
||||
> `old/`) — **выполнено**. Покупка домена НЕ была блокером C17: домен нужен для
|
||||
> S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
||||
>
|
||||
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
||||
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
||||
@@ -241,11 +242,11 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений.
|
||||
|
||||
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`,
|
||||
старого `app.css` (и его токенов), неиспользуемых классов; переименование
|
||||
`kit/` → `ui/` (финальное имя) либо оставление `kit/` как основного — решается
|
||||
на фазе 1. Финальный проход по брейкпоинтам 1200/1100/800/480. Критерий:
|
||||
`grep` по старым токенам/классам пуст, build чистый, старый дизайн нигде не
|
||||
упоминается.
|
||||
старого `app.css` (и его токенов), неиспользуемых классов; наведение порядка в
|
||||
`components/` (имя каталога и подпапок — решается на фазе 1; к C17 `kit/` уже
|
||||
разложен в `components/`). Финальный проход по брейкпоинтам 1200/1100/800/480.
|
||||
Критерий: `grep` по старым токенам/классам пуст, build чистый, старый дизайн
|
||||
нигде не упоминается.
|
||||
|
||||
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня),
|
||||
Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня).
|
||||
@@ -265,9 +266,9 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
|
||||
## 9. Открытые вопросы
|
||||
|
||||
- **Имя финального каталога компонентов:** оставить `kit/` как основное имя или
|
||||
переименовать в `ui/` на фазе 5 (удалив старое `ui/`). Рекомендую сразу писать
|
||||
в `kit/`, на фазе 5 переименовать в `ui/`. Решить на фазе 1.
|
||||
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
|
||||
разложил его в `components/` (подпапки fields/layout/ui). На C19 — только
|
||||
навести порядок внутри `components/`, переименование не требуется.
|
||||
- **Изоляция через layout:** `app.css` вынесен из корневого layout в
|
||||
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
|
||||
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
|
||||
@@ -358,15 +359,14 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
неконсистентен. (доказательство отсутствия регресса). Фиксы по результатам
|
||||
— отдельными мелкими коммитами.
|
||||
|
||||
### Шаг 5. Сделать новый дизайн основным — СЛЕДУЮЩИЙ ЭТАП
|
||||
### Шаг 5. Сделать новый дизайн основным
|
||||
|
||||
> Tech debt закрыт. Backlog №14 (переезд старых файлов в `lib/old/`) — **выполнено**.
|
||||
> Покупка домена — НЕ блокер C17. C17 переносит маршруты и делает новый дизайн
|
||||
> основным; `noindex` остаётся до покупки домена и запуска S1 (`plan-seo.md`).
|
||||
> Детальный план см. в `docs/plan-c17.md` (старый `(old)/` → `/v1/`, удаление
|
||||
> отложено до C19).
|
||||
> C17 — **выполнен** (`docs/archive/plan-c17.md`): старый `(old)/` → `/v1/`,
|
||||
> preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки
|
||||
> импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались:
|
||||
> C19 (удаление старого, раскрытие `recommended` на весь код), C20, C21.
|
||||
|
||||
- [ ] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
||||
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
||||
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
|
||||
shell. Убрать флаг/кнопку. **Снять `noindex, nofollow`** со страницы
|
||||
инструмента (`preview/tools/[id]/+page.svelte`) — страницы становятся
|
||||
@@ -376,26 +376,37 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
вместе с подключением canonical в S1. **И обновить `newCode` в
|
||||
`web/eslint.config.js`:** заменить `src/routes/preview/**` на
|
||||
`src/routes/**` (после переезда все маршруты — новый дизайн), компонентный
|
||||
glob `src/lib/components/kit/**` пока оставить.
|
||||
- [ ] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
|
||||
glob `src/lib/components/kit/**` пока оставить. _(Итог: `newCode` обновлён
|
||||
(`components/**`, `routes/**`), `noindex` оставлен до S1 — домен ещё не
|
||||
куплен. Детали: `docs/archive/plan-c17.md`.)_
|
||||
- [x] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
|
||||
_(Билд собирается без ошибок, линт/свелт-чека нет — выполнен.)_
|
||||
|
||||
### Шаг 6. Удаление старого
|
||||
### Шаг 6. Проверка нового дизайна
|
||||
|
||||
- [ ] **[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/**`._
|
||||
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
||||
предупреждений; `grep` по старым токенам/классам пуст.
|
||||
- [ ] **[C21]** _(опционально)_ Удалить `refs/` и `refs-html/` из репо — они
|
||||
больше не источник правды.
|
||||
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
|
||||
отсутствия регресса: старый `v1/` работает и не затронут.
|
||||
|
||||
### Шаг 7. Опциональная чистка (отложен, нет срока)
|
||||
|
||||
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
|
||||
правды.
|
||||
- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
||||
`app_v1.css` (и его токены), неиспользуемые классы. **И раскрыть ESLint
|
||||
`recommended` на весь код** — убрать scoped-блок `newCode` в
|
||||
`web/eslint.config.js`, прогнать `lint` и починить всплывшие ошибки.
|
||||
_Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) —
|
||||
это не «старый дизайн», у них свой линт-долг; расширение `recommended` на
|
||||
них может выдать много ошибок. Решить на C19: либо чиним сразу, либо
|
||||
расширяем scoped только на `src/routes/**` + `src/lib/components/**`._
|
||||
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
|
||||
каталога).
|
||||
|
||||
> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской
|
||||
> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит
|
||||
> только когда нет горящих задач и хочется навести порядок.
|
||||
>
|
||||
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
||||
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
||||
> чтобы регрессия локализовалась одним шагом назад.
|
||||
@@ -412,8 +423,6 @@ src/routes/preview/tools/[id]/+page.svelte
|
||||
> удаление `(old)/` + смена root-layout — в ОДНОМ коммите. Снятие `noindex`
|
||||
> также в C17, **если домен уже куплен**; иначе — отложить на S1. Не дробить
|
||||
> сам C17.
|
||||
> - **C19 должен идти строго после C17** (старый `app.css`/`ui/` удаляем только
|
||||
> когда old-сайт уже выключен).
|
||||
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны
|
||||
> существовать до страниц, которые их импортируют (C8–C14).
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# План: SEO + GEO — индексация и конкуренция в image tools
|
||||
|
||||
> **Статус (2026-09-10):** план к выполнению. Старт — после C17
|
||||
> (`plan-redesign.md`): до переезда `preview/*` на реальные маршруты страницы
|
||||
> инструментов в `noindex`, применять техминимум некуда.
|
||||
> **Уточнено:** домен НЕ блокер C17 (см. §6). Задачи S1 разбиты на
|
||||
> **Статус (2026-09-11):** план к выполнению. C17 выполнен — `preview/*`
|
||||
> переехали на реальные маршруты (в `noindex` сохраняются до запуска S1, см.
|
||||
> `plan-redesign.md` C17). Сейчас применять можно domain-free часть S1.
|
||||
> **Уточнено:** домен не был блокером C17 (см. §6). Задачи S1 разбиты на
|
||||
> domain-free (можно делать параллельно с покупкой домена) и domain-required
|
||||
> (после покупки и настройки домена на Vercel).
|
||||
>
|
||||
@@ -136,8 +136,8 @@
|
||||
|
||||
### S1. Технический минимум (сразу после C17)
|
||||
|
||||
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена)
|
||||
и **domain-required** (после покупки и DNS-настройки).
|
||||
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
|
||||
**domain-required** (после покупки и DNS-настройки).
|
||||
|
||||
#### S1-free: без домена (параллельно с C18–C19)
|
||||
|
||||
@@ -167,16 +167,16 @@
|
||||
из `pnpm build` после `vite build` (adapter-static пишет в `build/`,
|
||||
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
|
||||
опционального `BASE_PATH`.
|
||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты
|
||||
(`/kit` до C19; после — вернуть минимальный allow-all).
|
||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до
|
||||
C19; после — вернуть минимальный allow-all).
|
||||
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
||||
актуальному маршруту), OG-теги (title/description/type/url/image),
|
||||
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
||||
для абсолютных URL в canonical и OG:url.
|
||||
|
||||
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d.
|
||||
> Гейты: build зелёный; в `build/` лежит корректный `sitemap.xml` со всеми
|
||||
> инструментами; canonical/OG присутствуют на всех страницах; e2e зелёные.
|
||||
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d. Гейты: build
|
||||
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
|
||||
> canonical/OG присутствуют на всех страницах; e2e зелёные.
|
||||
|
||||
### S2. Контентный слой (параллельно C18–C19, волнами по категориям)
|
||||
|
||||
@@ -329,10 +329,10 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
|
||||
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
||||
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
||||
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
||||
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют).
|
||||
R2 (домен) — блокер только для S1-domain (canonical + sitemap + снятие
|
||||
`noindex`). После индексации переименование = переезд с 301, потеря позиций
|
||||
и ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17.
|
||||
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2
|
||||
(домен) — блокер только для S1-domain (canonical + sitemap + снятие
|
||||
`noindex`). После индексации переименование = переезд с 301, потеря позиций и
|
||||
ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17.
|
||||
|
||||
## 7. Что осознанно НЕ делаем
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# План разработки: easy-png-tools
|
||||
|
||||
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена —
|
||||
> сайт живёт, каталог переведён в типизированный `registry-new` (121/125), идёт
|
||||
> редизайн на `preview/*` по `plan-redesign.md` (параллельная ветка, старый UI
|
||||
> на `(old)/`). Tech debt закрыт. Переезд старых файлов в `old/` выполнен.
|
||||
> Следующий этап: C17 (перенос preview → корневые маршруты). Домен НЕ блокер
|
||||
> C17, нужен для SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10
|
||||
> спланированы в `docs/plan-platform.md`.
|
||||
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
||||
> каталог переведён в типизированный `registry` (121/125), новый дизайн по
|
||||
> `plan-redesign.md` стал основным на корневых маршрутах (C17/C18 выполнены),
|
||||
> старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19 в бэклоге).
|
||||
> Tech debt закрыт. Следующий этап: C20 (проверка нового дизайна,
|
||||
> `plan-redesign.md`, §10) + SEO S1-free (`plan-seo.md`) + баги. Домен нужен для
|
||||
> SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
|
||||
> `docs/plan-platform.md`.
|
||||
|
||||
## 0. Решения
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Аудит использования - UX\UI
|
||||
|
||||
## везде
|
||||
|
||||
- возможность переключать пиксели\проценты
|
||||
- инструменты без параметров выглядят странно, пересмотреть, может добавить какую-то надпись "нет параметров"
|
||||
- удаление\маски и т.д. - надо добавить галочку сглаживание
|
||||
- производительность: почему flip заметно тормозит, но нет индикации работы инструмента (также sharpen-png)
|
||||
- производительность: исследовать, есть ли смысл на инструментах геометрии выводить рамку результата до полноценной работы (например на skew \ rotate будет удобнее)
|
||||
|
||||
## аудит инструментов
|
||||
|
||||
### transparency
|
||||
|
||||
- add-stroke-png: добавить галочку "расширить на толщину линии"
|
||||
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо расширять на толщину линии
|
||||
- circle-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- square-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- star-mask-png: размеры заданы в процентах, надо задавать в пикселях, проверить форму звезды - сейчас выпуклые края, радиус задан до 100%, а более 50 - смысле нет
|
||||
- wavy-mask-png:размеры заданы в процентах, надо задавать в пикселях, пересмотреть форму и параметры. как будто неудобно конфигурировать
|
||||
- remove-alpha-channel-png: выбор цвета фона, как будет работать с полупрозрачной картинкой?
|
||||
- extract-alpha-mask-png: галочка инвертировать маску
|
||||
- round-corners-png: размеры заданы в пикселях, должна быть возможность задать в процентах, нету сглаживания по вырезанным пикселям
|
||||
- invert-alpha-png: края со сглаживанием странно обрабатываются, может галочка\слайдер обрезать сглаживание?
|
||||
- remove-background-png: возможность выбора точки, от которой удалять (или отдельный инструмент?)
|
||||
- make-thicker-png: расширять картинку на толщину линии
|
||||
- make-thinner-png: уменьшать от краев, если картинка начинается от края, то остается выступ
|
||||
- feather-edges-png: расширять картинку на толщину линии, (выбор цвета возможен? другой инструмент?)
|
||||
- clean-edges-png: непонятный инструмент (слишком маленький максимум?). найти тест кейс для него
|
||||
- despeckle-alpha-png: найти более заметный тест-кейс
|
||||
- close-holes-png: проверить, как должен работать, картинки с большой полупрозрачной областью - закрывает всё, по описанию должен закрывать только небольшие дырки
|
||||
|
||||
### colors
|
||||
|
||||
- two-colors-png: в range slider добавить описание что это luminance threshold
|
||||
- gamma-png: проверить правильность работы - яркое изображение выглядит не контрастным, проверить максимум и минимум
|
||||
- temperature-png: проверить правильность работы - не слишком ли отдает желтым\синим?
|
||||
- quantize-png: максимум 64 - не слишком мало? сделать кнопки с пресетами
|
||||
- custom-palette-png: копирует two-colors-png, только ввод через текстовое поле и нет выбора порога luminance
|
||||
- dithering-png: проверить алгоритм Floyd–Steinberg на малом количестве цветов
|
||||
- sepia-png: не нужна настройка силы эффекта?
|
||||
- extract-channel-png: мульти вывод сразу в 3 канала?
|
||||
- swap-channels-png: обдумать описание подобных инструментов через flow более простых?
|
||||
- black-and-white-png: аналог two-colors-png, только без выбора цветов
|
||||
- posterize-png: не нужен вариант настройки для каждого канала отдельно?
|
||||
- auto-contrast-png: найти тестовый кейс
|
||||
- decrease-color-count-png: сделать кнопки вместо селекта
|
||||
- png-to-hsl: проверить, что `space as rgb` должна выдавать одинаковые результаты, вне зависимости от выбора h s l. может выбрать отдельные кнопки?
|
||||
- png-to-hsv: тоже самое ^
|
||||
- png-to-hsi: тоже самое ^, нужно ли разделять на отдельные инструменты?
|
||||
- png-to-cmyk: тоже самое ^
|
||||
- png-to-lab: тоже самое ^
|
||||
|
||||
### geometry
|
||||
|
||||
- add-border-png: прозрачность цвета (аудит полей)
|
||||
- fit-on-background-png: цвет
|
||||
- change-canvas-size-png: использовать position9 вместо селекта
|
||||
- resize-png: по умолчанию размеры 0х0, надо поменять; выбор метода масштабирования
|
||||
- crop-png: offset - слишком большие значения
|
||||
- rotate-png: кнопки вместо select
|
||||
- flip-png: кнопки вместо select
|
||||
- add-padding-png: чем отличается от add-border-png?
|
||||
- center-by-alpha-png: найти тест кейс
|
||||
- zoom-png: выбор точки центра
|
||||
- trim-empty-space-png: выбор от 0 до 254 (надо везде привести к единому стилю - переключение 0-100 проценты и 0-255)
|
||||
- change-aspect-ratio-png: не видно что меняется, добавить рамку вокруг результата, а не вокруг div
|
||||
- swap-orientation-png: кнопки вместо select
|
||||
- symmetric-copy-png: кнопки вместо select, два выбора в селекте `keep side` не работают (в зависимости от выбора `axis`)
|
||||
- shift-png: цвет (прозрачность)
|
||||
- split-into-parts-png: результат с несколькими файлами, обдумать процесс
|
||||
|
||||
### filters
|
||||
|
||||
- vignette-png: выбор точки центра, цвет, кроме силы нужен еще параметр прироста силы (как называется?)
|
||||
- pixelate-png: разные алгоритмы? выглядит не очень. тест кейс?
|
||||
- randomize-pixels-png: настройка размеров другим вариантом - на сколько кусков делить. seed - поменять
|
||||
- add-noise-png: кнопки вместо select, seed - поменять
|
||||
- sharpen-png: добавить тест кейс
|
||||
- silhouette-png: аналог extract-alpha-mask-png?
|
||||
|
||||
### text
|
||||
|
||||
- add-text-png: plate снизу большой отступ, сделать галочку для этого (некоторые буквы будут обрезаться - y, g)
|
||||
- watermark-tile-png: заполняет не полностью
|
||||
|
||||
### analyze
|
||||
|
||||
- extract-color-from-png: надо описать или дать выбор на основе какого параметра работает threshold
|
||||
- show-transparent-png: кнопки вместо select
|
||||
- show-grayscale-pixels-png: кнопки вместо select
|
||||
- show-color-pixels-png: кнопки вместо select
|
||||
- light-pixel-mask-png: кнопки вместо select
|
||||
- verify-is-png: название неверное, в описании указано изображение, а фактически проверяется текст
|
||||
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный вердикт? может отдельные поля - 0\1, yes\no?
|
||||
|
||||
### generators
|
||||
|
||||
- color-spectrum-png: кнопки вместо select
|
||||
- draw-grid-png: выбор цвета фона
|
||||
- placeholder-png: с галочкой "показать текст" нет заполнения фона
|
||||
- blend-two-png: высота не задается
|
||||
- step-colors-png: кнопки вместо select, высота не задается, не слишком ли маленький max steps?
|
||||
- emoji-to-png: выбор эмодзи
|
||||
- color-wheel-png: сглаживание краев
|
||||
- complementary-png: кнопки вместо select(?), не меняется, всего 2 цвета, высота не задается
|
||||
- triadic-png: кнопки вместо select, высота не задается
|
||||
- tetradic-png: кнопки вместо select, высота не задается
|
||||
- analogous-png: кнопки вместо select, высота не задается
|
||||
- monochromatic-png: кнопки вместо select, высота не задается
|
||||
- shades-png: кнопки вместо select, высота не задается
|
||||
- mix-colors-png: высота не задается
|
||||
- sort-colors-png: кнопки вместо select, высота не задается
|
||||
|
||||
## аудит полей
|
||||
|
||||
- position offset: должны быть отдельные range slider на x и y
|
||||
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
|
||||
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
|
||||
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
|
||||
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
|
||||
- поле seed (random) - range slider не нужен, поле для ввода и "randomize" кнопка
|
||||
@@ -99,6 +99,7 @@
|
||||
- add-border-png — thickness, color
|
||||
- fit-on-background-png — width, height, transparent, color
|
||||
- tile-png — columns, rows
|
||||
- split-into-parts-png — columns, rows (мультифайловый вывод, zip)
|
||||
- trim-empty-space-png — threshold (альфа)
|
||||
- change-canvas-size-png — width, height, anchor (3×3)
|
||||
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad)
|
||||
@@ -222,9 +223,9 @@
|
||||
превью: 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?).
|
||||
- **Мультифайловый вывод** — механизм «результат = набор файлов» (zip)
|
||||
реализован Остальные 1→many: gif-to-frames, separate-colors,
|
||||
multiply-grid-as-files — подключаются по мере нужды.
|
||||
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
|
||||
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
|
||||
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "pnpm --dir web test",
|
||||
"dev": "pnpm --dir web dev",
|
||||
"build": "pnpm --dir web build",
|
||||
"format": "pnpm format:docs && pnpm --dir web format",
|
||||
"format:docs": "prettier --write docs --log-level warn",
|
||||
"check:docs": "prettier --check docs",
|
||||
"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"
|
||||
"generate:fixtures": "node tests/fixtures/manual-edge-cases/generate.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ku6epXBOCTuK",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# refs-html — статические снимки нового дизайна
|
||||
|
||||
Отформатированный HTML, вытащенный из Next-референса `refs/`, чтобы смотреть
|
||||
макеты как референс по разметке и классам: открыл файл в браузере — увидел
|
||||
дизайн.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Маршрут в нашем приложении (audit) | Что показывает |
|
||||
| ------------------------- | -------------------------------------- | --------------------------------------------------- |
|
||||
| `demo.html` | `/preview/demo` | Пайплайн-воркспейс + панель превью |
|
||||
| `list-tools.html` | `/preview/list-tools` | Каталог инструментов (группы, поиск, фильтры) |
|
||||
| `gradient.html` | `/preview/tools/linear-gradient-png` | Инструмент «Градиент»: настройки + превью + CSS |
|
||||
| `background-remover.html` | `/preview/tools/remove-background-png` | Инструмент «Удаление фона»: сравнение source/result |
|
||||
|
||||
> `index.html` не используется: в Next-рефе это зеркало `demo` (главная == `/easy-png-tools/demo`),
|
||||
> а в нашем приложении `/preview` — своя страница (воркспейс). Файл не генерируется
|
||||
> экстрактором (`scripts/extract-static.mjs` пропускает `index.html`) и исключён
|
||||
> из аудита (`EXCLUDE` в `web/scripts/audit.mjs`).
|
||||
|
||||
## Особенности
|
||||
|
||||
- **Это статика без JS**: все `<script>` удалены, интерактивности нет.
|
||||
Тумблеры/кнопки/слайдеры не кликаются — это витрина состояний по умолчанию.
|
||||
- Разметка отформатирована (отступы), CSS темы заинлайнен и причёсан —
|
||||
файлы автономны, работают с `file://`.
|
||||
- Тема по умолчанию: `gradient.html` и `background-remover.html` отрисованы
|
||||
в тёмной (так в коде рефа), `index.html`/`demo.html` — в светлой. Чтобы
|
||||
посмотреть светлый вариант тёмных страниц — убрать класс `dark-mode` у
|
||||
`.app-shell`; для тёмной главной — добавить его же.
|
||||
- Источник правды по пикселям — код в `refs/`; здесь только витрина.
|
||||
|
||||
## Как перегенерировать
|
||||
|
||||
```powershell
|
||||
cd refs
|
||||
pnpm install # если ещё не ставили
|
||||
node node_modules\next\dist\bin\next build # output: 'export' включён в next.config.mjs
|
||||
node extract-static.mjs .next\server\app ..\refs-html # форматирование, инлайн-CSS, без скриптов
|
||||
```
|
||||
|
||||
Скрипт: `refs/extract-static.mjs`.
|
||||
@@ -1,15 +0,0 @@
|
||||
# v0 sandbox internal files
|
||||
__v0_runtime_loader.js
|
||||
__v0_devtools.tsx
|
||||
__v0_jsx-dev-runtime.ts
|
||||
.snowflake/
|
||||
.v0-trash/
|
||||
.vercel/
|
||||
|
||||
# Environment variables
|
||||
.env*.local
|
||||
|
||||
# Common ignores
|
||||
node_modules
|
||||
.next/
|
||||
.DS_Store
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import TopBar from '@/components/top-bar'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Download, RotateCcw, Upload, Check } from 'lucide-react'
|
||||
|
||||
export default function BackgroundRemoverPage() {
|
||||
const [color, setColor] = useState('#E8EEF2')
|
||||
const [similarity, setSimilarity] = useState(72)
|
||||
const [outerOnly, setOuterOnly] = useState(true)
|
||||
const [showMask, setShowMask] = useState(false)
|
||||
const [downloaded, setDownloaded] = useState(false)
|
||||
const previewBackground = showMask ? 'repeating-conic-gradient(#7b8791 0 25%, #cbd3da 0 50%) 50% / 28px 28px' : 'repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px'
|
||||
const subjectStyle = useMemo(() => ({ background: showMask ? '#596773' : 'linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)', opacity: showMask ? .88 : 1, clipPath: `polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)` }), [showMask])
|
||||
const reset = () => { setColor('#E8EEF2'); setSimilarity(72); setOuterOnly(true); setShowMask(false) }
|
||||
return <main className="app-shell">
|
||||
<TopBar section="BACKGROUND REMOVER" status="AUTO PROCESSING" />
|
||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Remove background.</h1><p className="lede">Select a background color and tune the edge detection. Changes are processed automatically in your browser.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
||||
<div className="remover-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">REMOVER SETTINGS</span><strong>Configure detection</strong></div><span className="step-type">TOOL 02</span></div>
|
||||
<div className="setting-group"><label>BACKGROUND COLOR</label><div className="color-field"><span className="swatch" style={{ background: color }} /><input value={color} onChange={e => setColor(e.target.value)} /><input className="native-color" type="color" value={color} onChange={e => setColor(e.target.value)} aria-label="Choose background color" /></div><div className="color-reference"><span style={{ background: color }} /> sampled from image background</div></div>
|
||||
<div className="setting-group"><label>COLOR SIMILARITY <output>{similarity}%</output></label><input type="range" min="0" max="100" value={similarity} onChange={e => setSimilarity(Number(e.target.value))} /><div className="range-hints"><span>strict edges</span><span>more removal</span></div></div>
|
||||
<div className="setting-group toggle-group"><label><span>OUTER COLOR ONLY</span><input type="checkbox" checked={outerOnly} onChange={e => setOuterOnly(e.target.checked)} /><b className="toggle" /></label><p>Only remove connected background pixels from the edges.</p></div>
|
||||
<div className="setting-group toggle-group"><label><span>SHOW MASK</span><input type="checkbox" checked={showMask} onChange={e => setShowMask(e.target.checked)} /><b className="toggle" /></label><p>Preview the detected transparency mask.</p></div>
|
||||
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
|
||||
</section>
|
||||
<section className="remover-preview"><div className="preview-toolbar"><div><span className="label">SOURCE / RESULT</span><strong>comparison.png</strong></div><div className="preview-actions"><span className="processed"><Check size={14} /> processed</span><button className="download-btn" onClick={() => setDownloaded(true)}><Download size={15} /> {downloaded ? 'Downloaded' : 'Download result'}</button></div></div><div className="comparison-grid"><div className="image-card"><div className="image-label"><span>SOURCE</span><b>original.png</b></div><div className="remover-canvas source-canvas"><div className="subject subject-source" style={subjectStyle}><span>OBJECT</span></div><span className="canvas-size">1200 × 800</span></div></div><div className="image-card"><div className="image-label"><span>RESULT</span><b>{showMask ? 'mask-preview.png' : 'removed-bg.png'}</b></div><div className="remover-canvas" style={{ background: previewBackground }}><div className="subject" style={subjectStyle}><span>{showMask ? 'MASK' : 'PNG'}</span></div><span className="canvas-size">1200 × 800</span></div></div></div><div className="result-meta"><span>FORMAT <b>PNG-24</b></span><span>ALPHA <b>{showMask ? 'MASK' : 'ENABLED'}</b></span><span>SIMILARITY <b>{similarity}%</b></span></div></section></div>
|
||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>background remover · local-only</span><span>© 2024</span></footer>
|
||||
</main>
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '../../page'
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import TopBar from '@/components/top-bar'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, Copy, Download, RotateCcw } from 'lucide-react'
|
||||
|
||||
const directions = ['0° →', '45° ↗', '90° ↑', '135° ↖', '180° ←', '225° ↙', '270° ↓', '315° ↘']
|
||||
|
||||
export default function GradientPage() {
|
||||
const [start, setStart] = useState('#1769D2')
|
||||
const [end, setEnd] = useState('#00A8C7')
|
||||
const [direction, setDirection] = useState(135)
|
||||
const [opacity, setOpacity] = useState(100)
|
||||
const [type, setType] = useState<'linear' | 'radial'>('linear')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const gradient = useMemo(() => type === 'linear' ? `linear-gradient(${direction}deg, ${start} 0%, ${end} 100%)` : `radial-gradient(circle, ${start} 0%, ${end} 100%)`, [direction, end, start, type])
|
||||
const css = `background: ${gradient};\nopacity: ${opacity / 100};`
|
||||
const reset = () => { setStart('#1769D2'); setEnd('#00A8C7'); setDirection(135); setOpacity(100); setType('linear') }
|
||||
const copyCss = async () => { await navigator.clipboard?.writeText(css); setCopied(true); setTimeout(() => setCopied(false), 1400) }
|
||||
return <main className="app-shell">
|
||||
<TopBar section="GRADIENT" status="LIVE PREVIEW" />
|
||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Gradient background.</h1><p className="lede">Create a clean, export-ready gradient with precise control over color, direction and transparency.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
||||
<div className="gradient-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">GRADIENT SETTINGS</span><strong>Configure output</strong></div><span className="step-type">TOOL 01</span></div>
|
||||
<div className="setting-group"><label>GRADIENT TYPE</label><div className="segmented wide"><button className={type === 'linear' ? 'selected' : ''} onClick={() => setType('linear')}>Linear</button><button className={type === 'radial' ? 'selected' : ''} onClick={() => setType('radial')}>Radial</button></div></div>
|
||||
<div className="setting-group"><label>COLOR STOPS</label><div className="color-row"><div className="color-field"><span className="swatch" style={{ background: start }} /><input value={start} onChange={e => setStart(e.target.value)} /></div><span className="stop-arrow">→</span><div className="color-field"><span className="swatch" style={{ background: end }} /><input value={end} onChange={e => setEnd(e.target.value)} /></div></div><div className="gradient-bar" style={{ background: gradient }} /></div>
|
||||
<div className="setting-group"><label>DIRECTION <output>{direction}°</output></label><input type="range" min="0" max="360" value={direction} onChange={e => setDirection(Number(e.target.value))} /><div className="direction-grid">{directions.map((item, i) => <button key={item} className={direction === i * 45 ? 'selected' : ''} onClick={() => setDirection(i * 45)}>{item}</button>)}</div></div>
|
||||
<div className="setting-group"><label>OPACITY <output>{opacity}%</output></label><input type="range" min="0" max="100" value={opacity} onChange={e => setOpacity(Number(e.target.value))} /></div>
|
||||
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
|
||||
</section>
|
||||
<section className="gradient-preview"><div className="preview-toolbar"><div><span className="label">OUTPUT PREVIEW</span><strong>gradient.png</strong></div><div className="preview-actions"><button className="secondary-btn" onClick={copyCss}>{copied ? <Check size={15} /> : <Copy size={15} />} {copied ? 'Copied' : 'Copy CSS'}</button><button className="download-btn"><Download size={15} /> Download PNG <ChevronDown size={14} /></button></div></div><div className="large-canvas"><div className="gradient-art" style={{ background: gradient, opacity: opacity / 100 }}><div className="art-mark">PNG</div><span>easy-png-tools</span></div></div><div className="code-block"><div><span className="label">GENERATED CSS</span><button className="icon-btn" onClick={copyCss} aria-label="Copy CSS"><Copy size={14} /></button></div><pre>{css}</pre></div></section></div>
|
||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>gradient tool · local-only</span><span>© 2024</span></footer>
|
||||
</main>
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import TopBar from '@/components/top-bar'
|
||||
import Link from "next/link"
|
||||
import { useMemo, useState } from "react"
|
||||
import { ArrowUpRight, FileImage, Filter, Search, Sparkles } from "lucide-react"
|
||||
|
||||
const groups = [
|
||||
{ name: "CONVERT", tools: [["Convert JPG to PNG", "Re-encode JPEG files as lossless PNG while preserving transparency.", "/easy-png-tools/gradient"], ["Convert WebP to PNG", "Turn WebP images into a universal PNG format for any workflow."], ["PNG to Base64", "Encode an image as a base64 string for embedding in code or styles."], ["PNG to Data URI", "Build a complete data URI ready for HTML and CSS."], ["Convert PNG to JPG", "Composite transparency over a selected backdrop and export JPEG."]] },
|
||||
{ name: "TRANSPARENCY", tools: [["Remove background PNG", "Remove a solid background by color, tolerance, or edge-connected regions.", "/easy-png-tools/background-remover"], ["Extract alpha mask", "Turn the alpha channel into a clean black-and-white mask."], ["Round corners PNG", "Clip the image corners by a precise radius percentage."], ["Outline PNG", "Add a colored ring around opaque content with adjustable thickness."], ["Change PNG opacity", "Multiply the alpha channel while keeping the original colors unchanged."]] },
|
||||
{ name: "COLOR", tools: [["Create gradient PNG", "Generate a smooth transition between two colors with direction controls.", "/easy-png-tools/gradient"], ["Grayscale PNG", "Convert the image to luminance-based shades of gray."], ["Invert colors PNG", "Invert every color channel while leaving alpha untouched."], ["Brightness & contrast", "Adjust brightness and contrast across a controlled range."], ["Temperature PNG", "Make an image warmer or cooler with a single precise control."]] },
|
||||
{ name: "GEOMETRY", tools: [["Resize PNG", "Scale an image with bilinear interpolation and optional aspect lock."], ["Crop PNG", "Cut a rectangular area with exact coordinates and dimensions."], ["Rotate PNG", "Rotate by 90, 180, or 270 degrees without quality loss."], ["Flip PNG", "Mirror the image horizontally or vertically."], ["Add padding to PNG", "Expand the canvas on all sides by a chosen number of pixels."]] },
|
||||
{ name: "FILTERS", tools: [["Blur PNG", "Apply a fast Gaussian-style blur with transparent edge handling."], ["Sharpen PNG", "Emphasize edges with an adjustable sharpening kernel."], ["Vignette PNG", "Smoothly darken the image edges while preserving the center."], ["JPEG artifacts", "Simulate low-quality JPEG recompression for testing."]] },
|
||||
{ name: "ANALYZE", tools: [["PNG info", "Inspect dimensions, alpha presence, and unique color count."], ["Check grayscale", "Report whether the image contains only shades of gray."], ["Check transparency", "Detect transparent and semi-transparent pixels."], ["PNG orientation", "Classify the image as portrait, landscape, or square."]] },
|
||||
]
|
||||
|
||||
export default function ListToolsPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [active, setActive] = useState("ALL")
|
||||
const filtered = useMemo(() => groups.map(group => ({ ...group, tools: group.tools.filter(([name, description]) => (active === "ALL" || active === group.name) && `${name} ${description}`.toLowerCase().includes(query.toLowerCase())) })).filter(group => group.tools.length), [query, active])
|
||||
return <main className="app-shell"><TopBar section="CATALOG" status="LOCAL MODE / READY" /><div className="catalog-page">
|
||||
<div className="catalog-head"><div><div className="eyebrow">EASY-PNG-TOOLS / CATALOG</div><h1>Tool catalog</h1><p>Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.</p></div><div className="catalog-total"><b>32</b><span>TOOLS<br />AVAILABLE</span></div></div>
|
||||
<div className="catalog-toolbar"><label className="catalog-search"><Search size={16} /><input aria-label="Search tools" placeholder="Search tools..." value={query} onChange={e => setQuery(e.target.value)} /></label><div className="catalog-filters"><Filter size={15} />{["ALL", ...groups.map(g => g.name)].map(name => <button key={name} className={active === name ? "active" : ""} onClick={() => setActive(name)}>{name}</button>)}</div></div>
|
||||
<div className="catalog-groups">{filtered.map(group => <section className="catalog-group" key={group.name}><div className="group-title"><span>{group.name}</span><i>{String(group.tools.length).padStart(2, "0")} TOOLS</i></div><div className="tool-cards">{group.tools.map(([name, description, href], index) => <Link className="tool-card" href={href || "#"} key={name} onClick={e => { if (!href) e.preventDefault() }}><span className="tool-icon"><FileImage size={19} /></span><span className="tool-copy"><strong>{name}</strong><span>{description}</span></span><span className="tool-index">{String(index + 1).padStart(2, "0")}</span><ArrowUpRight size={16} className="tool-arrow" /></Link>)}</div></section>)}</div>
|
||||
{!filtered.length && <div className="catalog-empty"><Sparkles size={18} /> No tools match your search.</div>}
|
||||
<footer className="catalog-footer">ALL OPERATIONS RUN LOCALLY <span>•</span> YOUR FILES NEVER LEAVE THIS DEVICE</footer>
|
||||
</div></main>
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Analytics } from '@vercel/analytics/next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'easy-png-tools / Demo',
|
||||
description: 'Technical workspace for building PNG processing pipelines.',
|
||||
generator: 'easy-png-tools',
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
colorScheme: 'light',
|
||||
themeColor: '#eef1f4',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="ru" className="bg-background"><body className="antialiased">{children}{process.env.NODE_ENV === 'production' && <Analytics />}</body></html>
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import TopBar from '@/components/top-bar'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowDownToLine, Check, ChevronDown, Download, GripVertical, Link2, MoreHorizontal, Plus, RotateCcw, Settings2, SlidersHorizontal, Upload, X } from 'lucide-react'
|
||||
|
||||
const initialSteps = [
|
||||
{ id: 1, title: 'Gradient background', type: 'BACKGROUND' },
|
||||
{ id: 2, title: 'Remove background', type: 'TRANSFORM' },
|
||||
{ id: 3, title: 'Add outline', type: 'STYLE' },
|
||||
{ id: 4, title: 'Round corners', type: 'STYLE' },
|
||||
]
|
||||
|
||||
function PreviewTile({ label, sublabel, style, active = false }: { label: string; sublabel: string; style: React.CSSProperties; active?: boolean }) {
|
||||
return <div className={`preview-tile ${active ? 'active' : ''}`}><div className="tile-canvas"><div className="image-preview" style={style}><span className="sample-icon">PNG</span><span>easy-png-tools</span></div></div><div className="tile-label"><span>{label}</span><b>{sublabel}</b></div></div>
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [steps, setSteps] = useState(initialSteps)
|
||||
const [angle, setAngle] = useState(135)
|
||||
const [radius, setRadius] = useState(18)
|
||||
const [outline, setOutline] = useState(2)
|
||||
const [language, setLanguage] = useState('RU')
|
||||
const [showIntermediate, setShowIntermediate] = useState(true)
|
||||
const [gradient, setGradient] = useState('#DCEBFF')
|
||||
const gradientStyle = useMemo(() => ({ background: `linear-gradient(${angle}deg, ${gradient}, #8BC8F5)` }), [angle, gradient])
|
||||
const finalStyle = { ...gradientStyle, borderRadius: radius, boxShadow: `0 0 0 ${outline}px #16202B` }
|
||||
|
||||
return <main className="app-shell">
|
||||
<TopBar section="WORKSPACE" status="AUTO PIPELINE" />
|
||||
<div className="page-grid">
|
||||
<section className="workspace"><div className="eyebrow">PNG PROCESSING <span>/</span> WORKSPACE</div><div className="title-row"><div><h1>Build your image pipeline.</h1><p className="lede">Chain simple tools together. Every change is processed automatically and previewed at each stage.</p></div><div className="file-chip"><Upload size={15} /><span>source.png</span><b>1.8 MB</b></div></div>
|
||||
<div className="pipeline-head"><div><span className="label">PROCESSING PIPELINE</span><strong>{steps.length} active steps <em>• LIVE</em></strong></div><button className="add-btn" onClick={() => setSteps([...steps, { id: Date.now(), title: 'New adjustment', type: 'STYLE' }])}><Plus size={15} /> Add tool</button></div>
|
||||
<div className="steps-list">{steps.map((step, index) => <article className="step-card" key={step.id}><div className="step-index">{String(index + 1).padStart(2, '0')}</div><GripVertical className="drag" size={16} /><div className="step-body"><div className="step-heading"><div><span className="step-type">{step.type}</span><h2>{step.title}</h2></div><div className="step-tools"><span className="check"><Check size={12} /> AUTO</span><button aria-label="Remove step" onClick={() => setSteps(steps.filter(item => item.id !== step.id))}><X size={16} /></button><MoreHorizontal size={17} /></div></div>{index === 0 ? <div className="controls"><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch" style={{ background: gradient }} /><input value={gradient} onChange={e => setGradient(e.target.value)} aria-label="Gradient color" /><ChevronDown size={14} /></div></div><div className="control-block"><label>DIRECTION <output>{angle}°</output></label><input type="range" min="0" max="360" value={angle} onChange={e => setAngle(Number(e.target.value))} /></div><div className="control-block"><label>OPACITY <output>100%</output></label><div className="segmented"><button className="selected">100</button><button>75</button><button>50</button><button>25</button></div></div></div> : index === 2 ? <div className="controls compact"><div className="control-block"><label>WIDTH <output>{outline}px</output></label><input type="range" min="0" max="8" value={outline} onChange={e => setOutline(Number(e.target.value))} /></div><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch dark" /><input value="#16202B" readOnly /></div></div></div> : index === 3 ? <div className="controls compact"><div className="control-block"><label>RADIUS <output>{radius}px</output></label><input type="range" min="0" max="48" value={radius} onChange={e => setRadius(Number(e.target.value))} /></div><div className="toggle-row"><span>Preserve aspect ratio</span><button className="toggle on" aria-label="Preserve aspect ratio"><i /></button></div></div> : <div className="transform-note"><SlidersHorizontal size={15} /> Automatic subject detection enabled</div>}</div></article>)}</div>
|
||||
<div className="pipeline-footer"><button className="reset-btn" onClick={() => setSteps(initialSteps)}><RotateCcw size={14} /> Reset pipeline</button><span className="auto-note"><i /> changes are applied automatically</span></div>
|
||||
</section>
|
||||
<section className="preview-panel"><div className="preview-top"><div><span className="label">PIPELINE OUTPUTS</span><strong>Visual history</strong></div><div className="preview-actions"><button className="history-toggle top-toggle" onClick={() => setShowIntermediate(!showIntermediate)} aria-expanded={showIntermediate}>{showIntermediate ? 'Hide intermediate' : 'Show intermediate'} <ChevronDown size={15} className={showIntermediate ? 'rotated' : ''} /></button><button className="download-btn"><Download size={16} /> Download result <ArrowDownToLine size={14} /></button><div className="preview-meta"><div><span>DIMENSIONS</span><b>1200 × 800 px</b></div><div><span>FORMAT</span><b>PNG-24</b></div><div><span>SIZE</span><b>1.2 MB</b></div></div><button className="icon-btn" aria-label="Preview settings"><Settings2 size={17} /></button></div></div><div className="preview-stack"><PreviewTile label="SOURCE" sublabel="original.png · 1200 × 800" style={{ background: '#8d9aa5' }} />{showIntermediate && <><PreviewTile label="STEP 01" sublabel="gradient applied" style={gradientStyle} /><PreviewTile label="STEP 02" sublabel="background removed" style={{ ...gradientStyle, clipPath: 'inset(10% 8% 10% 8% round 12px)' }} /><PreviewTile label="STEP 03" sublabel="outline added" style={{ ...gradientStyle, boxShadow: `0 0 0 ${outline}px #16202B` }} /></>}<PreviewTile label="FINAL OUTPUT" sublabel="ready · PNG-24" style={finalStyle} active /></div><p className="preview-note">Output is generated in-browser. Your files never leave this device.</p></section>
|
||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span><Link2 size={13} /> pipeline is local-only</span><span>© 2024</span></footer>
|
||||
</main>
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { CircleHelp, Moon, Sun } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
const links = [
|
||||
["Workspace", "/"],
|
||||
["Catalog", "/easy-png-tools/list-tools"],
|
||||
["Gradient", "/easy-png-tools/gradient"],
|
||||
["Background remover", "/easy-png-tools/background-remover"],
|
||||
] as const
|
||||
|
||||
export function TopBar({ section = "WORKSPACE", status = "LOCAL MODE / READY" }: { section?: string; status?: string }) {
|
||||
const pathname = usePathname()
|
||||
const [dark, setDark] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem("ep-theme")
|
||||
const isDark = stored === "dark"
|
||||
setDark(isDark)
|
||||
document.documentElement.classList.toggle("dark-mode", isDark)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = !dark
|
||||
setDark(next)
|
||||
document.documentElement.classList.toggle("dark-mode", next)
|
||||
window.localStorage.setItem("ep-theme", next ? "dark" : "light")
|
||||
}
|
||||
|
||||
return <header className="topbar"><Link href="/" className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ {section}</span></Link><nav className="top-nav" aria-label="Primary navigation">{links.map(([label, href]) => <Link key={href} className={pathname === href ? "active" : ""} href={href}>{label}</Link>)}</nav><div className="top-actions"><span className="status"><i /> {status}</span><button className="icon-btn" aria-label="Help"><CircleHelp size={17} /></button><button className="icon-btn" aria-label="Toggle theme" onClick={toggleTheme}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button><div className="language"><button className="active">RU</button><span>/</span><button>EN</button></div></div></header>
|
||||
}
|
||||
|
||||
export default TopBar
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Button as ButtonPrimitive } from '@base-ui/react/button'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
icon: 'size-8',
|
||||
'icon-xs':
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm':
|
||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,11 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "my-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "16.3.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"shadcn": "^4.8.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"hono": "4.12.25"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
allowBuilds:
|
||||
msw: set this to true or false
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
|
||||
import { join, dirname, basename, extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Usage: node extract-static.mjs <source-out-dir> <dest-dir>
|
||||
// e.g. node extract-static.mjs .next\server\app ..\refs-html
|
||||
|
||||
const root = process.argv[2];
|
||||
const dest = process.argv[3];
|
||||
|
||||
// --- inject a minimal theme toggle (extraction strips all <script> tags, so the
|
||||
// ref pages would otherwise have no working dark-mode switch) ---
|
||||
const THEME_TOGGLE = `
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
||||
var shell = document.querySelector('.app-shell');
|
||||
if (!btn || window.__themeToggleInjected) return;
|
||||
window.__themeToggleInjected = true;
|
||||
btn.addEventListener('click', function () {
|
||||
document.documentElement.classList.toggle('dark-mode');
|
||||
if (shell) shell.classList.toggle('dark-mode');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
|
||||
function injectThemeToggle(html) {
|
||||
if (html.includes("__themeToggleInjected")) return html;
|
||||
const tag = "</body>";
|
||||
if (html.includes(tag)) return html.replace(tag, THEME_TOGGLE + "\n" + tag);
|
||||
if (html.includes("</html>")) return html.replace("</html>", THEME_TOGGLE + "\n</html>");
|
||||
return html + "\n" + THEME_TOGGLE;
|
||||
}
|
||||
|
||||
// --- CSS: find the compiled chunk and pretty-print it ---
|
||||
const cssChunks = join(root, "..", "..", "static", "chunks");
|
||||
|
||||
// --- fonts: inline @font-face + copy .woff2 next to the pages ---
|
||||
// The Next reference never actually loads its `--font-sans/--font-mono`
|
||||
// ("IBM Plex Sans"/"IBM Plex Mono"), so the extracted snapshots render with
|
||||
// the system fallback. Self-host the fontsource in a `assets/fonts/` dir next
|
||||
// to the HTML so the refs show the intended typefaces.
|
||||
|
||||
const FONT_ASSETS_DIR = "assets/fonts";
|
||||
|
||||
// fontsource packages relative to `web/` — same weights as design2.css imports.
|
||||
const FONT_SOURCES = [
|
||||
"ibm-plex-sans/400.css",
|
||||
"ibm-plex-sans/500.css",
|
||||
"ibm-plex-sans/600.css",
|
||||
"ibm-plex-sans/700.css",
|
||||
"ibm-plex-mono/400.css",
|
||||
"ibm-plex-mono/500.css",
|
||||
];
|
||||
|
||||
// Resolve `node_modules/@fontsource/<pkg>/<weight>.css`:
|
||||
// 1. sibling repo dir (root `web/…` or standalone `refs/…` layouts);
|
||||
// 2. upward walk from this script (monorepo with hoisted root deps).
|
||||
function findFontsourceRoot() {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [join(here, "..", "web", "node_modules", "@fontsource")];
|
||||
for (let dir = here; ; ) {
|
||||
candidates.push(join(dir, "node_modules", "@fontsource"));
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, FONT_SOURCES[0]))) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildFontFaces(dest) {
|
||||
const fontsource = findFontsourceRoot();
|
||||
if (!fontsource) {
|
||||
console.warn("WARN: @fontsource not found (web/node_modules missing?) — skipping font embedding");
|
||||
return { style: "", copied: [] };
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const raw = [];
|
||||
for (const rel of FONT_SOURCES) {
|
||||
const pkg = dirname(rel); // e.g. ibm-plex-sans
|
||||
const name = basename(rel, extname(rel)); // e.g. 400.css -> 400
|
||||
const file = join(fontsource, pkg, `${name}.css`);
|
||||
if (!existsSync(file)) continue;
|
||||
raw.push([file, readFileSync(file, "utf8"), pkg]);
|
||||
}
|
||||
|
||||
// Drop duplicate @font-face blocks (each `<name>.css` repeats across weights).
|
||||
let css = raw.map(([, text]) => text.replace(/\/\*[^*]*\*\//g, "").trim()).join("\n");
|
||||
css = css.replace(/@font-face\s*\{[\s\S]*?\}/g, (block) =>
|
||||
seen.has(block) ? "" : (seen.add(block), block)
|
||||
);
|
||||
|
||||
// Copy the woff2 files and point the css at `assets/fonts/`. Drop the woff
|
||||
// entries — modern browsers only need woff2 (dev-only static snapshots).
|
||||
const copied = [];
|
||||
const destFonts = join(dest, FONT_ASSETS_DIR);
|
||||
css = css.replace(/url\(\.\/files\/([^)]+\.woff2)\)\s*format\('woff2'\)\s*,\s*url\(\.\/files\/[^)]*\.woff\)\s*format\('woff'\)/g, (_m, f) => {
|
||||
const src = (() => {
|
||||
for (const [, , pkg] of raw) {
|
||||
const candidate = join(fontsource, pkg, "files", f);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
if (!src) {
|
||||
console.warn(`WARN: font file not found: ${f}`);
|
||||
return "";
|
||||
}
|
||||
mkdirSync(destFonts, { recursive: true });
|
||||
writeFileSync(join(destFonts, f), readFileSync(src));
|
||||
if (!copied.includes(f)) copied.push(f);
|
||||
return `url(${FONT_ASSETS_DIR}/${f}) format('woff2')`;
|
||||
});
|
||||
|
||||
return { style: "<style>\n" + css.trim() + "\n</style>", copied };
|
||||
}
|
||||
const cssFile = readdirSync(cssChunks)
|
||||
.filter((f) => f.endsWith(".css"))
|
||||
.sort((a, b) => statSync(join(cssChunks, b)).size - statSync(join(cssChunks, a)).size)[0];
|
||||
const css = readFileSync(join(cssChunks, cssFile), "utf8");
|
||||
|
||||
const prettyCss = css
|
||||
.replace(/\r/g, "")
|
||||
.replace(/\{/g, " {\n ")
|
||||
.replace(/;/g, ";\n ")
|
||||
.replace(/\}/g, "\n}\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
// --- conservative html formatter ---
|
||||
|
||||
const VOID = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
const RAW = new Set(["pre", "textarea", "script", "style"]);
|
||||
|
||||
function fmtHtml(html) {
|
||||
const tokens = html.match(/<!--[\s\S]*?-->|<!doctype[^>]*>|<\/?[a-zA-Z][^>]*>|[^<]+|</gi) || [];
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let rawTag = null;
|
||||
|
||||
for (const tk of tokens) {
|
||||
if (rawTag) {
|
||||
out.push(tk);
|
||||
if (new RegExp(`</${rawTag}\\s*>`, "i").test(tk)) {
|
||||
depth--;
|
||||
rawTag = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const isOpen = /^<[a-zA-Z]/.test(tk);
|
||||
const isClose = /^<\//.test(tk);
|
||||
let name = "";
|
||||
if (isOpen || isClose) {
|
||||
name = (tk.match(/^<\/?([a-zA-Z0-9-]+)/) || [])[1]?.toLowerCase() || "";
|
||||
}
|
||||
|
||||
if (isOpen && RAW.has(name)) {
|
||||
out.push("\n" + " ".repeat(depth) + tk);
|
||||
if (!/\/>$/.test(tk)) {
|
||||
depth++;
|
||||
rawTag = name;
|
||||
}
|
||||
} else if (isOpen && !RAW.has(name)) {
|
||||
out.push("\n" + " ".repeat(depth) + tk);
|
||||
if (!/\/>$/.test(tk) && !VOID.has(name)) depth++;
|
||||
} else if (isClose) {
|
||||
depth = Math.max(0, depth - 1);
|
||||
out.push("\n" + " ".repeat(depth) + tk);
|
||||
} else if (/^</.test(tk)) {
|
||||
out.push("\n" + " ".repeat(depth) + tk);
|
||||
} else {
|
||||
const t = tk.replace(/\s+/g, " ");
|
||||
if (t.trim()) out.push(t);
|
||||
}
|
||||
}
|
||||
return out
|
||||
.join("")
|
||||
.replace(/^\n/, "")
|
||||
.replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
// --- auto-discover pages by walking the export dir for *.html ---
|
||||
function walk(dir, base = "") {
|
||||
const found = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const rel = base ? join(base, entry) : entry;
|
||||
if (statSync(full).isDirectory()) {
|
||||
found.push(...walk(full, rel));
|
||||
} else if (entry.endsWith(".html")) {
|
||||
found.push(rel);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const pages = walk(root)
|
||||
// skip Next internals
|
||||
.filter((rel) => !basename(rel).startsWith("_") && !basename(rel).startsWith("404"))
|
||||
// `index.html` ╨╜╨░╨╝ ╨╜╨╡ ╨╜╤Г╨╢╨╡╨╜ ╨║╨░╨║ ╤А╨╡╤Д, ╨┐╨╛╤Н╤В╨╛╨╝╤Г ╨╜╨╡ ╨│╨╡╨╜╨╡╤А╨╕╤А╤Г╨╡╨╝ ╨╡╨│╨╛.
|
||||
.filter((rel) => basename(rel).toLowerCase() !== "index.html")
|
||||
.map((rel) => [rel, basename(rel)]);
|
||||
|
||||
console.log(`discovered ${pages.length} page(s):`, pages.map((p) => p[1]).join(", "));
|
||||
|
||||
const { style: fontFacesStyle, copied: fontFiles } = buildFontFaces(dest);
|
||||
if (fontFiles.length) {
|
||||
console.log(`fonts: ${fontFiles.length} file(s) -> ${join(dest, FONT_ASSETS_DIR)}`);
|
||||
}
|
||||
|
||||
for (const [src, out] of pages) {
|
||||
let html = readFileSync(join(root, src), "utf8");
|
||||
|
||||
// strip scripts — pure static snapshot
|
||||
html = html.replace(/<script[\s\S]*?<\/script>/g, "");
|
||||
html = html.replace(/<link[^>]+rel="preload"[^>]+as="script"[^>]*>/g, "");
|
||||
|
||||
// inline compiled css instead of linking /_next/...
|
||||
html = html.replace(/<link[^>]+rel="stylesheet"[^>]*>/g, () => "<style>\n" + prettyCss + "\n</style>");
|
||||
|
||||
// fonts first, so any later @font-face overrides win for the same family
|
||||
if (fontFacesStyle) html = html.replace("<head>", "<head>\n " + fontFacesStyle.trim());
|
||||
|
||||
html = fmtHtml(html);
|
||||
html = injectThemeToggle(html);
|
||||
|
||||
const file = join(dest, out);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, html);
|
||||
console.log("written:", file);
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 79 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 73 B |
@@ -0,0 +1 @@
|
||||
this is definitely not a png file
|
||||
@@ -0,0 +1,329 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(data) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++)
|
||||
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const out = Buffer.alloc(8 + data.length + 4);
|
||||
out.writeUInt32BE(data.length, 0);
|
||||
out.write(type, 4, "ascii");
|
||||
data.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function makePng(width, height, pixelAt) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter
|
||||
ihdr[12] = 0; // interlace
|
||||
const stride = width * 4 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * stride] = 0; // filter: none
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 4;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
raw[p + 3] = a;
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function makePalettePng(width, height, palette, pixelAt) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 3; // color type: indexed
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
// PLTE: palette entries (R,G,B × count), padded to multiple of 3
|
||||
const plteData = Buffer.alloc(256 * 3);
|
||||
for (let i = 0; i < palette.length && i < 256; i++) {
|
||||
plteData[i * 3] = palette[i][0];
|
||||
plteData[i * 3 + 1] = palette[i][1];
|
||||
plteData[i * 3 + 2] = palette[i][2];
|
||||
}
|
||||
|
||||
// Raw image data: 1 byte per pixel (index)
|
||||
const stride = width + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * stride] = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
raw[y * stride + 1 + x] = pixelAt(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("PLTE", plteData),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function make16bitPng(width, height, pixelAt) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 16; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const stride = width * 8 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * stride] = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 8;
|
||||
raw.writeUInt16BE(r, p);
|
||||
raw.writeUInt16BE(g, p + 2);
|
||||
raw.writeUInt16BE(b, p + 4);
|
||||
raw.writeUInt16BE(a, p + 6);
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function makeCorruptPng() {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(4, 0);
|
||||
ihdr.writeUInt32BE(4, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2; // RGB
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const stride = 4 * 3 + 1;
|
||||
const raw = Buffer.alloc(4 * stride);
|
||||
for (let y = 0; y < 4; y++) {
|
||||
raw[y * stride] = 0;
|
||||
for (let x = 0; x < 4; x++) {
|
||||
const p = y * stride + 1 + x * 3;
|
||||
raw[p] = 255;
|
||||
raw[p + 1] = 0;
|
||||
raw[p + 2] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const idat = chunk("IDAT", deflateSync(raw));
|
||||
// Corrupt CRC: flip a byte in the CRC field
|
||||
idat[idat.length - 1] ^= 0xff;
|
||||
|
||||
return Buffer.concat([sig, chunk("IHDR", ihdr), idat, chunk("IEND", Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
function makeTruncatedPng() {
|
||||
return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
|
||||
}
|
||||
|
||||
const outDir = join(__dirname);
|
||||
|
||||
// 1. PNG 1×1
|
||||
writeFileSync(join(outDir, "1x1.png"), makePng(1, 1, () => [255, 0, 0, 255]));
|
||||
|
||||
// 2. PNG 1×1 fully transparent
|
||||
writeFileSync(
|
||||
join(outDir, "1x1-transparent.png"),
|
||||
makePng(1, 1, () => [0, 0, 0, 0]),
|
||||
);
|
||||
|
||||
// 3. Large 1024×1024 checkerboard (≈1 megapixel RGBA)
|
||||
writeFileSync(
|
||||
join(outDir, "1024x1024-checker.png"),
|
||||
makePng(1024, 1024, (x, y) =>
|
||||
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255],
|
||||
),
|
||||
);
|
||||
|
||||
// 4. 1920×1080 gradient (≈2 megapixels, tests larger displays)
|
||||
writeFileSync(
|
||||
join(outDir, "1920x1080-gradient.png"),
|
||||
makePng(1920, 1080, (x, y) => [
|
||||
Math.floor((x / 1920) * 255),
|
||||
Math.floor((y / 1080) * 255),
|
||||
128,
|
||||
255,
|
||||
]),
|
||||
);
|
||||
|
||||
// 5. Transparency checkerboard (black/white with alpha pattern)
|
||||
writeFileSync(
|
||||
join(outDir, "alpha-checkerboard.png"),
|
||||
makePng(64, 64, (x, y) => {
|
||||
const alpha = (x + y) % 3 === 0 ? 0 : 255;
|
||||
const base = (x % 2 === y % 2) ? 0 : 255;
|
||||
return [base, base, base, alpha];
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Palette / indexed-color PNG (4 colors: red, green, blue, white)
|
||||
const palette = [
|
||||
[255, 0, 0],
|
||||
[0, 255, 0],
|
||||
[0, 0, 255],
|
||||
[255, 255, 255],
|
||||
];
|
||||
writeFileSync(
|
||||
join(outDir, "palette-4colors.png"),
|
||||
makePalettePng(32, 32, palette, (x, y) => {
|
||||
const idx = (Math.floor(x / 8) + Math.floor(y / 8)) % palette.length;
|
||||
return idx;
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. Palette with many colors (256-color full palette)
|
||||
const fullPalette = Array.from({ length: 256 }, (_, i) => [
|
||||
i,
|
||||
(i * 3) % 256,
|
||||
(i * 7) % 256,
|
||||
]);
|
||||
writeFileSync(
|
||||
join(outDir, "palette-256.png"),
|
||||
makePalettePng(64, 64, fullPalette, (x, y) => (x + y * 64) % 256),
|
||||
);
|
||||
|
||||
// 8. 16-bit RGBA PNG (subtle gradient to test high bit depth)
|
||||
writeFileSync(
|
||||
join(outDir, "16bit-subtle.png"),
|
||||
make16bitPng(32, 32, (x, y) => [
|
||||
Math.floor((x / 32) * 65535),
|
||||
Math.floor((y / 32) * 65535),
|
||||
32768,
|
||||
65535,
|
||||
]),
|
||||
);
|
||||
|
||||
// 9. Corrupt PNG (bad CRC in IDAT)
|
||||
writeFileSync(join(outDir, "corrupt-bad-crc.png"), makeCorruptPng());
|
||||
|
||||
// 10. Truncated PNG (only header, no data)
|
||||
writeFileSync(join(outDir, "truncated.png"), makeTruncatedPng());
|
||||
|
||||
// 11. Non-PNG file disguised as PNG
|
||||
writeFileSync(
|
||||
join(outDir, "fake-png.txt"),
|
||||
Buffer.from("this is definitely not a png file"),
|
||||
);
|
||||
|
||||
// 12. All-black fully opaque
|
||||
writeFileSync(
|
||||
join(outDir, "all-black-opaque.png"),
|
||||
makePng(64, 64, () => [0, 0, 0, 255]),
|
||||
);
|
||||
|
||||
// 13. All-white fully opaque
|
||||
writeFileSync(
|
||||
join(outDir, "all-white-opaque.png"),
|
||||
makePng(64, 64, () => [255, 255, 255, 255]),
|
||||
);
|
||||
|
||||
// 14. All-transparent
|
||||
writeFileSync(
|
||||
join(outDir, "all-transparent.png"),
|
||||
makePng(64, 64, () => [0, 0, 0, 0]),
|
||||
);
|
||||
|
||||
// 15. Semi-transparent gradient (alpha 0→255 across width)
|
||||
writeFileSync(
|
||||
join(outDir, "alpha-gradient.png"),
|
||||
makePng(128, 64, (x, y) => [
|
||||
0,
|
||||
100,
|
||||
255,
|
||||
Math.floor((x / 128) * 255),
|
||||
]),
|
||||
);
|
||||
|
||||
// 16. Noise pattern (random-ish pixels)
|
||||
writeFileSync(
|
||||
join(outDir, "noise-pattern.png"),
|
||||
makePng(100, 100, (x, y) => {
|
||||
const v = ((x * 7919 + y * 104729) % 256);
|
||||
return [v, (v * 3) % 256, (v * 7) % 256, 255];
|
||||
}),
|
||||
);
|
||||
|
||||
// 17. Minimal size: 2×2 with extreme colors
|
||||
writeFileSync(
|
||||
join(outDir, "2x2-extreme.png"),
|
||||
makePng(2, 2, (x, y) => {
|
||||
if (x === 0 && y === 0) return [0, 0, 0, 255];
|
||||
if (x === 1 && y === 0) return [255, 255, 255, 255];
|
||||
if (x === 0 && y === 1) return [255, 0, 0, 128];
|
||||
return [0, 0, 255, 0];
|
||||
}),
|
||||
);
|
||||
|
||||
// 18. Long strip (1×4000) — tests scroll / vertical tools
|
||||
writeFileSync(
|
||||
join(outDir, "1x4000-strip.png"),
|
||||
makePng(1, 4000, (x, y) => [y % 256, (y * 2) % 256, (y * 3) % 256, 255]),
|
||||
);
|
||||
|
||||
// 19. Wide strip (4000×1) — tests horizontal handling
|
||||
writeFileSync(
|
||||
join(outDir, "4000x1-strip.png"),
|
||||
makePng(4000, 1, (x, y) => [x % 256, (x * 2) % 256, (x * 3) % 256, 255]),
|
||||
);
|
||||
|
||||
// 20. PNG with very high alpha variance (sparse transparency)
|
||||
writeFileSync(
|
||||
join(outDir, "sparse-alpha.png"),
|
||||
makePng(100, 100, (x, y) => {
|
||||
const sparse = ((x * 31 + y * 17) % 100) < 10;
|
||||
return [sparse ? 0 : 100, sparse ? 0 : 200, 255, sparse ? 0 : 255];
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("Generated 20 edge case PNG files in:", outDir);
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 722 B |
|
After Width: | Height: | Size: 16 B |